use std::path::PathBuf;
use crate::collect::bitbucket::{discover_workspace_repos, BitbucketClient};
use crate::collect::github::{build_http_client, discover_org_repos_at};
use crate::commands::install::{split_list, InstallArgs, InstallHost, InstallPm};
use crate::commands::install_plan::{
env_var_for, Credential, InstallPlan, JiraSettings, LinearSettings, RepoEntry,
};
use crate::core::config::{BitbucketConfig, GithubConfig};
const ENV_BITBUCKET_TOKEN: &str = "BITBUCKET_TOKEN";
const ENV_JIRA_URL: &str = "JIRA_URL";
const ENV_JIRA_EMAIL: &str = "JIRA_EMAIL";
const ENV_JIRA_API_TOKEN: &str = "JIRA_API_TOKEN";
const ENV_LINEAR_API_KEY: &str = "LINEAR_API_KEY";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct Answers {
pub host: InstallHost,
pub org: Option<String>,
pub workspace: Option<String>,
pub repo_slugs: Vec<String>,
pub repo_paths: Vec<PathBuf>,
pub repo_cache: PathBuf,
pub host_token: Option<Credential>,
pub pm: InstallPm,
pub jira: Option<JiraSettings>,
pub linear: Option<LinearSettings>,
pub output_dir: String,
pub llm_provider: String,
pub llm_api_key: Option<Credential>,
pub github_api_base: Option<String>,
pub bitbucket_api_base: Option<String>,
}
pub(crate) async fn resolve_plan(args: &InstallArgs) -> anyhow::Result<InstallPlan> {
let answers = answers_from_flags(args, &|k| std::env::var(k).ok())?;
plan_from_answers(answers).await
}
pub(crate) fn answers_from_flags(
args: &InstallArgs,
env: &dyn Fn(&str) -> Option<String>,
) -> anyhow::Result<Answers> {
let token_var = host_token_env(args.host);
let host_token = token_var.and_then(|var| credential(args.host_token.as_deref(), var, env));
let jira_url = value(args.jira_url.as_deref(), ENV_JIRA_URL, env);
let jira_user = value(args.jira_user.as_deref(), ENV_JIRA_EMAIL, env);
let jira_token = credential(args.jira_token.as_deref(), ENV_JIRA_API_TOKEN, env);
let linear_key = credential(args.linear_api_key.as_deref(), ENV_LINEAR_API_KEY, env);
let missing = missing_flags(
args,
host_token.is_some(),
&[
("--jira-url <URL>", jira_url.is_some()),
("--jira-user <EMAIL>", jira_user.is_some()),
("--jira-token <TOKEN>", jira_token.is_some()),
("--linear-api-key <KEY>", linear_key.is_some()),
],
);
if !missing.is_empty() {
anyhow::bail!(
"`tga install` has no terminal to prompt on and these required flags are missing:\n {}\n\n\
Supply them, or run `tga install` on a terminal for the interactive wizard.",
missing.join("\n ")
);
}
let pm = args.pm.unwrap_or(InstallPm::None);
let llm_provider = args.llm_provider.to_lowercase();
Ok(Answers {
host: args.host.unwrap_or(InstallHost::Local),
org: args.org.clone(),
workspace: args.workspace.clone(),
repo_slugs: args.repo.clone(),
repo_paths: args.repo_path.clone(),
repo_cache: args.repo_cache.clone(),
host_token,
pm,
jira: match pm {
InstallPm::Jira => Some(JiraSettings {
url: jira_url.unwrap_or_default(),
username: jira_user.unwrap_or_default(),
token: jira_token.unwrap_or_else(|| Credential::literal("")),
}),
_ => None,
},
linear: match pm {
InstallPm::Linear => Some(LinearSettings {
api_key: linear_key.unwrap_or_else(|| Credential::literal("")),
team_keys: args.linear_team.clone(),
}),
_ => None,
},
llm_api_key: credential(args.llm_api_key.as_deref(), env_var_for(&llm_provider), env),
llm_provider,
output_dir: args.output_dir.clone(),
github_api_base: None,
bitbucket_api_base: None,
})
}
fn missing_flags(args: &InstallArgs, has_token: bool, pm_creds: &[(&str, bool)]) -> Vec<String> {
let mut missing = Vec::new();
if args.host.is_none() {
missing.push("--host <local|github|bitbucket>".to_string());
}
if args.pm.is_none() {
missing.push("--pm <none|github|jira|linear>".to_string());
}
match args.host {
None => return missing,
Some(InstallHost::Local) => {
if args.repo_path.is_empty() {
missing.push("--repo-path <PATH> (repeatable)".to_string());
}
}
Some(InstallHost::Github) => {
if args.org.is_none() && args.repo.is_empty() && args.repo_path.is_empty() {
missing.push("--org <ORG> (or --repo <OWNER/NAME>, repeatable)".to_string());
}
if !has_token {
missing.push("--host-token <TOKEN> (or set $GITHUB_TOKEN)".to_string());
}
}
Some(InstallHost::Bitbucket) => {
if args.workspace.is_none() {
missing.push("--workspace <WORKSPACE>".to_string());
}
if !has_token {
missing.push("--host-token <TOKEN> (or set $BITBUCKET_TOKEN)".to_string());
}
}
}
let needed: &[usize] = match args.pm {
Some(InstallPm::Jira) => &[0, 1, 2],
Some(InstallPm::Linear) => &[3],
_ => &[],
};
for i in needed {
let (flag, present) = pm_creds[*i];
if !present {
missing.push(flag.to_string());
}
}
missing
}
pub(crate) async fn plan_from_answers(answers: Answers) -> anyhow::Result<InstallPlan> {
let Answers {
host,
org,
workspace,
repo_slugs,
repo_paths,
repo_cache,
host_token,
pm: _,
jira,
linear,
output_dir,
llm_provider,
llm_api_key,
github_api_base,
bitbucket_api_base,
} = answers;
let mut plan = InstallPlan {
output_dir,
llm_provider,
llm_api_key,
jira,
linear,
..InstallPlan::default()
};
for p in &repo_paths {
plan.repos.push(RepoEntry {
name: leaf_name(p),
path: p.clone(),
org: None,
});
}
match host {
InstallHost::Local => {}
InstallHost::Github => {
let mut pairs = parse_slugs(&repo_slugs, None);
if let Some(org) = &org {
pairs.extend(discover(host_token.as_ref(), github_api_base.as_deref(), org).await?);
if pairs.is_empty() {
plan.notes.push(format!(
"GitHub org `{org}` returned no repositories the supplied token can see"
));
}
}
plan.github_token = host_token;
plan.github_org = org;
push_remote(&mut plan, &repo_cache, pairs);
}
InstallHost::Bitbucket => {
let mut pairs = parse_slugs(&repo_slugs, workspace.as_deref());
if let Some(ws) = &workspace {
pairs.extend(
discover_workspace(host_token.as_ref(), bitbucket_api_base.as_deref(), ws)
.await?,
);
if pairs.is_empty() {
plan.notes.push(format!(
"Bitbucket workspace `{ws}` returned no repositories the supplied \
token can see"
));
}
}
plan.bitbucket_workspace = workspace;
plan.bitbucket_token = host_token;
push_remote(&mut plan, &repo_cache, pairs);
}
}
Ok(plan)
}
async fn discover(
token: Option<&Credential>,
api_base: Option<&str>,
org: &str,
) -> anyhow::Result<Vec<(String, String)>> {
let cfg = GithubConfig {
token: token.map(|c| c.value.clone()),
..GithubConfig::default()
};
let http = build_http_client(&cfg)?;
let base = api_base.unwrap_or("https://api.github.com");
Ok(discover_org_repos_at(&http, base, org).await?)
}
async fn discover_workspace(
token: Option<&Credential>,
api_base: Option<&str>,
workspace: &str,
) -> anyhow::Result<Vec<(String, String)>> {
let cfg = BitbucketConfig {
token: token.map(|c| c.value.clone()),
workspaces: vec![workspace.to_string()],
api_base_url: api_base.map(str::to_string),
..BitbucketConfig::default()
};
let client = BitbucketClient::new_for_discovery(&cfg)?;
Ok(discover_workspace_repos(&client, workspace).await?)
}
fn push_remote(plan: &mut InstallPlan, cache: &std::path::Path, pairs: Vec<(String, String)>) {
let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
for (owner, name) in pairs {
if !seen.insert((owner.clone(), name.clone())) {
continue;
}
plan.repos.push(RepoEntry {
path: cache.join(&owner).join(&name),
name,
org: Some(owner),
});
}
}
fn parse_slugs(slugs: &[String], default_owner: Option<&str>) -> Vec<(String, String)> {
let mut out = Vec::new();
for raw in slugs.iter().flat_map(|s| split_list(s)) {
match raw.split_once('/') {
Some((owner, name)) if !owner.is_empty() && !name.is_empty() => {
out.push((owner.to_string(), name.to_string()));
}
_ => {
if let Some(owner) = default_owner {
out.push((owner.to_string(), raw));
}
}
}
}
out
}
fn leaf_name(p: &std::path::Path) -> String {
p.file_name()
.and_then(|s| s.to_str())
.unwrap_or("repo")
.to_string()
}
fn host_token_env(host: Option<InstallHost>) -> Option<&'static str> {
match host {
Some(InstallHost::Github) => Some(trusty_common::env_vars::ENV_GITHUB_TOKEN),
Some(InstallHost::Bitbucket) => Some(ENV_BITBUCKET_TOKEN),
_ => None,
}
}
fn value(flag: Option<&str>, var: &str, env: &dyn Fn(&str) -> Option<String>) -> Option<String> {
flag.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| {
env(var)
.map(|v| v.trim().to_string())
.filter(|s| !s.is_empty())
})
}
fn credential(
flag: Option<&str>,
var: &str,
env: &dyn Fn(&str) -> Option<String>,
) -> Option<Credential> {
if let Some(v) = flag.map(str::trim).filter(|s| !s.is_empty()) {
return Some(Credential::literal(v));
}
env(var)
.map(|v| v.trim().to_string())
.filter(|s| !s.is_empty())
.map(|v| Credential::from_env(var, v))
}
#[cfg(test)]
pub(crate) fn args_for_tests(argv: &[&str]) -> InstallArgs {
use clap::Parser;
#[derive(Parser)]
struct Wrapper {
#[command(flatten)]
args: InstallArgs,
}
Wrapper::parse_from(argv).args
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::install_plan::render_yaml;
use serde_json::json;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn env_map<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |k: &str| {
pairs
.iter()
.find(|(name, _)| *name == k)
.map(|(_, v)| (*v).to_string())
}
}
fn no_env(_: &str) -> Option<String> {
None
}
#[test]
fn missing_flags_are_named_not_prompted_for() {
let err = answers_from_flags(&args_for_tests(&["tga"]), &no_env)
.expect_err("bare install must be refused");
let msg = err.to_string();
assert!(msg.contains("--host <local|github|bitbucket>"), "{msg}");
assert!(msg.contains("--pm <none|github|jira|linear>"), "{msg}");
assert!(!msg.contains("--org"), "{msg}");
let err = answers_from_flags(
&args_for_tests(&["tga", "--host", "github", "--pm", "none"]),
&no_env,
)
.expect_err("github install without org or token must be refused");
let msg = err.to_string();
assert!(msg.contains("--org <ORG>"), "{msg}");
assert!(msg.contains("$GITHUB_TOKEN"), "{msg}");
assert!(!msg.contains("--host <"), "{msg}");
}
#[test]
fn bitbucket_needs_only_a_workspace_and_a_token() {
let answers = answers_from_flags(
&args_for_tests(&[
"tga",
"--host",
"bitbucket",
"--workspace",
"acme",
"--pm",
"none",
"--host-token",
"bb",
]),
&no_env,
)
.expect("#5220: discovery derives the repository set, so --repo is optional");
assert_eq!(answers.workspace.as_deref(), Some("acme"));
assert!(answers.repo_slugs.is_empty());
}
#[test]
fn bitbucket_without_a_token_is_refused() {
let err = answers_from_flags(
&args_for_tests(&[
"tga",
"--host",
"bitbucket",
"--workspace",
"acme",
"--pm",
"none",
]),
&no_env,
)
.expect_err("no credential means nothing can be discovered");
assert!(err.to_string().contains("--host-token <TOKEN>"), "{err}");
}
#[test]
fn pm_choice_pulls_in_its_own_credential_flags() {
let args = args_for_tests(&[
"tga",
"--host",
"local",
"--repo-path",
"/r",
"--pm",
"jira",
]);
let msg = answers_from_flags(&args, &no_env)
.expect_err("jira without creds")
.to_string();
assert!(msg.contains("--jira-url <URL>"), "{msg}");
assert!(msg.contains("--jira-user <EMAIL>"), "{msg}");
assert!(msg.contains("--jira-token <TOKEN>"), "{msg}");
let args = args_for_tests(&[
"tga",
"--host",
"local",
"--repo-path",
"/r",
"--pm",
"linear",
]);
let msg = answers_from_flags(&args, &no_env)
.expect_err("linear without a key")
.to_string();
assert!(msg.contains("--linear-api-key <KEY>"), "{msg}");
}
#[test]
fn flag_value_beats_the_environment() {
let args = args_for_tests(&[
"tga",
"--host",
"github",
"--org",
"acme",
"--pm",
"none",
"--host-token",
"from-flag",
]);
let answers =
answers_from_flags(&args, &env_map(&[("GITHUB_TOKEN", "from-env")])).expect("resolves");
let token = answers.host_token.expect("token resolved");
assert_eq!(token.value, "from-flag");
assert_eq!(token.emitted, "from-flag");
}
#[test]
fn env_token_is_emitted_as_a_reference() {
let args = args_for_tests(&["tga", "--host", "github", "--org", "acme", "--pm", "none"]);
let answers = answers_from_flags(&args, &env_map(&[("GITHUB_TOKEN", "ghp_secret")]))
.expect("resolves");
let token = answers.host_token.expect("token resolved");
assert_eq!(token.value, "ghp_secret");
assert_eq!(token.emitted, "${GITHUB_TOKEN}");
}
#[tokio::test]
async fn flag_path_discovers_org_repos_and_writes_them() {
let server = MockServer::start().await;
let page1: Vec<serde_json::Value> = (0..100)
.map(|i| json!({"full_name": format!("acme/repo{i}")}))
.collect();
Mock::given(method("GET"))
.and(path("/orgs/acme/repos"))
.and(query_param("page", "1"))
.respond_with(ResponseTemplate::new(200).set_body_json(page1))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/orgs/acme/repos"))
.and(query_param("page", "2"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!([{"full_name": "acme/widget"}])),
)
.mount(&server)
.await;
let args = args_for_tests(&[
"tga",
"--host",
"github",
"--org",
"acme",
"--pm",
"github",
"--host-token",
"ghp_test",
"--repo-cache",
"/cache",
]);
let mut answers = answers_from_flags(&args, &no_env).expect("resolves");
answers.github_api_base = Some(server.uri());
let plan = plan_from_answers(answers)
.await
.expect("discovery succeeds");
assert_eq!(plan.repos.len(), 101, "both pages must be collected");
assert_eq!(plan.github_org.as_deref(), Some("acme"));
let yaml = render_yaml(&plan);
assert!(yaml.contains("path: \"/cache/acme/widget\""), "{yaml}");
assert!(yaml.contains("name: \"widget\""), "{yaml}");
assert!(yaml.contains("name: \"repo0\""), "{yaml}");
assert!(yaml.contains(" org: \"acme\""), "{yaml}");
assert!(yaml.contains("token: \"ghp_test\""), "{yaml}");
}
#[tokio::test]
async fn generated_config_loads_back_through_config_load() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/orgs/acme/repos"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!([{"full_name": "acme/widget"}])),
)
.mount(&server)
.await;
let args = args_for_tests(&[
"tga",
"--host",
"github",
"--org",
"acme",
"--pm",
"linear",
"--host-token",
"ghp_test",
"--linear-api-key",
"lin_key",
"--linear-team",
"ENG",
"--repo-cache",
"/cache",
]);
let mut answers = answers_from_flags(&args, &no_env).expect("resolves");
answers.github_api_base = Some(server.uri());
let plan = plan_from_answers(answers).await.expect("resolves");
let dir = tempfile::tempdir().expect("tempdir");
let cfg_path = dir.path().join("config.yaml");
std::fs::write(&cfg_path, render_yaml(&plan)).expect("write");
let loaded = crate::core::config::Config::load(&cfg_path).expect("config loads back");
assert_eq!(loaded.repositories.len(), 1);
assert_eq!(
loaded.repositories[0].path,
std::path::PathBuf::from("/cache/acme/widget")
);
assert_eq!(loaded.repositories[0].org.as_deref(), Some("acme"));
let github = loaded.github.expect("github block");
assert_eq!(github.org.as_deref(), Some("acme"));
let linear = loaded.linear.expect("linear block");
assert_eq!(linear.team_keys, vec!["ENG".to_string()]);
}
#[tokio::test]
async fn empty_org_listing_is_recorded_as_a_note() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/orgs/ghost/repos"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
.mount(&server)
.await;
let args = args_for_tests(&[
"tga",
"--host",
"github",
"--org",
"ghost",
"--pm",
"none",
"--host-token",
"ghp_test",
]);
let mut answers = answers_from_flags(&args, &no_env).expect("resolves");
answers.github_api_base = Some(server.uri());
let plan = plan_from_answers(answers).await.expect("resolves");
assert!(plan.repos.is_empty());
assert!(
plan.notes.iter().any(|n| n.contains("no repositories")),
"{:?}",
plan.notes
);
}
#[tokio::test]
async fn flag_path_discovers_workspace_repos_and_writes_them() {
let server = MockServer::start().await;
let base = server.uri();
Mock::given(method("GET"))
.and(path("/repositories/acme"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"values": [{"full_name": "acme/widget", "slug": "widget"}],
"next": format!("{base}/repositories/acme?page=2"),
})))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repositories/acme"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"values": [{"full_name": "acme/sprocket", "slug": "sprocket"}],
})))
.mount(&server)
.await;
let args = args_for_tests(&[
"tga",
"--host",
"bitbucket",
"--workspace",
"acme",
"--repo",
"gadget",
"--pm",
"none",
"--host-token",
"bb-token",
"--repo-cache",
"/cache",
]);
let mut answers = answers_from_flags(&args, &no_env).expect("resolves");
answers.bitbucket_api_base = Some(base);
let plan = plan_from_answers(answers)
.await
.expect("discovery succeeds");
let yaml = render_yaml(&plan);
assert!(yaml.contains("workspaces: [\"acme\"]"), "{yaml}");
assert!(yaml.contains("path: \"/cache/acme/gadget\""), "{yaml}");
assert!(yaml.contains("path: \"/cache/acme/widget\""), "{yaml}");
assert!(yaml.contains("path: \"/cache/acme/sprocket\""), "{yaml}");
assert!(!yaml.contains("#5220"), "the stale caveat is gone: {yaml}");
}
#[tokio::test]
async fn a_rejected_workspace_token_fails_the_install() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repositories/acme"))
.respond_with(ResponseTemplate::new(401).set_body_string("Invalid credentials"))
.mount(&server)
.await;
let args = args_for_tests(&[
"tga",
"--host",
"bitbucket",
"--workspace",
"acme",
"--pm",
"none",
"--host-token",
"bb-token",
"--repo-cache",
"/cache",
]);
let mut answers = answers_from_flags(&args, &no_env).expect("resolves");
answers.bitbucket_api_base = Some(server.uri());
let err = plan_from_answers(answers)
.await
.expect_err("a 401 must not render an empty repository set");
let msg = err.to_string();
assert!(msg.contains("401"), "the status must be named: {msg}");
assert!(
msg.contains("Invalid credentials"),
"Bitbucket's own explanation must survive: {msg}"
);
}
}