use std::env;
#[derive(Clone)]
pub struct Config {
pub port: u16,
pub worker_token: String,
pub github_webhook_secret: String,
pub bitbucket_webhook_secret: String,
pub openrouter_api_key: String,
pub openrouter_model: String,
pub openrouter_model_explore: String,
pub openrouter_base_url: String,
pub openrouter_max_tokens: u32,
pub openrouter_temperature: f32,
pub max_diff_chars: usize,
pub include_globs: Vec<String>,
pub exclude_globs: Vec<String>,
pub self_critique: bool,
pub min_confidence: u8,
pub max_findings: usize,
pub agentic: bool,
pub max_turns: usize,
pub max_history_chars: usize,
pub review_on_update: bool,
pub github_token: String,
pub github_api_base: String,
pub bitbucket_email: String,
pub bitbucket_token: String,
pub bitbucket_api_base: String,
pub comment_marker: String,
pub user_agent: String,
pub http_referer: String,
pub x_title: String,
pub extra_system_prompt: String,
}
impl Config {
pub fn from_env() -> Self {
Self {
port: env_or("PORT", "8088").parse().unwrap_or(8088),
worker_token: env::var("WORKER_TOKEN").unwrap_or_default(),
github_webhook_secret: env::var("GITHUB_WEBHOOK_SECRET").unwrap_or_default(),
bitbucket_webhook_secret: env::var("BITBUCKET_WEBHOOK_SECRET").unwrap_or_default(),
openrouter_api_key: env::var("OPENROUTER_API_KEY")
.or_else(|_| env::var("LLM_API_KEY"))
.unwrap_or_default(),
openrouter_model: env_or("OPENROUTER_MODEL", "anthropic/claude-sonnet-4.5"),
openrouter_model_explore: env_or("OPENROUTER_MODEL_EXPLORE", "moonshotai/kimi-k2-0905"),
openrouter_base_url: env::var("LLM_BASE_URL")
.or_else(|_| env::var("OPENROUTER_BASE_URL"))
.unwrap_or_else(|_| "https://openrouter.ai/api/v1".to_string()),
openrouter_max_tokens: env_or("OPENROUTER_MAX_TOKENS", "4000")
.parse()
.unwrap_or(4000),
openrouter_temperature: env_or("OPENROUTER_TEMPERATURE", "0.2")
.parse()
.unwrap_or(0.2),
max_diff_chars: env_or("MAX_DIFF_CHARS", "200000")
.parse()
.unwrap_or(200_000),
include_globs: env_globs("INCLUDE_GLOBS", &[]),
exclude_globs: env_globs(
"EXCLUDE_GLOBS",
&[
"**/*.lock",
"**/package-lock.json",
"**/pnpm-lock.yaml",
"**/yarn.lock",
"**/bun.lockb",
"**/Cargo.lock",
"**/go.sum",
"**/composer.lock",
"**/Gemfile.lock",
"**/poetry.lock",
"**/*.min.js",
"**/*.min.css",
"**/dist/**",
"**/build/**",
"**/vendor/**",
"**/node_modules/**",
"**/*.snap",
"**/__snapshots__/**",
"**/*.pb.go",
"**/*_generated.*",
"**/*.generated.*",
],
),
self_critique: env_or("SELF_CRITIQUE", "true").parse().unwrap_or(true),
min_confidence: env_or("MIN_CONFIDENCE", "0").parse().unwrap_or(0),
max_findings: env_or("MAX_FINDINGS", "20").parse().unwrap_or(20),
agentic: env_or("AGENTIC", "false").parse().unwrap_or(false),
max_turns: env_or("MAX_TURNS", "6").parse().unwrap_or(6),
max_history_chars: env_or("MAX_HISTORY_CHARS", "45000")
.parse()
.unwrap_or(45_000),
review_on_update: env_or("REVIEW_ON_UPDATE", "false").parse().unwrap_or(false),
github_token: env::var("GH_TOKEN").unwrap_or_default(),
github_api_base: env_or("GH_API_BASE", "https://api.github.com"),
bitbucket_email: env::var("BB_EMAIL").unwrap_or_default(),
bitbucket_token: env::var("BB_API_TOKEN").unwrap_or_default(),
bitbucket_api_base: "https://api.bitbucket.org/2.0".to_string(),
comment_marker: env_or("COMMENT_MARKER", "🤖 ai-pr-review"),
user_agent: env_or("USER_AGENT", "pr-review-core"),
http_referer: env_or(
"OPENROUTER_HTTP_REFERER",
"https://github.com/nhatvu148/pr-review-core",
),
x_title: env_or("OPENROUTER_X_TITLE", "pr-review"),
extra_system_prompt: resolve_extra_system_prompt(),
}
}
}
fn resolve_extra_system_prompt() -> String {
match env::var("EXTRA_SYSTEM_PROMPT") {
Ok(v) if !v.is_empty() => v,
_ => match env::var("EXTRA_SYSTEM_PROMPT_FILE") {
Ok(path) if !path.is_empty() => std::fs::read_to_string(path).unwrap_or_default(),
_ => String::new(),
},
}
}
fn env_or(name: &str, default: &str) -> String {
env::var(name).unwrap_or_else(|_| default.to_string())
}
fn env_globs(name: &str, default: &[&str]) -> Vec<String> {
match env::var(name) {
Ok(v) if !v.trim().is_empty() => v
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect(),
_ => default.iter().map(|s| s.to_string()).collect(),
}
}
pub fn require(value: &str, name: &str) -> anyhow::Result<()> {
if value.is_empty() {
anyhow::bail!("Missing required env var: {name}");
}
Ok(())
}