pr_review_core/config.rs
1//! Environment configuration, loaded once at process start.
2//!
3//! Everything has a default except secrets; values that are required only for a
4//! specific path (a provider token, the OpenRouter key) are validated at the
5//! point of use via [`require`].
6
7use std::env;
8
9/// Resolved runtime configuration.
10#[derive(Clone)]
11pub struct Config {
12 pub port: u16,
13 pub worker_token: String,
14 /// Shared secret GitHub signs webhook deliveries with (X-Hub-Signature-256).
15 pub github_webhook_secret: String,
16 /// Shared secret Bitbucket signs webhook deliveries with (X-Hub-Signature).
17 pub bitbucket_webhook_secret: String,
18 /// Shared token GitLab sends webhook deliveries with (X-Gitlab-Token). Unlike
19 /// GitHub/Bitbucket this is a plain token compared verbatim, not an HMAC.
20 pub gitlab_webhook_secret: String,
21
22 /// OpenRouter (or any OpenAI-compatible) API key. Resolved from
23 /// `OPENROUTER_API_KEY`, falling back to `LLM_API_KEY` — the alias lets
24 /// Ollama/vLLM/local OpenAI-compatible servers reuse a generic var name.
25 pub openrouter_api_key: String,
26 /// Synthesis model: writes the final review findings (quality matters here).
27 pub openrouter_model: String,
28 /// Exploration model: drives the agentic tool loop (grep/read_file/list_dir)
29 /// to gather context. Cheaper — it navigates files, it doesn't judge.
30 pub openrouter_model_explore: String,
31 /// Base URL of the OpenAI-compatible chat-completions API. Resolved from
32 /// `LLM_BASE_URL`, then `OPENROUTER_BASE_URL`, defaulting to OpenRouter — the
33 /// `LLM_BASE_URL` alias lets Ollama/vLLM/local OpenAI-compatible servers work.
34 pub openrouter_base_url: String,
35 pub openrouter_max_tokens: u32,
36 pub openrouter_temperature: f32,
37 /// Whole-request timeout for a single model call. The hand-rolled loop had
38 /// none, so a stalled provider hung the entire review.
39 pub openrouter_timeout_secs: u64,
40 /// Retries for a transient failure (429 / 5xx) on one call. The hand-rolled
41 /// loop had none, so a single 429 discarded the whole review.
42 pub openrouter_max_retries: u32,
43
44 pub max_diff_chars: usize,
45
46 /// Glob patterns of files to INCLUDE in the diff before it's sent to the LLM.
47 /// Empty means include everything (subject to `exclude_globs`).
48 pub include_globs: Vec<String>,
49 /// Glob patterns of files to EXCLUDE from the diff (lockfiles, generated,
50 /// vendored, minified) — drops noise and saves tokens before the LLM call.
51 pub exclude_globs: Vec<String>,
52
53 /// Glob patterns marking vendored third-party source. Diff-hygiene (class D)
54 /// findings are suppressed inside these paths — bulk-committing upstream code is
55 /// the intent of a vendoring PR, not a defect. Empty means the conventional
56 /// defaults ([`crate::diff::DEFAULT_VENDORED_DIRS`]).
57 pub vendored_globs: Vec<String>,
58
59 /// When packing a large diff to the size budget, group related changed files
60 /// (a source + its test, i18n siblings) into one unit so they stay adjacent
61 /// and pack together instead of being scattered by priority. Fail-open.
62 pub file_bundling: bool,
63
64 /// Run a second, skeptical "self-critique" pass that removes false positives
65 /// and out-of-scope nits from the findings before posting.
66 pub self_critique: bool,
67 /// Drop findings whose model-reported `confidence` is below this threshold.
68 pub min_confidence: u8,
69 /// Hard cap on the number of findings posted (after sorting by severity).
70 pub max_findings: usize,
71
72 /// Re-anchor a finding whose `line` just missed a real diff line: snap it to
73 /// the nearest diff line within a small window when that line's code matches
74 /// what the finding references, so a small drift posts inline instead of
75 /// folding into the summary. Conservative + fail-open.
76 pub reanchor_findings: bool,
77
78 /// Use the agentic reviewer: clone the repo and let the model investigate
79 /// cross-file context with tools, instead of a single diff-only call.
80 pub agentic: bool,
81 /// Max tool-loop turns for the agentic reviewer (cost guard).
82 pub max_turns: usize,
83 /// Char budget for accumulated tool results in the agent conversation;
84 /// older results are elided once newer ones fill it (bounds per-turn tokens).
85 pub max_history_chars: usize,
86
87 /// Re-review when new commits are pushed to an open PR (GitHub `synchronize`
88 /// / Bitbucket `pullrequest:updated`). Off by default so iterating on a PR
89 /// doesn't trigger a fresh (expensive) review per push — `opened`/`reopened`/
90 /// `ready_for_review` always review; use the manual `POST /review` endpoint
91 /// to re-review on demand.
92 pub review_on_update: bool,
93
94 pub github_token: String,
95 pub github_api_base: String,
96
97 pub bitbucket_email: String,
98 pub bitbucket_token: String,
99 pub bitbucket_api_base: String,
100
101 pub gitlab_token: String,
102 pub gitlab_api_base: String,
103
104 /// Signature appended to every comment the bot posts and used as the dedupe
105 /// key to find/update its own comments. Injected so the library carries no
106 /// hardcoded bot identity.
107 pub comment_marker: String,
108 /// `User-Agent` header sent on provider (GitHub) API requests.
109 pub user_agent: String,
110 /// `HTTP-Referer` header sent to OpenRouter (attribution).
111 pub http_referer: String,
112 /// `X-Title` header sent to OpenRouter (attribution).
113 pub x_title: String,
114 /// Extra system-prompt text appended to the built-in prompts. Lets a consumer
115 /// inject a large conventions block (e.g. via a file baked into its image)
116 /// without changing the library.
117 pub extra_system_prompt: String,
118
119 /// Compute "structural context" (the enclosing function/symbol of each changed
120 /// line) and inject it into the review prompt so the model knows each change's
121 /// scope. Fully fail-open — never blocks a review.
122 pub structural_context: bool,
123 /// Max number of files to fetch + parse for structural context (cost guard).
124 pub structural_max_files: usize,
125
126 /// Surface deterministic complexity metrics (cyclomatic + cognitive, A–F grade)
127 /// for the functions a change touches, computed with tree-sitter alongside the
128 /// structural context. A cheap, LLM-free risk signal. Fully fail-open.
129 pub complexity_metrics: bool,
130 /// Only report a changed function when its cyclomatic complexity is at least
131 /// this — keeps the block a high-signal risk flag, not noise for trivial code.
132 pub complexity_min_cyclomatic: u32,
133
134 /// Compute a "blast radius" for the agentic reviewer: from the clone, find the
135 /// callers and tests of each changed symbol and seed the prompt with them (also
136 /// exposes a `references` tool). Agentic path only — needs the clone. Fail-open.
137 pub blast_radius: bool,
138 /// Max number of changed symbols to expand into a blast radius (cost guard).
139 pub blast_max_symbols: usize,
140 /// Max call sites listed per symbol per bucket (callers / tests) (cost guard).
141 pub blast_max_refs: usize,
142
143 /// Fetch the head commit's CI results (GitHub check runs / Bitbucket build
144 /// statuses) with the PR metadata and surface them in the prompt, so the
145 /// reviewer can't assert a broken build that CI already decided.
146 ///
147 /// One extra API call per review. Off is for tokens near their rate limit;
148 /// with it off the reviewer simply sees no CI block. Fully fail-open either way.
149 pub ci_status: bool,
150 /// Scan changed lockfiles for known-vulnerable dependencies via OSV.dev and
151 /// append advisories to the review summary. Fully fail-open — never blocks a
152 /// review.
153 pub cve_scan: bool,
154 /// Max distinct packages queried against OSV per review (cost/fan-out guard).
155 pub cve_max_packages: usize,
156 /// Base URL of the OSV.dev API (override for a mirror or a test double).
157 pub osv_api_base: String,
158}
159
160impl Config {
161 /// Build the config from environment variables, applying defaults.
162 pub fn from_env() -> Self {
163 Self {
164 // Local default avoids the common 8080 clash with Docker Desktop.
165 // Production pins PORT=8080 via the Dockerfile, matching fly.toml.
166 port: env_or("PORT", "8088").parse().unwrap_or(8088),
167 worker_token: env::var("WORKER_TOKEN").unwrap_or_default(),
168 github_webhook_secret: env::var("GITHUB_WEBHOOK_SECRET").unwrap_or_default(),
169 bitbucket_webhook_secret: env::var("BITBUCKET_WEBHOOK_SECRET").unwrap_or_default(),
170 gitlab_webhook_secret: env::var("GITLAB_WEBHOOK_SECRET").unwrap_or_default(),
171
172 openrouter_api_key: env::var("OPENROUTER_API_KEY")
173 .or_else(|_| env::var("LLM_API_KEY"))
174 .unwrap_or_default(),
175 openrouter_model: env_or("OPENROUTER_MODEL", "anthropic/claude-sonnet-4.5"),
176 openrouter_model_explore: env_or("OPENROUTER_MODEL_EXPLORE", "moonshotai/kimi-k2-0905"),
177 openrouter_base_url: env::var("LLM_BASE_URL")
178 .or_else(|_| env::var("OPENROUTER_BASE_URL"))
179 .unwrap_or_else(|_| "https://openrouter.ai/api/v1".to_string()),
180 openrouter_max_tokens: env_or("OPENROUTER_MAX_TOKENS", "4000")
181 .parse()
182 .unwrap_or(4000),
183 openrouter_temperature: env_or("OPENROUTER_TEMPERATURE", "0.2")
184 .parse()
185 .unwrap_or(0.2),
186 openrouter_timeout_secs: env_or("OPENROUTER_TIMEOUT_SECS", "120")
187 .parse()
188 .unwrap_or(120),
189 openrouter_max_retries: env_or("OPENROUTER_MAX_RETRIES", "3").parse().unwrap_or(3),
190
191 max_diff_chars: env_or("MAX_DIFF_CHARS", "200000")
192 .parse()
193 .unwrap_or(200_000),
194
195 include_globs: env_globs("INCLUDE_GLOBS", &[]),
196 exclude_globs: env_globs(
197 "EXCLUDE_GLOBS",
198 &[
199 "**/*.lock",
200 "**/package-lock.json",
201 "**/pnpm-lock.yaml",
202 "**/yarn.lock",
203 "**/bun.lockb",
204 "**/Cargo.lock",
205 "**/go.sum",
206 "**/composer.lock",
207 "**/Gemfile.lock",
208 "**/poetry.lock",
209 "**/*.min.js",
210 "**/*.min.css",
211 "**/dist/**",
212 "**/build/**",
213 "**/vendor/**",
214 "**/node_modules/**",
215 "**/*.snap",
216 "**/__snapshots__/**",
217 "**/*.pb.go",
218 "**/*_generated.*",
219 "**/*.generated.*",
220 ],
221 ),
222
223 // Empty = the conventional vendored directories (see `diff::is_vendored`).
224 vendored_globs: env_globs("VENDORED_GLOBS", &[]),
225
226 file_bundling: env_or("FILE_BUNDLING", "true").parse().unwrap_or(true),
227
228 self_critique: env_or("SELF_CRITIQUE", "true").parse().unwrap_or(true),
229 min_confidence: env_or("MIN_CONFIDENCE", "0").parse().unwrap_or(0),
230 max_findings: env_or("MAX_FINDINGS", "20").parse().unwrap_or(20),
231 reanchor_findings: env_or("REANCHOR_FINDINGS", "true").parse().unwrap_or(true),
232
233 agentic: env_or("AGENTIC", "false").parse().unwrap_or(false),
234 max_turns: env_or("MAX_TURNS", "6").parse().unwrap_or(6),
235 max_history_chars: env_or("MAX_HISTORY_CHARS", "45000")
236 .parse()
237 .unwrap_or(45_000),
238
239 review_on_update: env_or("REVIEW_ON_UPDATE", "false").parse().unwrap_or(false),
240
241 github_token: env::var("GH_TOKEN").unwrap_or_default(),
242 github_api_base: env_or("GH_API_BASE", "https://api.github.com"),
243
244 bitbucket_email: env::var("BB_EMAIL").unwrap_or_default(),
245 bitbucket_token: env::var("BB_API_TOKEN").unwrap_or_default(),
246 bitbucket_api_base: "https://api.bitbucket.org/2.0".to_string(),
247
248 gitlab_token: env::var("GITLAB_TOKEN").unwrap_or_default(),
249 gitlab_api_base: env_or("GITLAB_API_BASE", "https://gitlab.com/api/v4"),
250
251 comment_marker: env_or("COMMENT_MARKER", "🤖 ai-pr-review"),
252 user_agent: env_or("USER_AGENT", "pr-review-core"),
253 http_referer: env_or(
254 "OPENROUTER_HTTP_REFERER",
255 "https://github.com/nhatvu148/pr-review-core",
256 ),
257 x_title: env_or("OPENROUTER_X_TITLE", "pr-review"),
258 extra_system_prompt: resolve_extra_system_prompt(),
259
260 structural_context: env_or("STRUCTURAL_CONTEXT", "true").parse().unwrap_or(true),
261 structural_max_files: env_or("STRUCTURAL_MAX_FILES", "15").parse().unwrap_or(15),
262
263 complexity_metrics: env_or("COMPLEXITY_METRICS", "true").parse().unwrap_or(true),
264 complexity_min_cyclomatic: env_or("COMPLEXITY_MIN_CYCLOMATIC", "8")
265 .parse()
266 .unwrap_or(8),
267
268 blast_radius: env_or("BLAST_RADIUS", "true").parse().unwrap_or(true),
269 blast_max_symbols: env_or("BLAST_MAX_SYMBOLS", "12").parse().unwrap_or(12),
270 blast_max_refs: env_or("BLAST_MAX_REFS", "8").parse().unwrap_or(8),
271
272 ci_status: env_or("CI_STATUS", "true").parse().unwrap_or(true),
273 cve_scan: env_or("CVE_SCAN", "true").parse().unwrap_or(true),
274 cve_max_packages: env_or("CVE_MAX_PACKAGES", "100").parse().unwrap_or(100),
275 osv_api_base: env_or("OSV_API_BASE", "https://api.osv.dev"),
276 }
277 }
278
279 /// Return a clone of this config with any fields set in `rc` overridden.
280 ///
281 /// Only `Some(..)` fields of the per-repo [`RepoConfig`] take effect; the rest
282 /// keep the env-derived value. `rc.instructions` is *appended* to
283 /// [`Config::extra_system_prompt`] (newline-separated) rather than replacing it,
284 /// so a consumer's baked-in conventions block and the repo's own instructions
285 /// both reach the model.
286 ///
287 /// # Examples
288 /// ```
289 /// # use pr_review_core::config::Config;
290 /// # use pr_review_core::repo_config::RepoConfig;
291 /// let base = Config::from_env();
292 /// let rc = RepoConfig { min_confidence: Some(80), ..Default::default() };
293 /// let effective = base.with_repo_overrides(&rc);
294 /// assert_eq!(effective.min_confidence, 80);
295 /// ```
296 pub fn with_repo_overrides(&self, rc: &crate::repo_config::RepoConfig) -> Config {
297 let mut cfg = self.clone();
298 if let Some(v) = &rc.model {
299 cfg.openrouter_model = v.clone();
300 }
301 if let Some(v) = &rc.model_explore {
302 cfg.openrouter_model_explore = v.clone();
303 }
304 if let Some(v) = &rc.include_globs {
305 cfg.include_globs = v.clone();
306 }
307 if let Some(v) = &rc.exclude_globs {
308 cfg.exclude_globs = v.clone();
309 }
310 if let Some(v) = &rc.vendored {
311 cfg.vendored_globs = v.clone();
312 }
313 if let Some(v) = rc.ci_status {
314 cfg.ci_status = v;
315 }
316 if let Some(v) = rc.min_confidence {
317 cfg.min_confidence = v;
318 }
319 if let Some(v) = rc.max_findings {
320 cfg.max_findings = v;
321 }
322 if let Some(v) = rc.self_critique {
323 cfg.self_critique = v;
324 }
325 if let Some(v) = rc.agentic {
326 cfg.agentic = v;
327 }
328 if let Some(v) = rc.file_bundling {
329 cfg.file_bundling = v;
330 }
331 if let Some(v) = rc.reanchor_findings {
332 cfg.reanchor_findings = v;
333 }
334 if let Some(v) = &rc.instructions {
335 let extra = v.trim();
336 if !extra.is_empty() {
337 if cfg.extra_system_prompt.is_empty() {
338 cfg.extra_system_prompt = extra.to_string();
339 } else {
340 cfg.extra_system_prompt = format!("{}\n{extra}", cfg.extra_system_prompt);
341 }
342 }
343 }
344 cfg
345 }
346}
347
348/// Resolve the extra system prompt: prefer the inline `EXTRA_SYSTEM_PROMPT` env
349/// var, fall back to the contents of the file named by `EXTRA_SYSTEM_PROMPT_FILE`,
350/// else empty.
351fn resolve_extra_system_prompt() -> String {
352 match env::var("EXTRA_SYSTEM_PROMPT") {
353 Ok(v) if !v.is_empty() => v,
354 _ => match env::var("EXTRA_SYSTEM_PROMPT_FILE") {
355 Ok(path) if !path.is_empty() => std::fs::read_to_string(path).unwrap_or_default(),
356 _ => String::new(),
357 },
358 }
359}
360
361fn env_or(name: &str, default: &str) -> String {
362 env::var(name).unwrap_or_else(|_| default.to_string())
363}
364
365/// Parse a comma-separated list of glob patterns from `name`, trimming each entry
366/// and dropping empties. Falls back to `default` when the var is unset or empty.
367fn env_globs(name: &str, default: &[&str]) -> Vec<String> {
368 match env::var(name) {
369 Ok(v) if !v.trim().is_empty() => v
370 .split(',')
371 .map(str::trim)
372 .filter(|s| !s.is_empty())
373 .map(str::to_string)
374 .collect(),
375 _ => default.iter().map(|s| s.to_string()).collect(),
376 }
377}
378
379/// Ensure a required value is present, with a clear error naming the env var.
380///
381/// # Examples
382/// ```
383/// # use pr_review_core::config::require;
384/// assert!(require("", "OPENROUTER_API_KEY").is_err());
385/// assert!(require("sk-or-x", "OPENROUTER_API_KEY").is_ok());
386/// ```
387pub fn require(value: &str, name: &str) -> anyhow::Result<()> {
388 if value.is_empty() {
389 anyhow::bail!("Missing required env var: {name}");
390 }
391 Ok(())
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::repo_config::RepoConfig;
398
399 #[test]
400 fn overrides_only_set_fields_and_appends_instructions() {
401 let mut base = Config::from_env();
402 base.openrouter_model = "base/model".to_string();
403 base.min_confidence = 10;
404 base.max_findings = 5;
405 base.self_critique = true;
406 base.agentic = false;
407 base.file_bundling = true;
408 base.reanchor_findings = true;
409 base.extra_system_prompt = "BASE CONVENTIONS".to_string();
410
411 let rc = RepoConfig {
412 model: Some("repo/model".to_string()),
413 min_confidence: Some(75),
414 self_critique: Some(false),
415 agentic: Some(true),
416 file_bundling: Some(false),
417 reanchor_findings: Some(false),
418 include_globs: Some(vec!["src/**".to_string()]),
419 instructions: Some("Never nit about formatting.".to_string()),
420 ..Default::default()
421 };
422
423 let eff = base.with_repo_overrides(&rc);
424
425 // Overridden fields take the repo value.
426 assert_eq!(eff.openrouter_model, "repo/model");
427 assert_eq!(eff.min_confidence, 75);
428 assert!(!eff.self_critique);
429 assert!(eff.agentic);
430 assert!(!eff.file_bundling);
431 assert!(!eff.reanchor_findings);
432 assert_eq!(eff.include_globs, vec!["src/**".to_string()]);
433 // Untouched field keeps the base value.
434 assert_eq!(eff.max_findings, 5);
435 // Instructions are appended, not replaced.
436 assert_eq!(
437 eff.extra_system_prompt,
438 "BASE CONVENTIONS\nNever nit about formatting."
439 );
440 }
441
442 #[test]
443 fn empty_repo_config_is_a_noop() {
444 let mut base = Config::from_env();
445 base.openrouter_model = "keep/me".to_string();
446 base.extra_system_prompt = "keep".to_string();
447
448 let eff = base.with_repo_overrides(&RepoConfig::default());
449
450 assert_eq!(eff.openrouter_model, "keep/me");
451 assert_eq!(eff.extra_system_prompt, "keep");
452 }
453
454 #[test]
455 fn instructions_set_when_base_prompt_empty() {
456 let mut base = Config::from_env();
457 base.extra_system_prompt = String::new();
458 let rc = RepoConfig {
459 instructions: Some("Focus on security.".to_string()),
460 ..Default::default()
461 };
462 let eff = base.with_repo_overrides(&rc);
463 assert_eq!(eff.extra_system_prompt, "Focus on security.");
464 }
465}