use std::collections::{BTreeMap, BTreeSet};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use anyhow::Context;
use clap::Parser;
use enwiro_sdk::cli::{CookArgs, CookbookCore};
use enwiro_sdk::cookbook::{CookbookCapability, PruneOutcome};
use enwiro_sdk::git::remove_worktree;
use enwiro_sdk::metadata::DeclaredCapabilities;
use enwiro_sdk::{CookbookMetadata, CookbookPayload, PatternRecipe, Recipe, RecipeItem};
use serde_derive::{Deserialize, Serialize};
const LISTEN_POLL_INTERVAL: Duration = Duration::from_secs(60);
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ConfigurationValues {
pub worktree_dir: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RepoConfig {
pub repo: String,
pub local_path: PathBuf,
}
impl RepoConfig {
fn short_name(&self) -> &str {
self.repo
.rsplit_once('/')
.map_or(&self.repo, |(_, name)| name)
}
fn folder_name(&self) -> Option<String> {
git_repo_display_name(&self.local_path)
}
}
#[derive(Debug, Serialize, Deserialize, Default)]
struct GitCookbookConfig {
repo_globs: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum GithubItemKind {
PullRequest { head_ref_name: String },
Issue,
}
#[derive(Debug, Clone)]
pub struct GithubItem {
pub number: u64,
pub title: String,
pub repo: String,
pub kind: GithubItemKind,
pub updated_at: String,
}
#[derive(Parser)]
enum EnwiroCookbookGithub {
#[command(flatten)]
Core(CookbookCore),
Gear(GearArgs),
ExternalPaths(ExternalPathsArgs),
Describe(DescribeArgs),
Prune(PruneArgs),
Listen,
}
#[derive(clap::Args)]
pub struct GearArgs {
recipe_name: String,
}
#[derive(clap::Args)]
pub struct ExternalPathsArgs {
recipe_name: String,
}
#[derive(clap::Args)]
pub struct DescribeArgs {
recipe_name: String,
}
#[derive(clap::Args)]
pub struct PruneArgs {
recipe_name: String,
}
fn short_path_hash(path: &Path) -> String {
use sha2::{Digest, Sha256};
let hash = Sha256::digest(path.to_string_lossy().as_bytes());
hex::encode(hash)[..8].to_string()
}
fn default_worktree_dir() -> anyhow::Result<PathBuf> {
let base = dirs::data_dir().context("Could not determine data directory (is $HOME set?)")?;
Ok(base.join("enwiro").join("worktrees").join("pr"))
}
fn worktree_base_dir(config: &ConfigurationValues) -> anyhow::Result<PathBuf> {
match &config.worktree_dir {
Some(dir) => Ok(PathBuf::from(dir)),
None => default_worktree_dir(),
}
}
fn parse_github_remote(url: &str) -> Option<String> {
let url = url.trim();
if let Some(rest) = url.strip_prefix("git@github.com:") {
let repo = rest.strip_suffix(".git").unwrap_or(rest);
return if repo.contains('/') {
Some(repo.to_string())
} else {
None
};
}
let path = url
.strip_prefix("https://github.com/")
.or_else(|| url.strip_prefix("http://github.com/"))
.or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
let repo = path.strip_suffix(".git").unwrap_or(path);
if repo.contains('/') {
Some(repo.to_string())
} else {
None
}
}
fn discover_github_repos_from_config(
git_config: &GitCookbookConfig,
) -> anyhow::Result<Vec<RepoConfig>> {
let mut results = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for glob_pattern in &git_config.repo_globs {
let paths = glob::glob(glob_pattern)
.with_context(|| format!("Could not parse glob pattern: {}", glob_pattern))?;
for path in paths.flatten() {
let repo = match git2::Repository::open(&path) {
Ok(r) => r,
Err(_) => continue,
};
let origin = match repo.find_remote("origin") {
Ok(r) => r,
Err(_) => continue,
};
let url = match origin.url() {
Ok(u) => u.to_string(),
Err(_) => continue,
};
if let Some(github_repo) = parse_github_remote(&url)
&& seen.insert(github_repo.clone())
{
let canonical_path = path.canonicalize().unwrap_or(path);
tracing::debug!(repo = %github_repo, path = %canonical_path.display(), "Discovered GitHub repo");
results.push(RepoConfig {
repo: github_repo,
local_path: canonical_path,
});
}
}
}
Ok(results)
}
fn discover_github_repos() -> anyhow::Result<Vec<RepoConfig>> {
let git_config_json = enwiro_sdk::config::load_user_config("cookbook-git")
.context("Could not load git cookbook configuration")?;
let git_config: GitCookbookConfig = serde_json::from_value(git_config_json)
.context("Could not deserialize git cookbook configuration")?;
discover_github_repos_from_config(&git_config)
}
const FIX_CI_VARIANT: &str = "fix-ci";
fn parse_recipe_name(name: &str) -> anyhow::Result<(&str, u64, bool)> {
let (repo, number_str) = name
.rsplit_once('#')
.context("Recipe name must contain '#' (expected format: repo#123)")?;
let (number_str, is_fix_ci_variant) = match number_str.split_once('@') {
Some((number_str, variant)) => {
anyhow::ensure!(
variant == FIX_CI_VARIANT,
"Unrecognized goal variant '@{}' (expected '@{}')",
variant,
FIX_CI_VARIANT
);
(number_str, true)
}
None => (number_str, false),
};
let number = number_str
.parse::<u64>()
.with_context(|| format!("Invalid issue/PR number: {}", number_str))?;
Ok((repo, number, is_fix_ci_variant))
}
const RUN_SEPARATOR: &str = ".gha@run-";
fn parse_run_recipe_name(name: &str) -> Option<(&str, u64)> {
let (repo, run_id) = name.rsplit_once(RUN_SEPARATOR)?;
if repo.is_empty() || !run_id.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
Some((repo, run_id.parse().ok()?))
}
enum RecipeName<'a> {
Item {
repo: &'a str,
number: u64,
is_fix_ci_variant: bool,
},
Run {
repo: &'a str,
run_id: u64,
},
}
impl<'a> RecipeName<'a> {
fn parse(name: &'a str) -> anyhow::Result<Self> {
if let Some((repo, run_id)) = parse_run_recipe_name(name) {
return Ok(Self::Run { repo, run_id });
}
let (repo, number, is_fix_ci_variant) = parse_recipe_name(name)?;
Ok(Self::Item {
repo,
number,
is_fix_ci_variant,
})
}
fn repo(&self) -> &'a str {
match self {
Self::Item { repo, .. } | Self::Run { repo, .. } => repo,
}
}
}
fn build_search_query(repos: &[String], type_filter: &str) -> String {
let repo_filters: Vec<String> = repos.iter().map(|r| format!("repo:{}", r)).collect();
let date_qualifier = if type_filter.contains("is:issue") {
String::new()
} else {
let cutoff = chrono::Utc::now() - chrono::Duration::days(30);
format!(" updated:>{}", cutoff.format("%Y-%m-%d"))
};
format!(
"{} {}{} sort:updated-desc",
type_filter,
repo_filters.join(" "),
date_qualifier,
)
}
#[derive(Deserialize)]
struct GraphQlResponse {
data: GraphQlData,
}
#[derive(Deserialize)]
struct GraphQlData {
search: GraphQlSearch,
}
#[derive(Deserialize)]
struct GraphQlSearch {
nodes: Vec<GraphQlNode>,
}
#[derive(Deserialize)]
struct GraphQlNode {
number: u64,
title: String,
#[serde(rename = "headRefName", default)]
head_ref_name: Option<String>,
#[serde(rename = "updatedAt", default)]
updated_at: Option<String>,
repository: GraphQlRepo,
}
#[derive(Deserialize)]
struct GraphQlRepo {
#[serde(rename = "nameWithOwner")]
name_with_owner: String,
}
fn extract_short_repo_name(name_with_owner: String) -> String {
name_with_owner
.rsplit_once('/')
.map(|(_, name)| name.to_string())
.unwrap_or(name_with_owner)
}
fn parse_search_response(json: &str) -> anyhow::Result<Vec<GithubItem>> {
let response: GraphQlResponse =
serde_json::from_str(json).context("Could not parse GraphQL response")?;
Ok(response
.data
.search
.nodes
.into_iter()
.map(|node| {
let repo = extract_short_repo_name(node.repository.name_with_owner);
let kind = match node.head_ref_name {
Some(head_ref_name) => GithubItemKind::PullRequest { head_ref_name },
None => GithubItemKind::Issue,
};
GithubItem {
number: node.number,
title: node.title,
repo,
kind,
updated_at: node.updated_at.unwrap_or_default(),
}
})
.collect())
}
const SEARCH_QUERY: &str = r#"query($searchQuery: String!) {
search(query: $searchQuery, type: ISSUE, first: 100) {
nodes {
... on PullRequest {
number
title
headRefName
updatedAt
repository { nameWithOwner }
}
... on Issue {
number
title
updatedAt
repository { nameWithOwner }
}
}
}
}"#;
fn interpret_gh_output(
stdout: &[u8],
stderr: &[u8],
success: bool,
) -> anyhow::Result<Vec<GithubItem>> {
if !success {
let stderr_str = String::from_utf8_lossy(stderr);
if stderr_str.contains("GitHub search returned 100 results (the maximum)") {
tracing::warn!(
"GitHub search returned 100 results (the maximum). Some results may be missing."
);
} else {
anyhow::bail!(
"gh api graphql failed: {}. Is gh authenticated? (try: gh auth login)",
stderr_str
);
}
}
let stdout_str = String::from_utf8(stdout.to_vec()).context("gh produced invalid UTF-8")?;
parse_search_response(&stdout_str)
}
fn search_github(repos: &[String], type_filter: &str) -> anyhow::Result<Vec<GithubItem>> {
if repos.is_empty() {
return Ok(Vec::new());
}
let search_query = build_search_query(repos, type_filter);
let output = Command::new("gh")
.args([
"api",
"graphql",
"-F",
&format!("searchQuery={}", search_query),
"-f",
&format!("query={}", SEARCH_QUERY),
])
.output()
.context(
"Failed to run gh CLI. Is it installed and authenticated? \
(https://cli.github.com/, then run: gh auth login)",
)?;
interpret_gh_output(&output.stdout, &output.stderr, output.status.success())
}
fn sort_items_by_date(items: &mut [GithubItem]) {
items.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
}
fn compute_sort_order(index: usize, total: usize) -> u32 {
if total <= 1 {
0
} else {
((index * 100) / (total - 1)) as u32
}
}
fn git_repo_display_name(local_path: &Path) -> Option<String> {
local_path
.file_name()
.and_then(|n| n.to_str())
.map(str::to_string)
}
fn cooked_branch_name(item: &GithubItem) -> String {
match item.kind {
GithubItemKind::PullRequest { .. } => format!("pr-{}", item.number),
GithubItemKind::Issue => format!("issue-{}", item.number),
}
}
fn goal_detail(kind: &str, label: String, item: &GithubItem) -> enwiro_sdk::goal::GoalDetail {
enwiro_sdk::goal::GoalDetail {
kind: kind.to_string(),
label,
detail: Some(serde_json::json!({"repo": item.repo, "number": item.number})),
}
}
fn sanitize_title(title: &str) -> String {
title.replace(['\n', '\0', '\x1f'], " ")
}
fn recipes_for_item(
item: &GithubItem,
index: usize,
total: usize,
display_names: &std::collections::HashMap<String, String>,
) -> Vec<Recipe> {
let safe_title = sanitize_title(&item.title);
let sort_order = compute_sort_order(index, total);
let equivalent_to = display_names
.get(&item.repo)
.map(|display| vec![format!("{}@{}", display, cooked_branch_name(item))])
.unwrap_or_default();
let base_name = format!("{}#{}", item.repo, item.number);
match &item.kind {
GithubItemKind::Issue => {
let mut recipe = Recipe::with_description(base_name, format!("[issue] {}", safe_title));
recipe.sort_order = sort_order;
recipe.equivalent_to = equivalent_to;
recipe.goal = Some(goal_detail("github_issue", safe_title, item));
vec![recipe]
}
GithubItemKind::PullRequest { .. } => {
let mut work_on =
Recipe::with_description(base_name.clone(), format!("[PR] {}", safe_title));
work_on.sort_order = sort_order;
work_on.equivalent_to = equivalent_to.clone();
work_on.goal = Some(goal_detail("work_on", safe_title.clone(), item));
let fix_ci_label = format!("Fix CI for {}", safe_title);
let mut fix_ci = Recipe::with_description(
format!("{base_name}@{FIX_CI_VARIANT}"),
format!("[PR] {}", fix_ci_label),
);
fix_ci.sort_order = sort_order;
fix_ci.equivalent_to = equivalent_to;
fix_ci.goal = Some(goal_detail("fix_ci", fix_ci_label, item));
vec![work_on, fix_ci]
}
}
}
fn collect_recipes() -> Vec<Recipe> {
let Ok(repos) = discover_github_repos() else {
return Vec::new();
};
let repo_names: Vec<String> = repos.iter().map(|r| r.repo.clone()).collect();
let display_names: std::collections::HashMap<String, String> = repos
.iter()
.filter_map(|r| {
git_repo_display_name(&r.local_path)
.map(|name| (extract_short_repo_name(r.repo.clone()), name))
})
.collect();
let prs = search_github(&repo_names, "is:pr is:open").unwrap_or_default();
let issues = search_github(&repo_names, "is:issue is:open assignee:@me").unwrap_or_default();
let mut items: Vec<GithubItem> = prs.into_iter().chain(issues).collect();
sort_items_by_date(&mut items);
let total = items.len();
items
.iter()
.enumerate()
.flat_map(|(index, item)| recipes_for_item(item, index, total, &display_names))
.collect()
}
fn list_recipes() -> anyhow::Result<()> {
for recipe in collect_recipes() {
println!("{}", recipe.to_jsonl());
}
Ok(())
}
fn canonical_full_names(repos: &[RepoConfig]) -> BTreeMap<String, String> {
let mut full_names: Vec<String> = repos.iter().map(|r| r.repo.clone()).collect();
full_names.sort();
full_names.dedup();
let mut full_by_short: BTreeMap<String, String> = BTreeMap::new();
for full_name in full_names {
full_by_short
.entry(extract_short_repo_name(full_name.clone()))
.or_insert(full_name);
}
full_by_short
}
fn item_pattern_recipes(repos: &[RepoConfig]) -> Vec<RecipeItem> {
let full_by_short = canonical_full_names(repos);
let names: BTreeSet<String> = aliases(repos).into_iter().map(|(name, _)| name).collect();
names
.into_iter()
.map(|name| {
let url = full_by_short
.get(&name)
.map(|full_name| github_url_rule(&name, full_name));
RecipeItem::Pattern(PatternRecipe {
pattern: format!(
"{}#(?P<number>[0-9]{{1,19}})(?:@{FIX_CI_VARIANT})?",
enwiro_sdk::recipe_pattern::escape(&name)
),
description: Some(format!(
"Work on PR or issue #{{number}} in {}",
enwiro_sdk::recipe_pattern::escape_template(&name)
)),
url,
})
})
.collect()
}
fn github_url_rule(short_name: &str, full_name: &str) -> enwiro_sdk::url_rule::UrlRule {
enwiro_sdk::url_rule::UrlRule {
pattern: format!(
"https://github.com/{}/:kind(pull|issues)/:number([0-9]+){{/*}}?",
full_name
),
recipe: format!(
"{}#{{number}}",
enwiro_sdk::recipe_pattern::escape_template(short_name)
),
}
}
fn run_pattern_recipes(repos: &[RepoConfig]) -> Vec<RecipeItem> {
let full_by_short = canonical_full_names(repos);
let names: BTreeSet<String> = aliases(repos).into_iter().map(|(name, _)| name).collect();
names
.into_iter()
.map(|name| {
let url = full_by_short
.get(&name)
.map(|full_name| github_run_url_rule(&name, full_name));
RecipeItem::Pattern(PatternRecipe {
pattern: format!(
"{}{}(?P<run_id>[0-9]{{1,19}})",
enwiro_sdk::recipe_pattern::escape(&name),
enwiro_sdk::recipe_pattern::escape(RUN_SEPARATOR)
),
description: Some(format!(
"Work on a fix for CI run {{run_id}} in {}",
enwiro_sdk::recipe_pattern::escape_template(&name)
)),
url,
})
})
.collect()
}
fn github_run_url_rule(short_name: &str, full_name: &str) -> enwiro_sdk::url_rule::UrlRule {
enwiro_sdk::url_rule::UrlRule {
pattern: format!(
"https://github.com/{}/actions/runs/:run_id([0-9]+){{/*}}?",
full_name
),
recipe: format!(
"{}{}{{run_id}}",
enwiro_sdk::recipe_pattern::escape_template(short_name),
enwiro_sdk::recipe_pattern::escape_template(RUN_SEPARATOR)
),
}
}
fn collect_recipe_items() -> Vec<RecipeItem> {
let mut items: Vec<RecipeItem> = collect_recipes()
.into_iter()
.map(RecipeItem::Concrete)
.collect();
if let Ok(repos) = discover_github_repos() {
items.extend(item_pattern_recipes(&repos));
items.extend(run_pattern_recipes(&repos));
}
items
}
fn collect_status_events(
config: &ConfigurationValues,
done_cache: &mut std::collections::HashSet<String>,
) -> Vec<enwiro_sdk::listen::RecipeUpdate> {
let Ok(repos) = discover_github_repos() else {
return Vec::new();
};
let mut events = Vec::new();
for repo_config in &repos {
events.extend(repo_status_events(config, repo_config, done_cache));
}
events
}
fn repo_status_events(
config: &ConfigurationValues,
repo_config: &RepoConfig,
done_cache: &mut std::collections::HashSet<String>,
) -> Vec<enwiro_sdk::listen::RecipeUpdate> {
use enwiro_sdk::listen::RecipeUpdate;
use enwiro_sdk::status::{DoneOutcome, Status};
let short_repo = extract_short_repo_name(repo_config.repo.clone());
let Ok(repo_dir) = repo_worktree_dir(config, repo_config, &short_repo) else {
return Vec::new();
};
let Ok(entries) = std::fs::read_dir(&repo_dir) else {
return Vec::new(); };
let mut events = Vec::new();
for entry in entries.flatten() {
let Some((kind, number)) = parse_cooked_env_dir(&entry.file_name().to_string_lossy())
else {
continue;
};
let recipe = format!("{short_repo}#{number}");
if !done_cache.contains(&recipe) {
if !forge_item_is_done(&repo_config.repo, kind, number) {
continue;
}
done_cache.insert(recipe.clone());
}
events.push(RecipeUpdate::StatusChanged {
recipe,
status: Status::Done {
outcome: Some(DoneOutcome::Completed),
},
});
}
events
}
fn parse_cooked_env_dir(dir_name: &str) -> Option<(&'static str, u64)> {
for (prefix, kind) in [("pr-", "pr"), ("issue-", "issue")] {
if let Some(rest) = dir_name.strip_prefix(prefix)
&& let Ok(number) = rest.parse::<u64>()
{
return Some((kind, number));
}
}
None
}
fn forge_item_is_done(repo: &str, kind: &str, number: u64) -> bool {
let (subcommand, done_state) = match kind {
"pr" => ("pr", "MERGED"),
"issue" => ("issue", "CLOSED"),
_ => return false,
};
let output = Command::new("gh")
.args([
subcommand,
"view",
&number.to_string(),
"--repo",
repo,
"--json",
"state",
"-q",
".state",
])
.output();
match output {
Ok(out) if out.status.success() => {
String::from_utf8_lossy(&out.stdout).trim() == done_state
}
_ => false,
}
}
fn aliases(repos: &[RepoConfig]) -> Vec<(String, usize)> {
let shorts: BTreeSet<&str> = repos.iter().map(RepoConfig::short_name).collect();
let folders: Vec<Option<String>> = repos.iter().map(RepoConfig::folder_name).collect();
let flat_taken: BTreeSet<&str> = shorts
.iter()
.copied()
.chain(folders.iter().flatten().map(String::as_str))
.collect();
let mut result: Vec<(String, usize)> = Vec::new();
for (index, repo) in repos.iter().enumerate() {
let folder = folders[index]
.clone()
.filter(|folder| !shorts.contains(folder.as_str()));
let full = Some(repo.repo.clone())
.filter(|full| !flat_taken.contains(full.replace('/', "-").as_str()));
for name in std::iter::once(repo.short_name().to_string())
.chain(folder)
.chain(full)
{
if !result.contains(&(name.clone(), index)) {
result.push((name, index));
}
}
}
result
}
fn resolve_in<'a>(repos: &'a [RepoConfig], name: &str) -> anyhow::Result<&'a RepoConfig> {
let mut matching: Vec<usize> = aliases(repos)
.into_iter()
.filter(|(alias, _)| alias == name)
.map(|(_, index)| index)
.collect();
matching.dedup();
anyhow::ensure!(
matching.len() <= 1,
"Ambiguous repo name '{}': matches {} configured repos. Use a more specific name.",
name,
matching.len()
);
matching
.first()
.map(|&index| &repos[index])
.with_context(|| format!("No configured repo matching '{}'", name))
}
fn resolve_repo_config(repo_str: &str) -> anyhow::Result<RepoConfig> {
let repos = discover_github_repos()?;
let repo_config = resolve_in(&repos, repo_str)?.clone();
anyhow::ensure!(
repo_config.local_path.exists(),
"Local clone not found at {}. Please clone the repo first.",
repo_config.local_path.display()
);
Ok(repo_config)
}
fn print_worktree_path(wt_path: &Path) -> anyhow::Result<()> {
println!(
"{}",
wt_path
.to_str()
.context("Could not convert worktree path to string")?
);
Ok(())
}
fn repo_worktree_dir(
config: &ConfigurationValues,
repo_config: &RepoConfig,
repo_str: &str,
) -> anyhow::Result<PathBuf> {
let wt_base = worktree_base_dir(config)?;
let path_hash = short_path_hash(&repo_config.local_path);
Ok(wt_base.join(format!("{}-{}", repo_str, path_hash)))
}
fn worktree_path(
config: &ConfigurationValues,
repo_config: &RepoConfig,
repo_str: &str,
prefix: &str,
number: u64,
) -> anyhow::Result<PathBuf> {
Ok(repo_worktree_dir(config, repo_config, repo_str)?.join(format!("{}-{}", prefix, number)))
}
fn cook_pr(
config: &ConfigurationValues,
repo_config: &RepoConfig,
repo_str: &str,
number: u64,
) -> anyhow::Result<()> {
let wt_path = worktree_path(config, repo_config, repo_str, "pr", number)?;
std::fs::create_dir_all(wt_path.parent().unwrap())
.context("Could not create worktree directory")?;
let ref_name = format!("pr-{}", number);
let repo = git2::Repository::open(&repo_config.local_path)
.context("Could not open repository for worktree creation")?;
let branch = repo
.find_branch(&ref_name, git2::BranchType::Local)
.with_context(|| format!("Could not find branch {}", ref_name))?;
let reference = branch.into_reference();
let wt_name = format!("enwiro-pr-{}", number);
let mut opts = git2::WorktreeAddOptions::new();
opts.reference(Some(&reference));
repo.worktree(&wt_name, &wt_path, Some(&opts))
.with_context(|| format!("Could not create worktree for PR #{}", number))?;
tracing::debug!(path = %wt_path.display(), pr = number, "Created worktree for PR");
print_worktree_path(&wt_path)
}
fn get_default_branch(repo: &git2::Repository) -> anyhow::Result<String> {
enwiro_sdk::git::remote_default_branch(repo).ok_or_else(|| {
anyhow::anyhow!(
"Could not determine default branch: origin/HEAD is not set and \
neither origin/main nor origin/master exist. \
Try running: git remote set-head origin --auto"
)
})
}
fn cook_issue(
config: &ConfigurationValues,
repo_config: &RepoConfig,
repo_str: &str,
number: u64,
) -> anyhow::Result<()> {
let wt_path = worktree_path(config, repo_config, repo_str, "issue", number)?;
std::fs::create_dir_all(wt_path.parent().unwrap())
.context("Could not create worktree directory")?;
let local_path_str = repo_config
.local_path
.to_str()
.context("Could not convert local path to string")?;
let fetch_status = Command::new("git")
.args(["-C", local_path_str, "fetch", "origin"])
.status()
.context("Failed to run git fetch")?;
if !fetch_status.success() {
anyhow::bail!("Failed to fetch from {}", repo_config.repo);
}
let repo = git2::Repository::open(&repo_config.local_path)
.context("Could not open repository for worktree creation")?;
let default_branch = get_default_branch(&repo)?;
let branch_name = format!("issue-{}", number);
let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
Ok(existing) => {
tracing::debug!(branch = %branch_name, "Reusing existing issue branch");
existing
}
Err(_) => {
let origin_ref = format!("origin/{}", default_branch);
let origin_commit = repo
.find_reference(&format!("refs/remotes/{}", origin_ref))
.with_context(|| format!("Could not find ref {}", origin_ref))?
.peel_to_commit()
.with_context(|| format!("Could not resolve {} to a commit", origin_ref))?;
repo.branch(&branch_name, &origin_commit, false)
.with_context(|| format!("Could not create branch {}", branch_name))?
}
};
let reference = branch.into_reference();
let wt_name = format!("enwiro-issue-{}", number);
let mut opts = git2::WorktreeAddOptions::new();
opts.reference(Some(&reference));
repo.worktree(&wt_name, &wt_path, Some(&opts))
.with_context(|| format!("Could not create worktree for issue #{}", number))?;
tracing::debug!(path = %wt_path.display(), issue = number, "Created worktree for issue");
print_worktree_path(&wt_path)
}
#[derive(Debug, PartialEq, Eq)]
struct RunInfo {
head_sha: String,
head_branch: Option<String>,
name: Option<String>,
}
fn parse_run_response(json: &[u8]) -> anyhow::Result<RunInfo> {
let value: serde_json::Value =
serde_json::from_slice(json).context("gh api produced invalid JSON")?;
let text = |key: &str| value.get(key).and_then(|v| v.as_str()).map(String::from);
Ok(RunInfo {
head_sha: text("head_sha").context("gh api response had no string 'head_sha'")?,
head_branch: text("head_branch"),
name: text("name"),
})
}
fn fetch_run(repo: &str, run_id: u64) -> anyhow::Result<RunInfo> {
let output = Command::new("gh")
.args(["api", &format!("repos/{repo}/actions/runs/{run_id}")])
.output()
.context(
"Failed to run gh CLI. Is it installed and authenticated? \
(https://cli.github.com/, then run: gh auth login)",
)?;
anyhow::ensure!(
output.status.success(),
"gh api failed: {}",
String::from_utf8_lossy(&output.stderr)
);
parse_run_response(&output.stdout)
}
fn cook_run(
config: &ConfigurationValues,
repo_config: &RepoConfig,
run_id: u64,
) -> anyhow::Result<()> {
let wt_path = run_worktree_path(config, repo_config, run_id)?;
if wt_path.exists() {
return print_worktree_path(&wt_path);
}
let run = fetch_run(&repo_config.repo, run_id)?;
create_run_worktree(repo_config, &wt_path, run_id, &run.head_sha)?;
tracing::debug!(path = %wt_path.display(), run = run_id, "Created worktree for CI run");
print_worktree_path(&wt_path)
}
fn run_worktree_path(
config: &ConfigurationValues,
repo_config: &RepoConfig,
run_id: u64,
) -> anyhow::Result<PathBuf> {
worktree_path(
config,
repo_config,
repo_config.short_name(),
RUN_KIND.worktree_subdir,
run_id,
)
}
fn run_branch_name(run_id: u64) -> String {
format!("fix-ci-{run_id}")
}
fn create_run_worktree(
repo_config: &RepoConfig,
wt_path: &Path,
run_id: u64,
head_sha: &str,
) -> anyhow::Result<()> {
let local_path_str = repo_config
.local_path
.to_str()
.context("Could not convert local path to string")?;
let fetch_output = Command::new("git")
.args(["-C", local_path_str, "fetch", "origin", head_sha])
.output()
.context("Failed to run git fetch")?;
anyhow::ensure!(
fetch_output.status.success(),
"Failed to fetch commit {} of run {} from {}: {}",
head_sha,
run_id,
repo_config.repo,
String::from_utf8_lossy(fetch_output.stderr.as_slice()).trim()
);
std::fs::create_dir_all(wt_path.parent().context("Worktree path has no parent")?)
.context("Could not create worktree directory")?;
let repo = git2::Repository::open(&repo_config.local_path)
.context("Could not open repository for worktree creation")?;
let commit = repo
.find_commit(git2::Oid::from_str(head_sha).context("Invalid head_sha")?)
.with_context(|| format!("Commit {head_sha} is missing after fetch"))?;
let branch_name = run_branch_name(run_id);
let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
Ok(existing) => existing,
Err(_) => repo
.branch(&branch_name, &commit, false)
.with_context(|| format!("Could not create branch {branch_name}"))?,
};
let reference = branch.into_reference();
let mut opts = git2::WorktreeAddOptions::new();
opts.reference(Some(&reference));
repo.worktree(&format!("enwiro-run-{run_id}"), wt_path, Some(&opts))
.with_context(|| format!("Could not create worktree for run {run_id}"))?;
Ok(())
}
fn reject_fix_ci_on_issue(is_fix_ci_variant: bool, number: u64) -> anyhow::Result<()> {
anyhow::ensure!(
!is_fix_ci_variant,
"'@{FIX_CI_VARIANT}' only applies to pull requests, but #{number} is an issue"
);
Ok(())
}
fn cook(config: &ConfigurationValues, args: CookArgs) -> anyhow::Result<()> {
let (repo_str, number, is_fix_ci_variant) = match RecipeName::parse(&args.recipe_name)? {
RecipeName::Run { repo, run_id } => {
return cook_run(config, &resolve_repo_config(repo)?, run_id);
}
RecipeName::Item {
repo,
number,
is_fix_ci_variant,
} => (repo, number, is_fix_ci_variant),
};
let repo_config = resolve_repo_config(repo_str)?;
let repo_str = repo_config.short_name();
let pr_wt_path = worktree_path(config, &repo_config, repo_str, "pr", number)?;
let issue_wt_path = worktree_path(config, &repo_config, repo_str, "issue", number)?;
if pr_wt_path.exists() {
return print_worktree_path(&pr_wt_path);
}
if issue_wt_path.exists() {
reject_fix_ci_on_issue(is_fix_ci_variant, number)?;
return print_worktree_path(&issue_wt_path);
}
let old_repo_name = repo_config.repo.replace('/', "-");
let old_pr_wt_path =
repo_worktree_dir(config, &repo_config, &old_repo_name)?.join(format!("pr-{}", number));
if old_pr_wt_path.exists() {
return print_worktree_path(&old_pr_wt_path);
}
let local_path_str = repo_config
.local_path
.to_str()
.context("Could not convert local path to string")?;
let fetch_refspec = format!("pull/{}/head:pr-{}", number, number);
let fetch_output = Command::new("git")
.args(["-C", local_path_str, "fetch", "origin", &fetch_refspec])
.output()
.context("Failed to run git fetch")?;
if fetch_output.status.success() {
return cook_pr(config, &repo_config, repo_str, number);
}
let stderr = String::from_utf8_lossy(&fetch_output.stderr);
if stderr.contains("not found") || stderr.contains("couldn't find remote ref") {
reject_fix_ci_on_issue(is_fix_ci_variant, number)?;
cook_issue(config, &repo_config, repo_str, number)
} else {
anyhow::bail!(
"Failed to fetch #{} from {}: {}",
number,
repo_config.repo,
stderr.trim()
)
}
}
fn prune_recipe(
config: &ConfigurationValues,
recipe_name: &str,
) -> anyhow::Result<Option<PruneOutcome>> {
let Ok(name) = RecipeName::parse(recipe_name) else {
return Ok(None);
};
let Ok(repo_config) = resolve_repo_config(name.repo()) else {
return Ok(None);
};
match name {
RecipeName::Item { number, .. } => {
prune_worktree(config, &repo_config, repo_config.short_name(), number)
}
RecipeName::Run { run_id, .. } => {
let wt_path = run_worktree_path(config, &repo_config, run_id)?;
if wt_path.exists() {
Ok(Some(remove_worktree(&repo_config.local_path, &wt_path)?))
} else {
Ok(None)
}
}
}
}
fn prune_worktree(
config: &ConfigurationValues,
repo_config: &RepoConfig,
repo_str: &str,
number: u64,
) -> anyhow::Result<Option<PruneOutcome>> {
for prefix in ["pr", "issue"] {
let wt_path = worktree_path(config, repo_config, repo_str, prefix, number)?;
if wt_path.exists() {
return Ok(Some(remove_worktree(&repo_config.local_path, &wt_path)?));
}
}
Ok(None)
}
fn prune(config: &ConfigurationValues, args: PruneArgs) -> anyhow::Result<()> {
if let Some(outcome) = prune_recipe(config, &args.recipe_name)? {
println!("{}", serde_json::to_string(&outcome)?);
}
Ok(())
}
struct GearKind {
worktree_subdir: &'static str,
description_prefix: &'static str,
page_description: &'static str,
url_subdir: &'static str,
}
const PR_KIND: GearKind = GearKind {
worktree_subdir: "pr",
description_prefix: "Pull request",
page_description: "Open the PR page",
url_subdir: "pull",
};
const ISSUE_KIND: GearKind = GearKind {
worktree_subdir: "issue",
description_prefix: "Issue",
page_description: "Open the issue page",
url_subdir: "issues",
};
const RUN_KIND: GearKind = GearKind {
worktree_subdir: "run",
description_prefix: "CI run",
page_description: "Open the run page",
url_subdir: "actions/runs",
};
fn build_gear_file_for_kind(
kind: &GearKind,
repo: &str,
number: u64,
) -> enwiro_sdk::gear::GearFileData {
use enwiro_sdk::gear::{Gear, GearFileData, SCHEMA_VERSION, WebEntry};
use std::collections::HashMap;
let page = WebEntry {
description: kind.page_description.to_string(),
url: format!("https://github.com/{repo}/{}/{number}", kind.url_subdir),
};
let gear_entry = Gear {
description: format!("{} #{number} on {repo}", kind.description_prefix),
web: HashMap::from([("page".to_string(), page)]),
..Default::default()
};
GearFileData {
version: SCHEMA_VERSION,
gear: HashMap::from([(kind.worktree_subdir.to_string(), gear_entry)]),
}
}
fn gear_with_writer<W: Write>(
config: &ConfigurationValues,
repo_config: &RepoConfig,
repo_str: &str,
number: u64,
writer: &mut W,
) -> anyhow::Result<()> {
for kind in [&PR_KIND, &ISSUE_KIND] {
let path = worktree_path(config, repo_config, repo_str, kind.worktree_subdir, number)?;
if path.exists() {
let file = build_gear_file_for_kind(kind, &repo_config.repo, number);
serde_json::to_writer(writer, &file)?;
return Ok(());
}
}
anyhow::bail!("No worktree found for {}#{}", repo_str, number)
}
fn gear(config: &ConfigurationValues, args: GearArgs) -> anyhow::Result<()> {
let name = RecipeName::parse(&args.recipe_name)?;
let repo_config = resolve_repo_config(name.repo())?;
match name {
RecipeName::Item { number, .. } => gear_with_writer(
config,
&repo_config,
repo_config.short_name(),
number,
&mut std::io::stdout(),
),
RecipeName::Run { run_id, .. } => {
gear_for_run_with_writer(config, &repo_config, run_id, &mut std::io::stdout())
}
}
}
fn gear_for_run_with_writer<W: Write>(
config: &ConfigurationValues,
repo_config: &RepoConfig,
run_id: u64,
writer: &mut W,
) -> anyhow::Result<()> {
anyhow::ensure!(
run_worktree_path(config, repo_config, run_id)?.exists(),
"No worktree found for run {run_id}"
);
let file = build_gear_file_for_kind(&RUN_KIND, &repo_config.repo, run_id);
serde_json::to_writer(writer, &file)?;
Ok(())
}
fn resolve_external_paths(recipe_name: &str) -> anyhow::Result<Vec<String>> {
let repo_config = resolve_repo_config(RecipeName::parse(recipe_name)?.repo())?;
Ok(vec![
repo_config
.local_path
.to_str()
.context("Could not convert repo local path to string")?
.to_string(),
])
}
fn external_paths(args: ExternalPathsArgs) -> anyhow::Result<()> {
let paths = resolve_external_paths(&args.recipe_name)?;
println!("{}", serde_json::to_string(&paths)?);
Ok(())
}
fn parse_describe_response(json: &[u8]) -> anyhow::Result<String> {
let value: serde_json::Value =
serde_json::from_slice(json).context("gh api produced invalid JSON")?;
let title = value
.get("title")
.and_then(|t| t.as_str())
.context("gh api response had no string 'title'")?;
let prefix = if value.get("pull_request").is_some() {
"[PR]"
} else {
"[issue]"
};
Ok(format!("{} {}", prefix, sanitize_title(title)))
}
fn resolve_description(repo: &str, number: u64) -> anyhow::Result<String> {
let output = Command::new("gh")
.args(["api", &format!("repos/{repo}/issues/{number}")])
.output()
.context(
"Failed to run gh CLI. Is it installed and authenticated? \
(https://cli.github.com/, then run: gh auth login)",
)?;
anyhow::ensure!(
output.status.success(),
"gh api failed: {}",
String::from_utf8_lossy(&output.stderr)
);
parse_describe_response(&output.stdout)
}
fn run_description(run: &RunInfo) -> String {
let name = sanitize_title(run.name.as_deref().unwrap_or("workflow"));
match &run.head_branch {
Some(branch) => format!("[CI run] {} on {}", name, sanitize_title(branch)),
None => format!("[CI run] {}", name),
}
}
fn describe(args: DescribeArgs) -> anyhow::Result<()> {
let name = RecipeName::parse(&args.recipe_name)?;
let repo_config = resolve_repo_config(name.repo())?;
let description = match name {
RecipeName::Item { number, .. } => resolve_description(&repo_config.repo, number)?,
RecipeName::Run { run_id, .. } => run_description(&fetch_run(&repo_config.repo, run_id)?),
};
println!("{}", serde_json::to_string(&description)?);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn parse_cooked_env_dir_examples() {
assert_eq!(parse_cooked_env_dir("pr-123"), Some(("pr", 123)));
assert_eq!(parse_cooked_env_dir("issue-45"), Some(("issue", 45)));
assert_eq!(parse_cooked_env_dir("issue-0"), Some(("issue", 0)));
assert_eq!(parse_cooked_env_dir("pr-"), None);
assert_eq!(parse_cooked_env_dir("pr-abc"), None);
assert_eq!(parse_cooked_env_dir("pr-12x"), None);
assert_eq!(parse_cooked_env_dir("random"), None);
assert_eq!(parse_cooked_env_dir("branch-7"), None);
assert_eq!(parse_cooked_env_dir("pr-99999999999999999999999999"), None);
}
proptest! {
#[test]
fn parse_cooked_env_dir_never_panics(s in ".*") {
let _ = parse_cooked_env_dir(&s);
}
#[test]
fn parse_cooked_env_dir_round_trips(n in any::<u64>(), is_pr in any::<bool>()) {
let kind = if is_pr { "pr" } else { "issue" };
let dir = format!("{kind}-{n}");
prop_assert_eq!(parse_cooked_env_dir(&dir), Some((kind, n)));
}
}
#[test]
fn test_parse_recipe_name_valid() {
let (repo, number, is_fix_ci_variant) = parse_recipe_name("enwiro#42").unwrap();
assert_eq!(repo, "enwiro");
assert_eq!(number, 42);
assert!(!is_fix_ci_variant);
}
#[test]
fn test_parse_recipe_name_fix_ci_variant() {
let (repo, number, is_fix_ci_variant) = parse_recipe_name("owner/repo#42@fix-ci").unwrap();
assert_eq!(repo, "owner/repo");
assert_eq!(number, 42);
assert!(is_fix_ci_variant);
}
#[test]
fn test_parse_recipe_name_rejects_unknown_variant() {
let result = parse_recipe_name("owner/repo#42@bogus");
assert!(result.is_err());
}
fn repo(full: &str, path: &str) -> RepoConfig {
RepoConfig {
repo: full.to_string(),
local_path: PathBuf::from(path),
}
}
fn alias_names(repos: &[RepoConfig]) -> Vec<String> {
aliases(repos).into_iter().map(|(name, _)| name).collect()
}
#[test]
fn test_aliases_include_short_folder_and_full_name() {
let repos = vec![repo("kantord/enwiro", "/home/me/repos/enwiro-work")];
assert_eq!(
alias_names(&repos),
vec!["enwiro", "enwiro-work", "kantord/enwiro"]
);
}
#[test]
fn test_aliases_do_not_repeat_a_folder_equal_to_the_short_name() {
let repos = vec![repo("kantord/enwiro", "/home/me/repos/enwiro")];
assert_eq!(alias_names(&repos), vec!["enwiro", "kantord/enwiro"]);
}
#[test]
fn test_aliases_skip_a_folder_equal_to_another_repos_short_name() {
let repos = vec![
repo("x/bar", "/home/me/repos/foo"),
repo("y/foo", "/home/me/repos/foo-clone"),
];
let names = alias_names(&repos);
assert!(names.contains(&"bar".to_string()));
assert_eq!(
names.iter().filter(|n| n.as_str() == "foo").count(),
1,
"`foo` stays the short name of y/foo only"
);
assert_eq!(resolve_in(&repos, "foo").unwrap().repo, "y/foo");
}
#[test]
fn test_aliases_skip_a_full_name_that_flattens_onto_another_alias() {
let repos = vec![repo("a/b", "/repos/b"), repo("z/a-b", "/repos/a-b")];
let names = alias_names(&repos);
assert!(!names.contains(&"a/b".to_string()));
assert!(names.contains(&"z/a-b".to_string()));
}
#[test]
fn test_resolve_in_matches_short_folder_and_full_name() {
let repos = vec![repo("kantord/enwiro", "/home/me/repos/enwiro-work")];
for name in ["enwiro", "enwiro-work", "kantord/enwiro"] {
assert_eq!(resolve_in(&repos, name).unwrap().repo, "kantord/enwiro");
}
}
#[test]
fn test_resolve_in_rejects_unknown_names() {
let repos = vec![repo("kantord/enwiro", "/home/me/repos/enwiro")];
let err = resolve_in(&repos, "nope").unwrap_err().to_string();
assert!(err.contains("No configured repo matching 'nope'"), "{err}");
}
#[test]
fn test_resolve_in_errors_when_a_folder_alias_is_shared() {
let repos = vec![repo("x/one", "/work/app"), repo("y/two", "/oss/app")];
let err = resolve_in(&repos, "app").unwrap_err().to_string();
assert!(err.contains("Ambiguous repo name 'app'"), "{err}");
assert_eq!(resolve_in(&repos, "y/two").unwrap().repo, "y/two");
}
#[test]
fn test_resolve_in_errors_when_a_short_name_is_shared() {
let repos = vec![
repo("kantord/tool", "/a/tool"),
repo("acme/tool", "/b/tool"),
];
let err = resolve_in(&repos, "tool").unwrap_err().to_string();
assert!(err.contains("Ambiguous repo name 'tool'"), "{err}");
assert_eq!(resolve_in(&repos, "acme/tool").unwrap().repo, "acme/tool");
}
#[test]
fn test_alias_and_short_name_share_one_worktree_path() {
let repos = vec![repo("kantord/enwiro", "/home/me/repos/enwiro-work")];
let config = ConfigurationValues {
worktree_dir: Some("/wt".to_string()),
};
let paths: Vec<PathBuf> = ["enwiro", "enwiro-work", "kantord/enwiro"]
.into_iter()
.map(|name| {
let repo_config = resolve_in(&repos, name).unwrap();
worktree_path(&config, repo_config, repo_config.short_name(), "pr", 849).unwrap()
})
.collect();
assert_eq!(paths[0], paths[1]);
assert_eq!(paths[0], paths[2]);
}
#[test]
fn test_item_pattern_recipes_claim_aliases_without_url_rule() {
let repos = vec![repo("kantord/enwiro", "/home/me/repos/enwiro-work")];
let items = item_pattern_recipes(&repos);
let claim = |name: &str| -> &PatternRecipe {
items
.iter()
.find_map(|item| match item {
RecipeItem::Pattern(p)
if enwiro_sdk::recipe_pattern::match_name(
&enwiro_sdk::recipe_pattern::anchor(&p.pattern),
None,
name,
)
.is_some() =>
{
Some(p)
}
_ => None,
})
.unwrap_or_else(|| panic!("no claim covers {name}"))
};
assert!(claim("enwiro#849").url.is_some());
assert!(claim("enwiro-work#849").url.is_none());
assert!(claim("kantord/enwiro#849").url.is_none());
claim("enwiro-work#849@fix-ci");
for item in &items {
let RecipeItem::Pattern(p) = item else {
panic!("expected only pattern items");
};
enwiro_sdk::recipe_pattern::validate(&p.pattern, p.description.as_deref())
.expect("emitted pattern must pass daemon validation");
}
}
#[test]
fn test_item_pattern_recipes_claim_any_number_per_repo() {
let repos = vec![
RepoConfig {
repo: "kantord/enwiro".to_string(),
local_path: PathBuf::from("/tmp/enwiro"),
},
RepoConfig {
repo: "vercel/next.js".to_string(),
local_path: PathBuf::from("/tmp/next.js"),
},
];
let items = item_pattern_recipes(&repos);
let patterns: Vec<&PatternRecipe> = items
.iter()
.map(|item| match item {
RecipeItem::Pattern(p) => p,
RecipeItem::Concrete(_) => panic!("expected only pattern items"),
})
.filter(|p| p.url.is_some())
.collect();
assert_eq!(patterns.len(), 2);
for pattern in &patterns {
enwiro_sdk::recipe_pattern::validate(&pattern.pattern, pattern.description.as_deref())
.expect("emitted pattern must pass daemon validation");
}
let anchored = enwiro_sdk::recipe_pattern::anchor(&patterns[1].pattern);
assert!(enwiro_sdk::recipe_pattern::match_name(&anchored, None, "next.js#123").is_some());
assert!(enwiro_sdk::recipe_pattern::match_name(&anchored, None, "next-js#123").is_none());
assert!(enwiro_sdk::recipe_pattern::match_name(&anchored, None, "next.js#abc").is_none());
assert!(enwiro_sdk::recipe_pattern::match_name(&anchored, None, "next.js#٤٢").is_none());
assert!(
enwiro_sdk::recipe_pattern::match_name(
&anchored,
None,
"next.js#99999999999999999999999"
)
.is_none()
);
assert!(
enwiro_sdk::recipe_pattern::match_name(&anchored, None, "other/next.js#5").is_none()
);
let enwiro_anchored = enwiro_sdk::recipe_pattern::anchor(&patterns[0].pattern);
let matched = enwiro_sdk::recipe_pattern::match_name(
&enwiro_anchored,
patterns[0].description.as_deref(),
"enwiro#997",
)
.unwrap();
assert_eq!(
matched.description.as_deref(),
Some("Work on PR or issue #997 in enwiro")
);
assert!(
enwiro_sdk::recipe_pattern::match_name(&enwiro_anchored, None, "enwiro#997@fix-ci")
.is_some()
);
assert!(
enwiro_sdk::recipe_pattern::match_name(&enwiro_anchored, None, "enwiro#997@bogus")
.is_none()
);
}
#[test]
fn test_item_pattern_recipes_carry_a_valid_url_rule() {
let repos = vec![RepoConfig {
repo: "kantord/enwiro".to_string(),
local_path: PathBuf::from("/tmp/enwiro"),
}];
let items = item_pattern_recipes(&repos);
let RecipeItem::Pattern(pattern) = &items[0] else {
panic!("expected a pattern item");
};
let rule = pattern.url.as_ref().expect("pattern must carry a URL rule");
enwiro_sdk::url_rule::validate(rule).expect("emitted URL rule must pass daemon validation");
assert_eq!(
rule.pattern,
"https://github.com/kantord/enwiro/:kind(pull|issues)/:number([0-9]+){/*}?"
);
assert_eq!(rule.recipe, "enwiro#{number}");
let anchored = enwiro_sdk::recipe_pattern::anchor(&pattern.pattern);
assert!(enwiro_sdk::recipe_pattern::match_name(&anchored, None, "enwiro#42").is_some());
}
#[test]
fn test_item_pattern_recipes_share_short_name_single_claim() {
let repos = vec![
RepoConfig {
repo: "kantord/tool".to_string(),
local_path: PathBuf::from("/tmp/tool"),
},
RepoConfig {
repo: "acme/tool".to_string(),
local_path: PathBuf::from("/tmp/tool2"),
},
];
let items = item_pattern_recipes(&repos);
let canonical: Vec<&PatternRecipe> = items
.iter()
.filter_map(|item| match item {
RecipeItem::Pattern(p) if p.url.is_some() => Some(p),
_ => None,
})
.collect();
assert_eq!(
canonical.len(),
1,
"short-name claims stay deduplicated by short name"
);
let rule = canonical[0].url.as_ref().unwrap();
assert!(rule.pattern.contains("github.com/acme/tool"));
}
#[test]
fn test_parse_run_recipe_name_examples() {
assert_eq!(
parse_run_recipe_name("enwiro.gha@run-9876543210"),
Some(("enwiro", 9876543210))
);
assert_eq!(
parse_run_recipe_name("next.js.gha@run-5"),
Some(("next.js", 5))
);
assert_eq!(
parse_run_recipe_name("owner/repo.gha@run-5"),
Some(("owner/repo", 5))
);
}
#[test]
fn test_parse_run_recipe_name_rejects_other_names() {
for name in [
"enwiro#42",
"enwiro#42@fix-ci",
"enwiro@run-5",
"enwiro.gha@run-",
"enwiro.gha@run-abc",
"enwiro.gha@run-+5",
"enwiro.gha@run-5@fix-ci",
".gha@run-5",
] {
assert_eq!(parse_run_recipe_name(name), None, "{name}");
}
}
fn run_claims(repos: &[RepoConfig]) -> Vec<PatternRecipe> {
run_pattern_recipes(repos)
.into_iter()
.map(|item| match item {
RecipeItem::Pattern(p) => p,
RecipeItem::Concrete(_) => panic!("expected only pattern items"),
})
.collect()
}
fn claims_name(claim: &PatternRecipe, name: &str) -> bool {
enwiro_sdk::recipe_pattern::match_name(
&enwiro_sdk::recipe_pattern::anchor(&claim.pattern),
None,
name,
)
.is_some()
}
#[test]
fn test_run_pattern_recipes_claim_every_alias_but_only_short_name_has_url_rule() {
let repos = vec![repo("kantord/enwiro", "/home/me/repos/enwiro-work")];
let claims = run_claims(&repos);
let claim_for = |name: &str| {
claims
.iter()
.find(|c| claims_name(c, name))
.unwrap_or_else(|| panic!("no claim covers {name}"))
};
assert!(claim_for("enwiro.gha@run-123").url.is_some());
assert!(claim_for("enwiro-work.gha@run-123").url.is_none());
assert!(claim_for("kantord/enwiro.gha@run-123").url.is_none());
for claim in &claims {
enwiro_sdk::recipe_pattern::validate(&claim.pattern, claim.description.as_deref())
.expect("emitted pattern must pass daemon validation");
}
}
#[test]
fn test_run_claim_is_exact_and_leaves_branch_and_item_names_alone() {
let repos = vec![repo("vercel/next.js", "/tmp/next.js")];
let claims = run_claims(&repos);
let claim = claims.iter().find(|c| c.url.is_some()).unwrap();
assert!(claims_name(claim, "next.js.gha@run-1"));
assert!(claims_name(claim, "next.js.gha@run-1234567890123456789"));
assert!(!claims_name(claim, "next-js.gha@run-1"));
assert!(!claims_name(claim, "next.js.gha@run-abc"));
assert!(!claims_name(claim, "next.js.gha@run-12345678901234567890"));
assert!(!claims_name(claim, "next.js@run-1"));
assert!(!claims_name(claim, "next.js#1"));
for item in item_pattern_recipes(&repos) {
let RecipeItem::Pattern(p) = item else {
panic!("expected only pattern items");
};
assert!(!claims_name(&p, "next.js.gha@run-1"));
}
}
#[test]
fn test_run_pattern_description_names_the_run_and_repo() {
let repos = vec![repo("kantord/enwiro", "/tmp/enwiro")];
let claims = run_claims(&repos);
let claim = claims.iter().find(|c| c.url.is_some()).unwrap();
let matched = enwiro_sdk::recipe_pattern::match_name(
&enwiro_sdk::recipe_pattern::anchor(&claim.pattern),
claim.description.as_deref(),
"enwiro.gha@run-77",
)
.unwrap();
assert_eq!(
matched.description.as_deref(),
Some("Work on a fix for CI run 77 in enwiro")
);
}
#[test]
fn test_run_url_rule_is_valid_and_renders_a_claimed_name() {
let rule = github_run_url_rule("enwiro", "kantord/enwiro");
enwiro_sdk::url_rule::validate(&rule).expect("emitted URL rule must pass validation");
assert_eq!(
rule.pattern,
"https://github.com/kantord/enwiro/actions/runs/:run_id([0-9]+){/*}?"
);
assert_eq!(rule.recipe, "enwiro.gha@run-{run_id}");
}
#[test]
fn test_parse_run_response_extracts_commit_branch_and_name() {
let json =
br#"{"head_sha":"abc123","head_branch":"main","name":"CI","status":"completed"}"#;
assert_eq!(
parse_run_response(json).unwrap(),
RunInfo {
head_sha: "abc123".to_string(),
head_branch: Some("main".to_string()),
name: Some("CI".to_string()),
}
);
}
#[test]
fn test_parse_run_response_tolerates_missing_optional_fields() {
let run = parse_run_response(br#"{"head_sha":"abc123","head_branch":null}"#).unwrap();
assert_eq!(run.head_branch, None);
assert_eq!(run.name, None);
}
#[test]
fn test_parse_run_response_errors_without_head_sha() {
assert!(parse_run_response(br#"{"name":"CI"}"#).is_err());
assert!(parse_run_response(b"not json").is_err());
}
#[test]
fn test_run_description_includes_workflow_and_branch() {
let run = RunInfo {
head_sha: "abc".to_string(),
head_branch: Some("main".to_string()),
name: Some("CI\nbuild".to_string()),
};
assert_eq!(run_description(&run), "[CI run] CI build on main");
}
#[test]
fn test_parse_recipe_name_large_number() {
let (repo, number, _is_fix_ci_variant) = parse_recipe_name("next.js#12345").unwrap();
assert_eq!(repo, "next.js");
assert_eq!(number, 12345);
}
#[test]
fn test_parse_recipe_name_no_hash() {
let result = parse_recipe_name("enwiro");
assert!(result.is_err());
}
#[test]
fn test_parse_recipe_name_invalid_number() {
let result = parse_recipe_name("enwiro#abc");
assert!(result.is_err());
}
#[test]
fn test_build_search_query_pr() {
let repos = vec!["kantord/enwiro".to_string()];
let query = build_search_query(&repos, "is:pr is:open");
assert!(query.contains("repo:kantord/enwiro"));
assert!(query.contains("is:pr"));
assert!(query.contains("is:open"));
assert!(query.contains("sort:updated-desc"));
assert!(
query.contains("updated:>"),
"Should contain date filter, got: {}",
query
);
}
#[test]
fn test_build_search_query_issue() {
let repos = vec!["kantord/enwiro".to_string()];
let query = build_search_query(&repos, "is:issue is:open assignee:@me");
assert!(query.contains("repo:kantord/enwiro"));
assert!(query.contains("is:issue"));
assert!(query.contains("is:open"));
assert!(query.contains("assignee:@me"));
assert!(query.contains("sort:updated-desc"));
}
#[test]
fn test_build_search_query_issue_omits_date_filter() {
let repos = vec!["kantord/enwiro".to_string()];
let query = build_search_query(&repos, "is:issue is:open assignee:@me");
assert!(
!query.contains("updated:>"),
"Issue query must NOT include a date filter, but got: {}",
query
);
}
#[test]
fn test_build_search_query_pr_retains_date_filter() {
let repos = vec!["kantord/enwiro".to_string()];
let query = build_search_query(&repos, "is:pr is:open");
assert!(
query.contains("updated:>"),
"PR query MUST include a date filter, but got: {}",
query
);
}
#[test]
fn test_build_search_query_multiple_repos() {
let repos = vec![
"kantord/enwiro".to_string(),
"expressjs/express".to_string(),
];
let query = build_search_query(&repos, "is:pr is:open");
assert!(query.contains("repo:kantord/enwiro"));
assert!(query.contains("repo:expressjs/express"));
}
#[test]
fn test_parse_search_response_prs() {
let json = r#"{
"data": {
"search": {
"nodes": [
{
"number": 42,
"title": "Fix the thing",
"headRefName": "fix-thing",
"updatedAt": "2026-02-14T13:10:29Z",
"repository": { "nameWithOwner": "kantord/enwiro" }
},
{
"number": 99,
"title": "Add feature",
"headRefName": "feature/add-stuff",
"updatedAt": "2026-02-13T10:00:00Z",
"repository": { "nameWithOwner": "expressjs/express" }
}
]
}
}
}"#;
let items = parse_search_response(json).unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].number, 42);
assert_eq!(items[0].title, "Fix the thing");
assert!(matches!(
&items[0].kind,
GithubItemKind::PullRequest { head_ref_name } if head_ref_name == "fix-thing"
));
assert_eq!(items[0].repo, "enwiro");
assert_eq!(items[1].number, 99);
assert_eq!(items[1].repo, "express");
}
#[test]
fn test_parse_search_response_issues() {
let json = r#"{
"data": {
"search": {
"nodes": [
{
"number": 225,
"title": "Discover GitHub Issues",
"updatedAt": "2026-02-14T13:10:29Z",
"repository": { "nameWithOwner": "kantord/enwiro" }
},
{
"number": 100,
"title": "Fix login bug",
"updatedAt": "2026-02-13T10:00:00Z",
"repository": { "nameWithOwner": "expressjs/express" }
}
]
}
}
}"#;
let items = parse_search_response(json).unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].number, 225);
assert_eq!(items[0].title, "Discover GitHub Issues");
assert!(matches!(&items[0].kind, GithubItemKind::Issue));
assert_eq!(items[0].repo, "enwiro");
assert_eq!(items[1].number, 100);
assert!(matches!(&items[1].kind, GithubItemKind::Issue));
}
#[test]
fn test_parse_search_response_empty_nodes() {
let json = r#"{"data": {"search": {"nodes": []}}}"#;
let items = parse_search_response(json).unwrap();
assert!(items.is_empty());
}
#[test]
fn test_parse_github_remote_ssh() {
assert_eq!(
parse_github_remote("git@github.com:kantord/enwiro.git"),
Some("kantord/enwiro".to_string())
);
}
#[test]
fn test_parse_github_remote_https_with_git_suffix() {
assert_eq!(
parse_github_remote("https://github.com/kantord/enwiro.git"),
Some("kantord/enwiro".to_string())
);
}
#[test]
fn test_parse_github_remote_https_without_git_suffix() {
assert_eq!(
parse_github_remote("https://github.com/kantord/enwiro"),
Some("kantord/enwiro".to_string())
);
}
#[test]
fn test_parse_github_remote_ssh_protocol() {
assert_eq!(
parse_github_remote("ssh://git@github.com/kantord/enwiro.git"),
Some("kantord/enwiro".to_string())
);
}
#[test]
fn test_parse_github_remote_gitlab_returns_none() {
assert_eq!(
parse_github_remote("git@gitlab.com:kantord/project.git"),
None
);
}
#[test]
fn test_parse_github_remote_empty_string() {
assert_eq!(parse_github_remote(""), None);
}
#[test]
fn test_discover_finds_github_repo() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("enwiro");
std::fs::create_dir(&repo_path).unwrap();
let repo = git2::Repository::init(&repo_path).unwrap();
repo.remote("origin", "git@github.com:kantord/enwiro.git")
.unwrap();
let git_config = GitCookbookConfig {
repo_globs: vec![tmp.path().join("*").to_str().unwrap().to_string()],
};
let repos = discover_github_repos_from_config(&git_config).unwrap();
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].repo, "kantord/enwiro");
assert_eq!(repos[0].local_path, repo_path.canonicalize().unwrap());
}
#[test]
fn test_discover_skips_non_github_repo() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("project");
std::fs::create_dir(&repo_path).unwrap();
let repo = git2::Repository::init(&repo_path).unwrap();
repo.remote("origin", "git@gitlab.com:kantord/project.git")
.unwrap();
let git_config = GitCookbookConfig {
repo_globs: vec![tmp.path().join("*").to_str().unwrap().to_string()],
};
let repos = discover_github_repos_from_config(&git_config).unwrap();
assert_eq!(repos.len(), 0);
}
#[test]
fn test_discover_skips_repo_without_origin() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("project");
std::fs::create_dir(&repo_path).unwrap();
git2::Repository::init(&repo_path).unwrap();
let git_config = GitCookbookConfig {
repo_globs: vec![tmp.path().join("*").to_str().unwrap().to_string()],
};
let repos = discover_github_repos_from_config(&git_config).unwrap();
assert_eq!(repos.len(), 0);
}
#[test]
fn test_discover_skips_non_repo_directory() {
let tmp = tempfile::TempDir::new().unwrap();
let not_a_repo = tmp.path().join("just-a-folder");
std::fs::create_dir(¬_a_repo).unwrap();
let git_config = GitCookbookConfig {
repo_globs: vec![tmp.path().join("*").to_str().unwrap().to_string()],
};
let repos = discover_github_repos_from_config(&git_config).unwrap();
assert_eq!(repos.len(), 0);
}
#[test]
fn test_discover_deduplicates_repos() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("enwiro");
std::fs::create_dir(&repo_path).unwrap();
let repo = git2::Repository::init(&repo_path).unwrap();
repo.remote("origin", "git@github.com:kantord/enwiro.git")
.unwrap();
let git_config = GitCookbookConfig {
repo_globs: vec![
tmp.path().join("*").to_str().unwrap().to_string(),
repo_path.to_str().unwrap().to_string(),
],
};
let repos = discover_github_repos_from_config(&git_config).unwrap();
assert_eq!(repos.len(), 1);
}
#[test]
fn test_default_worktree_dir_is_absolute() {
let dir = default_worktree_dir().unwrap();
assert!(
dir.is_absolute(),
"default_worktree_dir should return an absolute path, got: {:?}",
dir
);
}
#[test]
fn test_worktree_base_dir_uses_config() {
let config = ConfigurationValues {
worktree_dir: Some("/tmp/my-pr-worktrees".to_string()),
..Default::default()
};
let dir = worktree_base_dir(&config).unwrap();
assert_eq!(dir, PathBuf::from("/tmp/my-pr-worktrees"));
}
#[test]
fn test_reject_fix_ci_on_issue_errors_for_fix_ci_variant() {
let result = reject_fix_ci_on_issue(true, 42);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("fix-ci"));
}
#[test]
fn test_reject_fix_ci_on_issue_allows_plain_variant() {
assert!(reject_fix_ci_on_issue(false, 42).is_ok());
}
fn repo_with_commit(repo_path: &Path) -> git2::Repository {
let repo = git2::Repository::init(repo_path).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
{
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
}
repo
}
fn add_worktree(repo: &git2::Repository, branch_name: &str, wt_name: &str, wt_path: &Path) {
let head = repo.head().unwrap().peel_to_commit().unwrap();
let branch = repo.branch(branch_name, &head, false).unwrap();
let reference = branch.into_reference();
std::fs::create_dir_all(wt_path.parent().unwrap()).unwrap();
let mut opts = git2::WorktreeAddOptions::new();
opts.reference(Some(&reference));
repo.worktree(wt_name, wt_path, Some(&opts)).unwrap();
}
#[test]
fn test_prune_worktree_is_none_when_never_cooked() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("my-project");
std::fs::create_dir(&repo_path).unwrap();
repo_with_commit(&repo_path);
let config = ConfigurationValues {
worktree_dir: Some(tmp.path().join("worktrees").to_str().unwrap().to_string()),
};
let repo_config = RepoConfig {
repo: "kantord/my-project".to_string(),
local_path: repo_path,
};
assert_eq!(
prune_worktree(&config, &repo_config, "my-project", 42).unwrap(),
None
);
}
#[test]
fn test_prune_worktree_removes_a_cooked_pr_worktree() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("my-project");
std::fs::create_dir(&repo_path).unwrap();
let repo = repo_with_commit(&repo_path);
let config = ConfigurationValues {
worktree_dir: Some(tmp.path().join("worktrees").to_str().unwrap().to_string()),
};
let repo_config = RepoConfig {
repo: "kantord/my-project".to_string(),
local_path: repo_path,
};
let wt_path = worktree_path(&config, &repo_config, "my-project", "pr", 42).unwrap();
add_worktree(&repo, "pr-42", "enwiro-pr-42", &wt_path);
assert!(wt_path.exists());
let outcome = prune_worktree(&config, &repo_config, "my-project", 42).unwrap();
assert_eq!(outcome, Some(PruneOutcome::Removed));
assert!(!wt_path.exists(), "worktree directory must be gone");
}
#[test]
fn test_prune_worktree_keeps_a_dirty_cooked_issue_worktree() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("my-project");
std::fs::create_dir(&repo_path).unwrap();
let repo = repo_with_commit(&repo_path);
let config = ConfigurationValues {
worktree_dir: Some(tmp.path().join("worktrees").to_str().unwrap().to_string()),
};
let repo_config = RepoConfig {
repo: "kantord/my-project".to_string(),
local_path: repo_path,
};
let wt_path = worktree_path(&config, &repo_config, "my-project", "issue", 7).unwrap();
add_worktree(&repo, "issue-7", "enwiro-issue-7", &wt_path);
std::fs::write(wt_path.join("uncommitted.txt"), b"work in progress").unwrap();
let outcome = prune_worktree(&config, &repo_config, "my-project", 7).unwrap();
assert!(
matches!(outcome, Some(PruneOutcome::Kept { .. })),
"expected a kept outcome for a dirty worktree, got: {outcome:?}"
);
assert!(wt_path.exists(), "dirty worktree must survive");
}
#[test]
fn test_cook_creates_worktree_for_pr() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("my-project");
std::fs::create_dir(&repo_path).unwrap();
let repo = git2::Repository::init(&repo_path).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("pr-42", &head, false).unwrap();
let wt_dir = tmp.path().join("worktrees");
let repo_config = RepoConfig {
repo: "kantord/enwiro".to_string(),
local_path: repo_path.clone(),
};
let path_hash = short_path_hash(&repo_config.local_path);
let wt_path = wt_dir
.join(format!("kantord-enwiro-{}", path_hash))
.join("pr-42");
std::fs::create_dir_all(wt_path.parent().unwrap()).unwrap();
let branch = repo.find_branch("pr-42", git2::BranchType::Local).unwrap();
let reference = branch.into_reference();
let mut opts = git2::WorktreeAddOptions::new();
opts.reference(Some(&reference));
repo.worktree("enwiro-pr-42", &wt_path, Some(&opts))
.unwrap();
assert!(wt_path.exists(), "Worktree path should exist on disk");
let wt_repo = git2::Repository::open(&wt_path).unwrap();
assert!(wt_repo.is_worktree(), "Should be a git worktree");
}
#[test]
fn test_cook_creates_worktree_for_issue() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("my-project");
std::fs::create_dir(&repo_path).unwrap();
let repo = git2::Repository::init(&repo_path).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let commit_oid = repo
.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
let commit = repo.find_commit(commit_oid).unwrap();
repo.branch("issue-225", &commit, false).unwrap();
let wt_dir = tmp.path().join("worktrees");
let path_hash = short_path_hash(&repo_path);
let wt_path = wt_dir
.join(format!("my-project-{}", path_hash))
.join("issue-225");
std::fs::create_dir_all(wt_path.parent().unwrap()).unwrap();
let branch = repo
.find_branch("issue-225", git2::BranchType::Local)
.unwrap();
let reference = branch.into_reference();
let mut opts = git2::WorktreeAddOptions::new();
opts.reference(Some(&reference));
repo.worktree("enwiro-issue-225", &wt_path, Some(&opts))
.unwrap();
assert!(wt_path.exists(), "Worktree path should exist on disk");
let wt_repo = git2::Repository::open(&wt_path).unwrap();
assert!(wt_repo.is_worktree(), "Should be a git worktree");
}
fn setup_repo_with_origin(local_path: &Path, origin_path: &Path) -> git2::Repository {
let origin = git2::Repository::init_bare(origin_path).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = origin.index().unwrap().write_tree().unwrap();
let tree = origin.find_tree(tree_id).unwrap();
origin
.commit(Some("refs/heads/main"), &sig, &sig, "initial", &tree, &[])
.unwrap();
let repo = git2::build::RepoBuilder::new()
.clone(origin_path.to_str().unwrap(), local_path)
.unwrap();
repo
}
fn run_fixture(tmp: &Path) -> (ConfigurationValues, RepoConfig, String) {
let origin_path = tmp.join("origin.git");
let local_path = tmp.join("my-project");
setup_repo_with_origin(&local_path, &origin_path);
let origin = git2::Repository::open_bare(&origin_path).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let parent = origin.find_reference("refs/heads/main").unwrap();
let parent = parent.peel_to_commit().unwrap();
let tree = origin.find_tree(parent.tree_id()).unwrap();
let failing = origin
.commit(
Some("refs/heads/main"),
&sig,
&sig,
"breaks ci",
&tree,
&[&parent],
)
.unwrap();
let config = ConfigurationValues {
worktree_dir: Some(tmp.join("worktrees").to_str().unwrap().to_string()),
};
let repo_config = RepoConfig {
repo: "kantord/my-project".to_string(),
local_path,
};
(config, repo_config, failing.to_string())
}
#[test]
fn test_create_run_worktree_checks_out_the_runs_commit_on_a_fix_ci_branch() {
let tmp = tempfile::TempDir::new().unwrap();
let (config, repo_config, sha) = run_fixture(tmp.path());
let wt_path = run_worktree_path(&config, &repo_config, 99).unwrap();
create_run_worktree(&repo_config, &wt_path, 99, &sha).unwrap();
let wt_repo = git2::Repository::open(&wt_path).unwrap();
assert!(wt_repo.is_worktree());
let head = wt_repo.head().unwrap();
assert_eq!(head.shorthand().unwrap(), "fix-ci-99");
assert_eq!(head.peel_to_commit().unwrap().id().to_string(), sha);
}
#[test]
fn test_create_run_worktree_errors_naming_the_commit_when_fetch_fails() {
let tmp = tempfile::TempDir::new().unwrap();
let (config, repo_config, _sha) = run_fixture(tmp.path());
let wt_path = run_worktree_path(&config, &repo_config, 99).unwrap();
let missing = "0123456789abcdef0123456789abcdef01234567";
let error = create_run_worktree(&repo_config, &wt_path, 99, missing).unwrap_err();
let message = error.to_string();
assert!(message.contains(missing), "{message}");
assert!(message.contains("kantord/my-project"), "{message}");
assert!(!wt_path.exists(), "no half-created worktree");
}
#[test]
fn test_gear_for_run_requires_a_cooked_worktree_and_links_the_run_page() {
let tmp = tempfile::TempDir::new().unwrap();
let (config, repo_config, sha) = run_fixture(tmp.path());
let mut output = Vec::new();
assert!(gear_for_run_with_writer(&config, &repo_config, 99, &mut output).is_err());
let wt_path = run_worktree_path(&config, &repo_config, 99).unwrap();
create_run_worktree(&repo_config, &wt_path, 99, &sha).unwrap();
gear_for_run_with_writer(&config, &repo_config, 99, &mut output).unwrap();
let json: serde_json::Value = serde_json::from_slice(&output).unwrap();
assert_eq!(
json["gear"]["run"]["web"]["page"]["url"],
"https://github.com/kantord/my-project/actions/runs/99"
);
}
#[test]
fn test_recipe_name_tells_runs_from_items() {
assert!(matches!(
RecipeName::parse("enwiro.gha@run-7").unwrap(),
RecipeName::Run {
repo: "enwiro",
run_id: 7
}
));
assert!(matches!(
RecipeName::parse("enwiro#7@fix-ci").unwrap(),
RecipeName::Item {
repo: "enwiro",
number: 7,
is_fix_ci_variant: true
}
));
assert!(RecipeName::parse("enwiro").is_err());
}
#[test]
fn test_get_default_branch_uses_origin_head() {
let tmp = tempfile::TempDir::new().unwrap();
let origin_path = tmp.path().join("origin.git");
let local_path = tmp.path().join("local");
let repo = setup_repo_with_origin(&local_path, &origin_path);
let branch = get_default_branch(&repo).unwrap();
assert_eq!(branch, "main");
}
#[test]
fn test_get_default_branch_falls_back_to_main() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("repo");
std::fs::create_dir(&repo_path).unwrap();
let repo = git2::Repository::init(&repo_path).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
repo.remote("origin", "https://example.com/fake.git")
.unwrap();
let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
repo.reference(
"refs/remotes/origin/main",
head_commit.id(),
false,
"fake remote ref",
)
.unwrap();
let branch = get_default_branch(&repo).unwrap();
assert_eq!(branch, "main");
}
#[test]
fn test_get_default_branch_falls_back_to_master() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("repo");
std::fs::create_dir(&repo_path).unwrap();
let repo = git2::Repository::init(&repo_path).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
repo.remote("origin", "https://example.com/fake.git")
.unwrap();
let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
repo.reference(
"refs/remotes/origin/master",
head_commit.id(),
false,
"fake remote ref",
)
.unwrap();
let branch = get_default_branch(&repo).unwrap();
assert_eq!(branch, "master");
}
#[test]
fn test_parse_search_response_captures_updated_at() {
let json = r#"{
"data": {
"search": {
"nodes": [
{
"number": 42,
"title": "Fix the thing",
"headRefName": "fix-thing",
"updatedAt": "2026-02-14T13:10:29Z",
"repository": { "nameWithOwner": "kantord/enwiro" }
},
{
"number": 225,
"title": "Bug report",
"updatedAt": "2026-02-12T09:00:00Z",
"repository": { "nameWithOwner": "kantord/enwiro" }
}
]
}
}
}"#;
let items = parse_search_response(json).unwrap();
assert_eq!(items[0].updated_at, "2026-02-14T13:10:29Z");
assert_eq!(items[1].updated_at, "2026-02-12T09:00:00Z");
}
#[test]
fn test_list_recipes_sorts_combined_items_by_date() {
let mut items = vec![
GithubItem {
number: 10,
title: "Old PR".to_string(),
repo: "enwiro".to_string(),
kind: GithubItemKind::PullRequest {
head_ref_name: "old-pr".to_string(),
},
updated_at: "2026-02-01T00:00:00Z".to_string(),
},
GithubItem {
number: 20,
title: "Recent issue".to_string(),
repo: "enwiro".to_string(),
kind: GithubItemKind::Issue,
updated_at: "2026-02-15T00:00:00Z".to_string(),
},
GithubItem {
number: 30,
title: "Newest PR".to_string(),
repo: "enwiro".to_string(),
kind: GithubItemKind::PullRequest {
head_ref_name: "newest-pr".to_string(),
},
updated_at: "2026-02-18T00:00:00Z".to_string(),
},
];
sort_items_by_date(&mut items);
assert_eq!(items[0].number, 30, "Newest PR should be first");
assert_eq!(items[1].number, 20, "Recent issue should be second");
assert_eq!(items[2].number, 10, "Old PR should be last");
}
#[test]
fn test_cooked_branch_name_matches_worktree_branch() {
let pr = GithubItem {
number: 42,
title: "x".to_string(),
repo: "enwiro".to_string(),
kind: GithubItemKind::PullRequest {
head_ref_name: "feature-x".to_string(),
},
updated_at: String::new(),
};
let issue = GithubItem {
number: 7,
title: "x".to_string(),
repo: "enwiro".to_string(),
kind: GithubItemKind::Issue,
updated_at: String::new(),
};
assert_eq!(cooked_branch_name(&pr), "pr-42");
assert_eq!(cooked_branch_name(&issue), "issue-7");
}
#[test]
fn test_recipes_for_item_issue_yields_one_recipe_with_github_issue_goal() {
let item = GithubItem {
number: 42,
title: "Fix auth bug".to_string(),
repo: "owner/repo".to_string(),
kind: GithubItemKind::Issue,
updated_at: String::new(),
};
let recipes = recipes_for_item(&item, 0, 1, &std::collections::HashMap::new());
assert_eq!(recipes.len(), 1);
assert_eq!(recipes[0].name, "owner/repo#42");
assert_eq!(
recipes[0].goal.as_ref().map(|g| g.kind.as_str()),
Some("github_issue")
);
}
#[test]
fn test_recipes_for_item_pr_yields_work_on_and_fix_ci_variants() {
let item = GithubItem {
number: 42,
title: "Add feature".to_string(),
repo: "owner/repo".to_string(),
kind: GithubItemKind::PullRequest {
head_ref_name: "feature".to_string(),
},
updated_at: String::new(),
};
let recipes = recipes_for_item(&item, 0, 1, &std::collections::HashMap::new());
assert_eq!(recipes.len(), 2);
assert_eq!(recipes[0].name, "owner/repo#42");
assert_eq!(
recipes[0].goal.as_ref().map(|g| g.kind.as_str()),
Some("work_on")
);
assert_eq!(recipes[1].name, "owner/repo#42@fix-ci");
assert_eq!(
recipes[1].goal.as_ref().map(|g| g.kind.as_str()),
Some("fix_ci")
);
}
#[test]
fn test_recipes_for_item_pr_variants_share_equivalent_to() {
let item = GithubItem {
number: 42,
title: "Add feature".to_string(),
repo: "owner/repo".to_string(),
kind: GithubItemKind::PullRequest {
head_ref_name: "feature".to_string(),
},
updated_at: String::new(),
};
let mut display_names = std::collections::HashMap::new();
display_names.insert("owner/repo".to_string(), "repo".to_string());
let recipes = recipes_for_item(&item, 0, 1, &display_names);
assert_eq!(recipes.len(), 2);
assert_eq!(recipes[0].equivalent_to, vec!["repo@pr-42".to_string()]);
assert_eq!(recipes[1].equivalent_to, recipes[0].equivalent_to);
}
#[test]
fn test_git_repo_display_name_is_path_basename() {
assert_eq!(
git_repo_display_name(Path::new("/home/me/code/enwiro")).as_deref(),
Some("enwiro")
);
}
#[test]
fn test_get_default_branch_errors_when_no_candidates() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("repo");
std::fs::create_dir(&repo_path).unwrap();
let repo = git2::Repository::init(&repo_path).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
repo.remote("origin", "https://example.com/fake.git")
.unwrap();
let result = get_default_branch(&repo);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Could not determine default branch"),
"Expected helpful error, got: {}",
err
);
}
#[test]
fn test_compute_sort_order_single_item() {
assert_eq!(compute_sort_order(0, 1), 0);
}
#[test]
fn test_compute_sort_order_two_items() {
assert_eq!(compute_sort_order(0, 2), 0);
assert_eq!(compute_sort_order(1, 2), 100);
}
#[test]
fn test_compute_sort_order_three_items() {
assert_eq!(compute_sort_order(0, 3), 0);
assert_eq!(compute_sort_order(1, 3), 50);
assert_eq!(compute_sort_order(2, 3), 100);
}
mod describe_tests {
use super::*;
#[test]
fn test_parse_describe_response_issue_gets_issue_prefix() {
let json = br#"{"title": "Fix auth bug"}"#;
assert_eq!(
parse_describe_response(json).unwrap(),
"[issue] Fix auth bug"
);
}
#[test]
fn test_parse_describe_response_pull_request_gets_pr_prefix() {
let json = br#"{"title": "Fix auth bug", "pull_request": {}}"#;
assert_eq!(parse_describe_response(json).unwrap(), "[PR] Fix auth bug");
}
#[test]
fn test_parse_describe_response_sanitizes_control_characters() {
let json = "{\"title\": \"Fix\\nauth bug\"}";
assert_eq!(
parse_describe_response(json.as_bytes()).unwrap(),
"[issue] Fix auth bug"
);
}
#[test]
fn test_parse_describe_response_errors_on_missing_title() {
let json = br#"{"pull_request": {}}"#;
assert!(parse_describe_response(json).is_err());
}
#[test]
fn test_parse_describe_response_errors_on_invalid_json() {
assert!(parse_describe_response(b"not json").is_err());
}
}
mod interpret_gh_output_tests {
use super::*;
fn gh_output_with_one_pr() -> Vec<u8> {
r#"{
"data": {
"search": {
"nodes": [
{
"number": 42,
"title": "Fix the thing",
"headRefName": "fix-thing",
"updatedAt": "2026-02-14T13:10:29Z",
"repository": { "nameWithOwner": "kantord/enwiro" }
}
]
}
}
}"#
.as_bytes()
.to_vec()
}
#[test]
fn test_interpret_gh_output_truncation_warning_returns_partial_results() {
let stdout = gh_output_with_one_pr();
let stderr =
b"GitHub search returned 100 results (the maximum). Some results may be missing."
.to_vec();
let result = interpret_gh_output(&stdout, &stderr, false);
assert!(
result.is_ok(),
"Expected Ok with partial results, got Err: {:?}",
result.unwrap_err()
);
let items = result.unwrap();
assert_eq!(
items.len(),
1,
"Expected 1 parsed item from partial results, got {}",
items.len()
);
assert_eq!(items[0].number, 42);
}
#[test]
fn test_interpret_gh_output_real_failure_still_errors() {
let stdout = gh_output_with_one_pr();
let stderr = b"some other gh error: authentication failed".to_vec();
let result = interpret_gh_output(&stdout, &stderr, false);
assert!(
result.is_err(),
"Expected Err for a real gh failure, but got Ok"
);
}
}
mod gear_subcommand {
use super::*;
fn setup_gear_test() -> (ConfigurationValues, RepoConfig, tempfile::TempDir) {
let tmp = tempfile::TempDir::new().unwrap();
let local_path = tmp.path().join("enwiro");
std::fs::create_dir(&local_path).unwrap();
let repo_config = RepoConfig {
repo: "kantord/enwiro".to_string(),
local_path,
};
let config = ConfigurationValues {
worktree_dir: Some(tmp.path().join("worktrees").to_str().unwrap().to_string()),
};
(config, repo_config, tmp)
}
fn assert_gear_emits(kind: &str, number: u64, expected: serde_json::Value) {
let (config, repo_config, _tmp) = setup_gear_test();
let path = worktree_path(&config, &repo_config, "enwiro", kind, number).unwrap();
std::fs::create_dir_all(&path).unwrap();
let mut output = Vec::new();
gear_with_writer(&config, &repo_config, "enwiro", number, &mut output).unwrap();
let json: serde_json::Value = serde_json::from_slice(&output).unwrap();
assert_eq!(json, expected);
}
#[test]
fn outputs_pull_request_url_when_pr_worktree_exists() {
assert_gear_emits(
"pr",
42,
serde_json::json!({
"version": 1,
"gear": {
"pr": {
"description": "Pull request #42 on kantord/enwiro",
"web": {
"page": {
"description": "Open the PR page",
"url": "https://github.com/kantord/enwiro/pull/42"
}
}
}
}
}),
);
}
#[test]
fn outputs_issue_url_when_issue_worktree_exists() {
assert_gear_emits(
"issue",
309,
serde_json::json!({
"version": 1,
"gear": {
"issue": {
"description": "Issue #309 on kantord/enwiro",
"web": {
"page": {
"description": "Open the issue page",
"url": "https://github.com/kantord/enwiro/issues/309"
}
}
}
}
}),
);
}
#[test]
fn errors_when_no_worktree_exists() {
let (config, repo_config, _tmp) = setup_gear_test();
let mut output = Vec::new();
let result = gear_with_writer(&config, &repo_config, "enwiro", 42, &mut output);
assert!(
result.is_err(),
"Expected error when no worktree exists, but got Ok"
);
}
}
}
fn read_config() -> anyhow::Result<ConfigurationValues> {
let payload =
CookbookPayload::read_from_stdin().context("Could not read cookbook payload from stdin")?;
let config: ConfigurationValues = serde_json::from_value(payload.config)
.context("Could not deserialize cookbook-github configuration")?;
tracing::debug!("Config loaded, repos will be auto-discovered from git cookbook");
Ok(config)
}
fn main() -> anyhow::Result<()> {
let _guard = enwiro_sdk::init_logging("enwiro-cookbook-github.log");
let args = EnwiroCookbookGithub::parse();
match args {
EnwiroCookbookGithub::Core(CookbookCore::ListRecipes(_)) => {
list_recipes()?;
}
EnwiroCookbookGithub::Core(CookbookCore::Cook(args)) => {
let config = read_config()?;
cook(&config, args)?;
}
EnwiroCookbookGithub::Gear(args) => {
let config = read_config()?;
gear(&config, args)?;
}
EnwiroCookbookGithub::ExternalPaths(args) => {
external_paths(args)?;
}
EnwiroCookbookGithub::Describe(args) => {
describe(args)?;
}
EnwiroCookbookGithub::Prune(args) => {
let config = read_config()?;
prune(&config, args)?;
}
EnwiroCookbookGithub::Core(CookbookCore::Metadata) => {
println!(
"{}",
CookbookMetadata {
capabilities: DeclaredCapabilities::declare([CookbookCapability::Listen]),
default_priority: Some(30),
project_overridable: vec![],
}
.to_json()
);
}
EnwiroCookbookGithub::Listen => {
let payload = CookbookPayload::read_first_line_from_stdin()
.context("Could not read cookbook payload from stdin")?;
let config: ConfigurationValues = serde_json::from_value(payload.config)
.context("Could not deserialize cookbook-github configuration")?;
let mut done_cache: std::collections::HashSet<String> =
std::collections::HashSet::new();
enwiro_sdk::listen::serve_updates(LISTEN_POLL_INTERVAL, move || {
let mut updates = vec![enwiro_sdk::listen::RecipeUpdate::Recipes {
data: collect_recipe_items(),
}];
updates.extend(collect_status_events(&config, &mut done_cache));
updates
});
}
};
Ok(())
}