Skip to main content

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}