Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ anyhow = "1.0"
clap = { version = "4.5", features = ["derive", "env"] }
crossterm = "0.29"
dirs = "6.0.0"
indicatif = "0.18.2"
reqwest = { version = "0.12", features = ["json", "blocking"] }
serde = { version = "1", features = ["derive"] }
toml = "0.9.8"
Expand Down
22 changes: 6 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,19 +119,17 @@ Commitbot will:

## Configuration

Commitbot looks for a configuration file at:

```bash
~/.config/commitbot/config.toml
```
Commitbot looks for a configuration file at: [~/.config/commitbot.toml](./commitbot.toml). These settings are available
to CLI flags, environment variables, or even per-project config.

Example:

```toml
model = "gpt-4o-mini"
```

You can override these settings with CLI flags or environment variables.
["mikegarde/commitbot"]
model = "gpt-5-nano"
```

---

Expand All @@ -144,14 +142,6 @@ You can override these settings with CLI flags or environment variables.

---

## ⚠️ Privacy Notice

> At this time, `commitbot` sends staged diffs to OpenAI’s API for analysis.
>
> Future versions will support **self-hosted** and **local** model endpoints (e.g. Ollama, LM Studio, or API-compatible providers) so your code can stay fully private.

---

## License

**GPL-3.0-or-later**
Expand All @@ -160,4 +150,4 @@ See [LICENSE](./LICENSE) for details.

---

_Commitbot is under active development — features and output quality will evolve with each release._
_Commitbot is under active development — features and output quality will evolve with each release._
30 changes: 30 additions & 0 deletions commitbot.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# ~/.config/commitbot.toml

#######################################
# Global defaults (used for all repos)
#######################################
[default]
# Default model if nothing else is specified
model = "gpt-5-nano"

# Optional: OpenAI-style API key (falls back to env OPENAI_API_KEY)
openai_api_key = "your api key here"

# 1 = fully serial, >1 = parallel API calls
max_concurrent_requests = 4

#######################################
# Per-repo overrides
#######################################
["mikegarde/commitbot"]
model = "gpt-4o-mini"
openai_api_key = "alternative for spend identification"
max_concurrent_requests = 8


["company/enterprise"]
# Enterprise / self-hosted style config
base_url = "https://enterprise.api.endpoint"
model = "enterprise-model-v1"
openai_api_key = "enterprise api key here"
max_concurrent_requests = 2
6 changes: 5 additions & 1 deletion src/cli_args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ pub struct Cli {
#[arg(long, global = true)]
pub debug: bool,

/// Max concurrent requests to the LLM API
#[arg(long, global = true)]
pub max: Option<usize>,

/// Model name to use (e.g. gpt-4o-mini). If 'none', acts like --no-model.
#[arg(short, long, global = true)]
pub model: Option<String>,
Expand All @@ -38,7 +42,7 @@ pub struct Cli {
pub no_model: bool,

/// API key (otherwise uses OPENAI_API_KEY env var)
#[arg(short = 'k', long, env = "OPENAI_API_KEY", global = true)]
#[arg(short = 'k', long, global = true)]
pub api_key: Option<String>,

/// Optional: a brief human description of the ticket (for commit/PR summaries)
Expand Down
76 changes: 62 additions & 14 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,51 +1,98 @@
use crate::Cli;
use crate::{git, Cli};
use serde::Deserialize;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::PathBuf;
use git::detect_repo_id;

/// Final resolved configuration for commitbot.
#[derive(Debug, Clone)]
pub struct Config {
pub openai_api_key: String,
pub model: String,
pub max_concurrent_requests: usize,
}

impl Config {
/// Build the final config from CLI flags, environment, TOML file, and defaults.
///
/// Precedence:
/// 1. CLI flags (`--model`)
/// 2. Env var `COMMITBOT_MODEL`
/// 3. TOML `~/.config/commitbot.toml`
/// 4. Hardcoded default ("gpt-5-nano")
/// Precedence (highest to lowest):
/// 1. CLI flags (`--model`, `--api-key`, `--max`)
/// 2. Env vars (`COMMITBOT_MODEL`, `OPENAI_API_KEY`, `COMMITBOT_MAX_CONCURRENT_REQUESTS`)
/// 3. Per-repo table in `~/.config/commitbot.toml` (e.g. ["mikegarde/commitbot"])
/// 4. [default] table in `~/.config/commitbot.toml`
/// 5. Hardcoded defaults (model = "gpt-5-nano", max_concurrent_requests = 4)
pub fn from_sources(cli: &Cli) -> Self {
let file_cfg = load_file_config().unwrap_or_default();
let file_cfg_root = load_file_config().unwrap_or_default();
let repo_id = detect_repo_id();

// Split file config into [default] and repo-specific override (if any).
let default_file_cfg = file_cfg_root.default.unwrap_or_default();
let repo_file_cfg = repo_id
.as_deref()
.and_then(|id| file_cfg_root.repos.get(id))
.cloned()
.unwrap_or_default();

// CLI values
let model_cli = cli.model.clone();
let api_key_cli = cli.api_key.clone();
let max_cli = cli.max;

// Env values
let model_env = env::var("COMMITBOT_MODEL").ok();
let api_key_env = env::var("OPENAI_API_KEY").ok();
let max_env = env::var("COMMITBOT_MAX_CONCURRENT_REQUESTS")
.ok()
.and_then(|s| s.parse::<usize>().ok());

// Resolve model
let model = model_cli
.or(model_env)
.or(file_cfg.model)
.or(repo_file_cfg.model)
.or(default_file_cfg.model)
.unwrap_or_else(|| "gpt-5-nano".to_string());

// Resolve API key (must exist somewhere)
let openai_api_key = api_key_cli
.or(api_key_env)
.or(file_cfg.openai_api_key)
.expect("OPENAI_API_KEY must be set via env var or CLI");
.or(repo_file_cfg.openai_api_key)
.or(default_file_cfg.openai_api_key)
.expect("OPENAI_API_KEY must be set via CLI, env var, or config file");

// Resolve max concurrency; default to 4 if not specified anywhere
let max_concurrent_requests = max_cli
.or(max_env)
.or(repo_file_cfg.max_concurrent_requests)
.or(default_file_cfg.max_concurrent_requests)
.unwrap_or(4);

Config { model, openai_api_key }
Config {
model,
openai_api_key,
max_concurrent_requests,
}
}
}

#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, Clone)]
struct FileConfig {
/// Default model to use when not provided via CLI or env.
pub model: Option<String>,
pub openai_api_key: Option<String>,
pub max_concurrent_requests: Option<usize>,
}

/// Root of the TOML file:
/// - [default]
/// - ["owner/repo"] tables flattened into `repos`
#[derive(Debug, Default, Deserialize)]
struct FileConfigRoot {
pub default: Option<FileConfig>,

#[serde(flatten)]
pub repos: HashMap<String, FileConfig>,
}

/// Return `~/.config/commitbot.toml`
Expand All @@ -54,12 +101,13 @@ fn config_path() -> Option<PathBuf> {
Some(home.join(".config").join("commitbot.toml"))
}

fn load_file_config() -> Option<FileConfig> {
fn load_file_config() -> Option<FileConfigRoot> {
let path = config_path()?;
if !path.exists() {
return None;
}

let data = fs::read_to_string(&path).ok()?;
toml::from_str::<FileConfig>(&data).ok()
toml::from_str::<FileConfigRoot>(&data).ok()
}

42 changes: 42 additions & 0 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,45 @@ pub fn stage_all() -> Result<()> {
git_output(&["add", "-A"])?;
Ok(())
}

/// Try to derive a repo identifier like "owner/repo" from `git remote.origin.url`.
pub fn detect_repo_id() -> Option<String> {
use std::process::Command;

let output = Command::new("git")
.args(["config", "--get", "remote.origin.url"])
.output()
.ok()?;

if !output.status.success() {
return None;
}

let url = String::from_utf8(output.stdout).ok()?;
let trimmed = url.trim().trim_end_matches(".git");

// For SSH: git@github.com:owner/repo
// For HTTPS: https://github.com/owner/repo
let path = if let Some(idx) = trimmed.find("://") {
// Strip scheme and host, keep "owner/repo"
let rest = &trimmed[idx + 3..];
match rest.find('/') {
Some(slash) => &rest[slash + 1..],
None => rest,
}
} else if let Some(idx) = trimmed.find(':') {
// SSH-style: after ':' is "owner/repo"
&trimmed[idx + 1..]
} else {
trimmed
};

let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if segments.len() >= 2 {
let owner = segments[segments.len() - 2];
let repo = segments[segments.len() - 1];
Some(format!("{}/{}", owner, repo))
} else {
None
}
}
2 changes: 1 addition & 1 deletion src/llm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::git::{PrItem, PrSummaryMode};
use anyhow::Result;

/// Trait for talking to an LLM (real or dummy).
pub trait LlmClient {
pub trait LlmClient: Send + Sync {
/// Generate a per-file summary based on diff + metadata.
fn summarize_file(
&self,
Expand Down
Loading