use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoEntry {
pub path: PathBuf,
pub name: String,
pub org: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Credential {
pub value: String,
pub emitted: String,
}
impl Credential {
#[must_use]
pub fn literal(value: impl Into<String>) -> Self {
let value = value.into();
Self {
emitted: value.clone(),
value,
}
}
#[must_use]
pub fn from_env(var: &str, value: impl Into<String>) -> Self {
Self {
value: value.into(),
emitted: format!("${{{var}}}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JiraSettings {
pub url: String,
pub username: String,
pub token: Credential,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinearSettings {
pub api_key: Credential,
pub team_keys: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InstallPlan {
pub repos: Vec<RepoEntry>,
pub github_org: Option<String>,
pub github_token: Option<Credential>,
pub bitbucket_workspace: Option<String>,
pub bitbucket_token: Option<Credential>,
pub jira: Option<JiraSettings>,
pub linear: Option<LinearSettings>,
pub output_dir: String,
pub llm_provider: String,
pub llm_api_key: Option<Credential>,
pub notes: Vec<String>,
}
#[must_use]
pub fn render_yaml(plan: &InstallPlan) -> String {
let mut out = String::new();
out.push_str("# Generated by `tga install`\n");
for note in &plan.notes {
out.push_str(&format!("# NOTE: {note}\n"));
}
out.push_str("version: \"1.0\"\n\n");
out.push_str("repositories:\n");
for r in &plan.repos {
out.push_str(&format!(" - path: \"{}\"\n", r.path.display()));
out.push_str(&format!(" name: \"{}\"\n", r.name));
if let Some(org) = &r.org {
out.push_str(&format!(" org: \"{org}\"\n"));
}
}
out.push('\n');
out.push_str("output:\n");
out.push_str(&format!(" directory: \"{}\"\n", plan.output_dir));
out.push_str(" formats: [csv, json, markdown]\n\n");
render_hosts(plan, &mut out);
render_pm(plan, &mut out);
render_classification(plan, &mut out);
out
}
fn render_hosts(plan: &InstallPlan, out: &mut String) {
if plan.github_token.is_some() || plan.github_org.is_some() {
out.push_str("github:\n");
if let Some(token) = &plan.github_token {
out.push_str(&format!(" token: \"{}\"\n", token.emitted));
}
if let Some(org) = &plan.github_org {
out.push_str(&format!(" org: \"{org}\"\n"));
}
out.push_str(" fetch_prs: true\n\n");
}
if plan.bitbucket_workspace.is_some() || plan.bitbucket_token.is_some() {
out.push_str("bitbucket:\n");
if let Some(ws) = &plan.bitbucket_workspace {
out.push_str(&format!(" workspaces: [\"{ws}\"]\n"));
}
if let Some(token) = &plan.bitbucket_token {
out.push_str(&format!(" token: \"{}\"\n", token.emitted));
}
out.push_str(" fetch_prs: true\n\n");
}
}
fn render_pm(plan: &InstallPlan, out: &mut String) {
if let Some(jira) = &plan.jira {
out.push_str("jira:\n");
out.push_str(&format!(" url: \"{}\"\n", jira.url));
out.push_str(&format!(" username: \"{}\"\n", jira.username));
out.push_str(&format!(" token: \"{}\"\n\n", jira.token.emitted));
}
if let Some(linear) = &plan.linear {
out.push_str("linear:\n");
out.push_str(&format!(" api_key: \"{}\"\n", linear.api_key.emitted));
if !linear.team_keys.is_empty() {
let keys = linear
.team_keys
.iter()
.map(|k| format!("\"{k}\""))
.collect::<Vec<_>>()
.join(", ");
out.push_str(&format!(" team_keys: [{keys}]\n"));
}
out.push('\n');
}
}
fn render_classification(plan: &InstallPlan, out: &mut String) {
if plan.llm_provider != "openai" && plan.llm_provider != "openrouter" {
return;
}
out.push_str("classification:\n");
out.push_str(" use_llm: true\n");
out.push_str(&format!(
" llm_model: \"{}\"\n",
default_model_for(&plan.llm_provider)
));
if let Some(key) = &plan.llm_api_key {
out.push_str(&format!(
" # API key (also pickable from ${} env var)\n",
env_var_for(&plan.llm_provider)
));
out.push_str(&format!(" # llm_api_key: \"{}\"\n", key.emitted));
}
out.push('\n');
}
#[must_use]
pub fn default_model_for(provider: &str) -> &'static str {
match provider {
"openai" => "gpt-4o-mini",
"openrouter" => "openrouter/auto",
_ => "",
}
}
#[must_use]
pub fn env_var_for(provider: &str) -> &'static str {
match provider {
"openai" => "OPENAI_API_KEY",
"openrouter" => "OPENROUTER_API_KEY",
_ => "LLM_API_KEY",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_plan(path: &str, output_dir: &str) -> InstallPlan {
InstallPlan {
repos: vec![RepoEntry {
path: PathBuf::from(path),
name: "repo".to_string(),
org: None,
}],
output_dir: output_dir.to_string(),
llm_provider: "none".to_string(),
..InstallPlan::default()
}
}
#[test]
fn render_yaml_minimal() {
let yaml = render_yaml(&minimal_plan("/tmp/repo", "./out"));
assert!(yaml.contains("repositories:"));
assert!(yaml.contains("path: \"/tmp/repo\""));
assert!(yaml.contains("output:"));
assert!(yaml.contains("directory: \"./out\""));
assert!(!yaml.contains("github:"));
assert!(!yaml.contains("bitbucket:"));
assert!(!yaml.contains("jira:"));
assert!(!yaml.contains("linear:"));
assert!(!yaml.contains("classification:"));
}
#[test]
fn render_yaml_with_github_and_llm() {
let mut plan = minimal_plan("/tmp/repo", "./out");
plan.github_token = Some(Credential::literal("ghp_xxx"));
plan.llm_provider = "openai".to_string();
plan.llm_api_key = Some(Credential::literal("sk-xxx"));
let yaml = render_yaml(&plan);
assert!(yaml.contains("github:"));
assert!(yaml.contains("ghp_xxx"));
assert!(yaml.contains("classification:"));
assert!(yaml.contains("use_llm: true"));
assert!(yaml.contains("gpt-4o-mini"));
}
#[test]
fn render_yaml_emits_discovered_org_repos() {
let mut plan = minimal_plan("/cache/acme/widget", "./out");
plan.repos = vec![
RepoEntry {
path: PathBuf::from("/cache/acme/widget"),
name: "widget".to_string(),
org: Some("acme".to_string()),
},
RepoEntry {
path: PathBuf::from("/cache/acme/gadget"),
name: "gadget".to_string(),
org: Some("acme".to_string()),
},
];
plan.github_org = Some("acme".to_string());
let yaml = render_yaml(&plan);
assert!(yaml.contains("name: \"widget\""), "{yaml}");
assert!(yaml.contains("name: \"gadget\""), "{yaml}");
assert!(yaml.contains(" org: \"acme\""), "{yaml}");
assert!(yaml.contains(" org: \"acme\""), "{yaml}");
}
#[test]
fn render_yaml_emits_linear_and_bitbucket() {
let mut plan = minimal_plan("/tmp/repo", "./out");
plan.bitbucket_workspace = Some("acme".to_string());
plan.bitbucket_token = Some(Credential::literal("bb-token"));
plan.linear = Some(LinearSettings {
api_key: Credential::literal("lin_xxx"),
team_keys: vec!["ENG".to_string(), "OPS".to_string()],
});
plan.notes = vec!["the workspace looked empty".to_string()];
let yaml = render_yaml(&plan);
assert!(yaml.contains("bitbucket:"), "{yaml}");
assert!(yaml.contains("workspaces: [\"acme\"]"), "{yaml}");
assert!(yaml.contains("linear:"), "{yaml}");
assert!(yaml.contains("team_keys: [\"ENG\", \"OPS\"]"), "{yaml}");
assert!(
yaml.contains("# NOTE: the workspace looked empty"),
"{yaml}"
);
}
#[test]
fn env_sourced_credentials_emit_a_reference_not_the_secret() {
let mut plan = minimal_plan("/tmp/repo", "./out");
plan.github_token = Some(Credential::from_env("GITHUB_TOKEN", "ghp_secret"));
let yaml = render_yaml(&plan);
assert!(yaml.contains("token: \"${GITHUB_TOKEN}\""), "{yaml}");
assert!(!yaml.contains("ghp_secret"), "{yaml}");
}
}