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 gitlab_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 gitlab_token: String,
pub gitlab_api_base: String,
pub comment_marker: String,
pub user_agent: String,
pub http_referer: String,
pub x_title: String,
pub extra_system_prompt: String,
pub structural_context: bool,
pub structural_max_files: usize,
}
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(),
gitlab_webhook_secret: env::var("GITLAB_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(),
gitlab_token: env::var("GITLAB_TOKEN").unwrap_or_default(),
gitlab_api_base: env_or("GITLAB_API_BASE", "https://gitlab.com/api/v4"),
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(),
structural_context: env_or("STRUCTURAL_CONTEXT", "true").parse().unwrap_or(true),
structural_max_files: env_or("STRUCTURAL_MAX_FILES", "15").parse().unwrap_or(15),
}
}
pub fn with_repo_overrides(&self, rc: &crate::repo_config::RepoConfig) -> Config {
let mut cfg = self.clone();
if let Some(v) = &rc.model {
cfg.openrouter_model = v.clone();
}
if let Some(v) = &rc.model_explore {
cfg.openrouter_model_explore = v.clone();
}
if let Some(v) = &rc.include_globs {
cfg.include_globs = v.clone();
}
if let Some(v) = &rc.exclude_globs {
cfg.exclude_globs = v.clone();
}
if let Some(v) = rc.min_confidence {
cfg.min_confidence = v;
}
if let Some(v) = rc.max_findings {
cfg.max_findings = v;
}
if let Some(v) = rc.self_critique {
cfg.self_critique = v;
}
if let Some(v) = rc.agentic {
cfg.agentic = v;
}
if let Some(v) = &rc.instructions {
let extra = v.trim();
if !extra.is_empty() {
if cfg.extra_system_prompt.is_empty() {
cfg.extra_system_prompt = extra.to_string();
} else {
cfg.extra_system_prompt = format!("{}\n{extra}", cfg.extra_system_prompt);
}
}
}
cfg
}
}
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(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repo_config::RepoConfig;
#[test]
fn overrides_only_set_fields_and_appends_instructions() {
let mut base = Config::from_env();
base.openrouter_model = "base/model".to_string();
base.min_confidence = 10;
base.max_findings = 5;
base.self_critique = true;
base.agentic = false;
base.extra_system_prompt = "BASE CONVENTIONS".to_string();
let rc = RepoConfig {
model: Some("repo/model".to_string()),
min_confidence: Some(75),
self_critique: Some(false),
agentic: Some(true),
include_globs: Some(vec!["src/**".to_string()]),
instructions: Some("Never nit about formatting.".to_string()),
..Default::default()
};
let eff = base.with_repo_overrides(&rc);
assert_eq!(eff.openrouter_model, "repo/model");
assert_eq!(eff.min_confidence, 75);
assert!(!eff.self_critique);
assert!(eff.agentic);
assert_eq!(eff.include_globs, vec!["src/**".to_string()]);
assert_eq!(eff.max_findings, 5);
assert_eq!(
eff.extra_system_prompt,
"BASE CONVENTIONS\nNever nit about formatting."
);
}
#[test]
fn empty_repo_config_is_a_noop() {
let mut base = Config::from_env();
base.openrouter_model = "keep/me".to_string();
base.extra_system_prompt = "keep".to_string();
let eff = base.with_repo_overrides(&RepoConfig::default());
assert_eq!(eff.openrouter_model, "keep/me");
assert_eq!(eff.extra_system_prompt, "keep");
}
#[test]
fn instructions_set_when_base_prompt_empty() {
let mut base = Config::from_env();
base.extra_system_prompt = String::new();
let rc = RepoConfig {
instructions: Some("Focus on security.".to_string()),
..Default::default()
};
let eff = base.with_repo_overrides(&rc);
assert_eq!(eff.extra_system_prompt, "Focus on security.");
}
}