Skip to content

Commit eda811a

Browse files
haasonsaasclaude
andcommitted
Add GitHub integration, multi-provider settings, wide events, and deep review fixes
- Multi-provider settings with tabbed UI (Providers, Review, Model, Repos, Advanced) - GitHub repo browser with PR discovery and inline review comment posting via Octokit - Wide event system: flat ReviewEvent struct captures full review lifecycle (timing, model, diff stats, results, GitHub posting) emitted as structured tracing log - Collapsible event details panel in review detail page - Connection pooling: shared reqwest::Client across all GitHub API calls - Fix URL encoding for multi-byte UTF-8, diff size limits (50MB), zombie review pruning - Rate limit monitoring via X-RateLimit-Remaining headers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 41717c3 commit eda811a

File tree

16 files changed

+3404
-131
lines changed

16 files changed

+3404
-131
lines changed

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ categories = ["development-tools", "command-line-utilities"]
1111

1212
[dependencies]
1313
clap = { version = "4.4", features = ["derive"] }
14-
tokio = { version = "1.35", features = ["macros", "rt-multi-thread", "fs", "time", "sync"] }
14+
tokio = { version = "1.35", features = ["macros", "rt-multi-thread", "fs", "time", "sync", "process"] }
1515
serde = { version = "1.0", features = ["derive"] }
1616
serde_json = "1.0"
1717
serde_yaml = "0.9"

src/config.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,26 @@ use std::collections::HashMap;
44
use std::path::{Path, PathBuf};
55
use tracing::warn;
66

7+
#[derive(Debug, Clone, Serialize, Deserialize)]
8+
pub struct ProviderConfig {
9+
#[serde(default)]
10+
pub api_key: Option<String>,
11+
#[serde(default)]
12+
pub base_url: Option<String>,
13+
#[serde(default = "default_true")]
14+
pub enabled: bool,
15+
}
16+
17+
impl Default for ProviderConfig {
18+
fn default() -> Self {
19+
Self {
20+
api_key: None,
21+
base_url: None,
22+
enabled: true,
23+
}
24+
}
25+
}
26+
727
#[derive(Debug, Clone, Serialize, Deserialize)]
828
pub struct Config {
929
#[serde(default = "default_model")]
@@ -175,6 +195,12 @@ pub struct Config {
175195

176196
#[serde(default)]
177197
pub rule_priority: Vec<String>,
198+
199+
#[serde(default)]
200+
pub providers: HashMap<String, ProviderConfig>,
201+
202+
#[serde(default)]
203+
pub github_token: Option<String>,
178204
}
179205

180206
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -299,6 +325,8 @@ impl Default for Config {
299325
rules_files: Vec::new(),
300326
max_active_rules: default_max_active_rules(),
301327
rule_priority: Vec::new(),
328+
providers: HashMap::new(),
329+
github_token: None,
302330
}
303331
}
304332
}
@@ -715,6 +743,70 @@ impl Config {
715743
}
716744
}
717745

746+
/// Resolve which provider to use based on configuration.
747+
///
748+
/// Returns `(api_key, base_url, adapter)` by checking:
749+
/// 1. If `adapter` is explicitly set and a matching enabled provider exists, use it.
750+
/// 2. If no adapter is set, infer from the model name.
751+
/// 3. Fall back to top-level `api_key`/`base_url`.
752+
pub fn resolve_provider(&self) -> (Option<String>, Option<String>, Option<String>) {
753+
// If adapter is explicitly set, look for a matching provider
754+
if let Some(ref adapter) = self.adapter {
755+
let key = adapter.to_lowercase();
756+
if let Some(provider) = self.providers.get(&key) {
757+
if provider.enabled {
758+
let api_key = provider.api_key.clone().or_else(|| self.api_key.clone());
759+
let base_url = provider.base_url.clone().or_else(|| self.base_url.clone());
760+
return (api_key, base_url, Some(key));
761+
}
762+
}
763+
// Adapter is set but no matching provider found; fall through to top-level
764+
return (self.api_key.clone(), self.base_url.clone(), Some(key));
765+
}
766+
767+
// No adapter set: try to detect provider from model name
768+
let model_lower = self.model.to_lowercase();
769+
let detected = if model_lower.starts_with("anthropic/")
770+
|| model_lower.starts_with("claude")
771+
{
772+
Some("anthropic")
773+
} else if model_lower.starts_with("openai/")
774+
|| model_lower.starts_with("gpt")
775+
|| model_lower.starts_with("o1")
776+
|| model_lower.starts_with("o3")
777+
|| model_lower.starts_with("o4")
778+
{
779+
Some("openai")
780+
} else if model_lower.starts_with("ollama:") {
781+
Some("ollama")
782+
} else {
783+
// Default: check if openrouter provider is configured
784+
if self.providers.get("openrouter").map_or(false, |p| p.enabled) {
785+
Some("openrouter")
786+
} else {
787+
None
788+
}
789+
};
790+
791+
if let Some(provider_key) = detected {
792+
if let Some(provider) = self.providers.get(provider_key) {
793+
if provider.enabled {
794+
let api_key = provider.api_key.clone().or_else(|| self.api_key.clone());
795+
let base_url = provider.base_url.clone().or_else(|| self.base_url.clone());
796+
// Map openrouter to openai adapter (OpenRouter uses OpenAI-compatible API)
797+
let adapter = match provider_key {
798+
"openrouter" => Some("openai".to_string()),
799+
other => Some(other.to_string()),
800+
};
801+
return (api_key, base_url, adapter);
802+
}
803+
}
804+
}
805+
806+
// Fall back to top-level fields
807+
(self.api_key.clone(), self.base_url.clone(), self.adapter.clone())
808+
}
809+
718810
/// Try to resolve the API key from Vault if Vault is configured and api_key is not set.
719811
pub async fn resolve_vault_api_key(&mut self) -> Result<()> {
720812
if self.api_key.is_some() {

0 commit comments

Comments
 (0)