From c5de09128f4e353df0740805243d2c12ec3ba938 Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sat, 30 May 2026 17:48:58 +0700 Subject: [PATCH 01/23] fix(ci): add --allow-dirty to cargo publish (Cargo.lock mismatch on tag checkout) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99705c0..ec25a07 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,7 +104,7 @@ jobs: domain: "https://infisical.ajianaz.dev" - name: Publish to crates.io - run: cargo publish --token ${{ env.CARGO_REGISTRY_TOKEN }} + run: cargo publish --token ${{ env.CARGO_REGISTRY_TOKEN }} --allow-dirty release: name: Create GitHub Release From fe06cc220cb8f7b702659d1b1883af5af0e432ef Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sat, 30 May 2026 19:06:24 +0700 Subject: [PATCH 02/23] feat: add missing CLI flags + fix all documentation Flags added: - review --commit : review git commit/range - review --diff-file : review from diff file - review --quiet: suppress non-essential output - review --severity : filter by min severity - scan --focus : override focus areas - config show: display resolved config - config set : update config values Documentation fixes: - README: cargo install cora-cli, fix config path, remove non-existent flags (--branch, --full, --commit HEAD), remove Homebrew (tap doesn't exist), fix env vars table - Website docs: fix all 10 inconsistencies across 7 pages - Fix test version assertion (use env var, not hardcoded) Fixes version test to use CARGO_PKG_VERSION env var --- Cargo.lock | 2 +- README.md | 223 ++++++++++-------- src/commands/config_cmd.rs | 187 +++++++++++++++ src/commands/mod.rs | 1 + src/commands/review.rs | 44 +++- src/commands/scan.rs | 11 +- src/git/diff.rs | 13 + src/main.rs | 77 +++++- tests/cli_basic.rs | 2 +- .../routes/docs/cli-reference/+page.svelte | 82 +++++-- .../routes/docs/configuration/+page.svelte | 30 ++- website/src/routes/docs/examples/+page.svelte | 19 +- .../routes/docs/getting-started/+page.svelte | 21 +- .../src/routes/docs/installation/+page.svelte | 6 +- .../src/routes/docs/providers/+page.svelte | 10 +- website/src/routes/docs/usage/+page.svelte | 106 +++++---- 16 files changed, 633 insertions(+), 201 deletions(-) create mode 100644 src/commands/config_cmd.rs diff --git a/Cargo.lock b/Cargo.lock index 74984e2..93be71a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -253,7 +253,7 @@ dependencies = [ [[package]] name = "cora-cli" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "assert_cmd", diff --git a/README.md b/README.md index a24ddcf..6b3ba1f 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,6 @@ cargo install cora-cli ``` -### Homebrew - -```bash -brew tap ajianaz/tap -brew install cora-cli -``` - ### Binary Download Download the latest release from [GitHub Releases](https://github.com/ajianaz/cora-cli/releases): @@ -84,19 +77,13 @@ export ANTHROPIC_API_KEY="sk-ant-..." cora review ``` -### 3. Review a Specific File - -```bash -cora review src/main.rs -``` - -### 4. Review the Last Commit +### 3. Review a Specific Commit ```bash cora review --commit HEAD ``` -### 5. Scan the Entire Project +### 4. Scan the Entire Project ```bash cora scan . @@ -109,32 +96,48 @@ cora scan . Review code changes using an LLM. ```bash -# Review staged files +# Review staged files (default) cora review -# Review specific files -cora review src/main.rs src/lib.rs +# Review staged explicitly +cora review --staged + +# Review unstaged changes +cora review --unstaged + +# Review unpushed commits +cora review --unpushed -# Review a range of commits +# Review against a branch +cora review --base develop + +# Review a commit or range +cora review --commit HEAD cora review --commit HEAD~3..HEAD -# Review a pull request diff +# Review from a diff file cora review --diff-file pr.diff # Use a specific model cora review --model gpt-4o -# Output as SARIF (for CI) -cora review --output sarif --output-file results.sarif +# Filter by severity +cora review --severity major -# Output as JSON -cora review --output json +# Quiet mode (minimal output) +cora review --quiet -# Set severity threshold -cora review --severity warning +# Stream response +cora review --stream -# Quiet mode (machine-readable) -cora review --quiet +# Output as SARIF +cora review --format sarif + +# Review and upload SARIF to GitHub Code Scanning +cora review --base develop --upload + +# Output as JSON +cora review --format json ``` ### `cora scan` @@ -145,14 +148,14 @@ Scan files for code quality issues without requiring git context. # Scan current directory cora scan . -# Scan specific files -cora scan src/**/*.rs - -# Scan with custom rules focus +# Scan with focus areas cora scan . --focus security,performance # Exclude patterns -cora scan . --exclude "tests/**" --exclude "examples/**" +cora scan . --exclude "tests/**" --exclude "vendor/**" + +# Incremental (only changed files) +cora scan . --incremental ``` ### `cora config` @@ -163,12 +166,55 @@ Manage configuration. # Show current configuration cora config show -# Initialize config file -cora config init +# Set model +cora config set model gpt-4o + +# Set provider +cora config set provider anthropic +``` + +### `cora init` + +Create a `.cora.yaml` config file in your project. + +```bash +cora init +cora init --force +``` + +### `cora auth` + +Manage API key authentication. + +```bash +cora auth login +cora auth status +cora auth remove +``` + +### `cora hook` + +Manage git hooks. + +```bash +cora hook install +cora hook uninstall +``` + +### `cora providers` + +List detected AI providers. -# Set a configuration value -cora config set model claude-sonnet-4-20250514 -cora config set severity error +```bash +cora providers +``` + +### `cora upload-sarif` + +Upload a SARIF file to GitHub Code Scanning. + +```bash +cora upload-sarif results.sarif ``` ### `cora completion` @@ -176,65 +222,56 @@ cora config set severity error Generate shell completions. ```bash -cora completion bash > ~/.cora-completion.bash -cora completion zsh > ~/.cora-completion.zsh -cora completion fish > ~/.cora-completion.fish +cora completion bash +cora completion zsh +cora completion fish ``` ## ⚙️ Configuration -Create a `.cora.yaml` in your project root or `~/.config/cora/config.yaml` globally: +Create a `.cora.yaml` in your project root. API keys are stored at `~/.cora/config.toml`. ```yaml # .cora.yaml -model: gpt-4o -temperature: 0.1 - -# Provider configuration -provider: - name: openai # openai | anthropic | google | custom - base_url: null # Override API endpoint (for custom/self-hosted) - api_key_env: OPENAI_API_KEY # Environment variable for API key # Review settings review: - severity: warning # error | warning | info | note - focus: # Focus areas (empty = all) - - security - - performance - - maintainability - ignore: - patterns: - - "tests/**" - - "vendor/**" - - "*.generated.*" - rules: - - "line-too-long" - max_tokens: 4096 - language: en # Response language - -# Output settings -output: - format: colored # colored | plain | json | sarif - file: null # Output to file instead of stdout - -# Git settings -git: - auto_stage: false # Auto-stage files after review - base_branch: main # Base branch for PR reviews + severity: warning # minimum severity level + max_issues: 20 # max issues to report + focus: security,performance # focus areas + +# Patterns to ignore +ignore: + - "vendor/**" + - "*.min.js" + - "migrations/**" + +# Provider-specific model overrides +providers: + openai: + model: gpt-4o + anthropic: + model: claude-sonnet-4-20250514 ``` ### Environment Variables -| Variable | Description | Default | -|----------|-------------|---------| -| `OPENAI_API_KEY` | OpenAI API key | — | -| `ANTHROPIC_API_KEY` | Anthropic API key | — | -| `GOOGLE_API_KEY` | Google AI API key | — | -| `CORa_MODEL` | Override model | — | -| `CORa_PROVIDER` | Override provider | — | -| `CORa_CONFIG` | Path to config file | `.cora.yaml` | -| `CORa_LOG_LEVEL` | Log verbosity | `info` | +| Variable | Description | +|----------|-------------| +| `CORA_API_KEY` | Generic API key | +| `OPENAI_API_KEY` | OpenAI API key | +| `ANTHROPIC_API_KEY` | Anthropic API key | +| `GROQ_API_KEY` | Groq API key | +| `ZAI_API_KEY` | Z.AI API key | +| `CORA_PROVIDER` | Override provider | +| `CORA_MODEL` | Override model | +| `CORA_BASE_URL` | Override API base URL | +| `CORA_CONFIG` | Config file path | +| `CORA_FORMAT` | Output format | +| `CORA_NO_COLOR` | Disable colors | +| `GITHUB_TOKEN` | GitHub token for SARIF upload | +| `GITHUB_REPOSITORY` | GitHub repo for SARIF upload | +| `GITHUB_REF` | GitHub ref for SARIF upload | ## 🔗 CI/CD Integration @@ -263,9 +300,7 @@ jobs: env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} run: | - cora review --commit origin/main..HEAD \ - --output sarif \ - --output-file results.sarif + cora review --base origin/develop --format sarif --output-file results.sarif - name: Upload SARIF to GitHub Code Scanning if: always() @@ -284,7 +319,7 @@ code-review: before_script: - cargo install cora-cli script: - - cora review --commit origin/main..HEAD --severity error + - cora review --base origin/develop --severity critical variables: OPENAI_API_KEY: $CI_OPENAI_API_KEY rules: @@ -311,7 +346,7 @@ Or add it manually to `.git/hooks/pre-commit`: ```bash #!/bin/sh # cora-cli pre-commit hook -cora review --quiet --severity error +cora review --quiet --severity critical if [ $? -ne 0 ]; then echo "❌ Code review found critical issues. Commit blocked." echo " Run 'cora review' to see details, or use 'git commit --no-verify' to skip." @@ -319,18 +354,6 @@ if [ $? -ne 0 ]; then fi ``` -### With [pre-commit](https://pre-commit.com) framework - -```yaml -# .pre-commit-config.yaml -repos: - - repo: https://github.com/ajianaz/cora-cli - rev: v0.1.0 - hooks: - - id: cora-review - args: ['--severity', 'warning'] -``` - ## 🆚 Positioning: How Cora Compares | Feature | **cora-cli** | AI Agent IDE Tools | Standard Linters | diff --git a/src/commands/config_cmd.rs b/src/commands/config_cmd.rs new file mode 100644 index 0000000..0de968b --- /dev/null +++ b/src/commands/config_cmd.rs @@ -0,0 +1,187 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use colored::Colorize; + +use crate::config::loader; + +/// Execute `cora config show` — print the current resolved configuration. +pub fn execute_config_show() -> Result<()> { + let config = loader::load_config(None, None, None, None, None, None, false)?; + + println!("{}", "╔══════════════════════════════════════════╗".cyan().bold()); + println!("{}", "║ Current Configuration ║".cyan().bold()); + println!("{}", "╚══════════════════════════════════════════╝".cyan().bold()); + println!(); + + println!( + "{} {}", + "provider:".bold(), + config.provider.provider.green() + ); + println!( + "{} {}", + " model:".dimmed(), + config.provider.model.green() + ); + println!( + "{} {}", + " base_url:".dimmed(), + config.provider.base_url.green() + ); + + println!( + "{} {}", + "focus:".bold(), + config + .focus + .iter() + .map(|f| format!("{}", f.green())) + .collect::>() + .join(", ") + ); + + if !config.rules.is_empty() { + println!( + "{} {}", + "rules:".bold(), + config + .rules + .iter() + .map(|r| format!("{}", r.green())) + .collect::>() + .join(", ") + ); + } + + println!( + "{} {}", + "hook:".bold(), + format!( + "mode={} min_severity={} max_diff_size={}", + config.hook.mode.yellow(), + config.hook.min_severity.yellow(), + config.hook.max_diff_size + ) + ); + + println!( + "{} {}", + "output:".bold(), + format!( + "format={} color={}", + config.output.format.green(), + config.output.color + ) + ); + + println!( + "{} {}", + "ignore files:".bold(), + if config.ignore.files.is_empty() { + "(none)".to_string().dimmed().to_string() + } else { + config + .ignore + .files + .iter() + .map(|f| format!("{}", f.dimmed())) + .collect::>() + .join(", ") + } + ); + + Ok(()) +} + +/// Execute `cora config set ` — write a key-value pair to ~/.cora/config.toml. +/// +/// Supported keys: model, provider, format, severity. +pub fn execute_config_set(key: &str, value: &str) -> Result<()> { + // Validate the key + match key { + "model" | "provider" | "format" | "severity" => {} + _ => { + anyhow::bail!( + "unsupported key: {key}\nSupported keys: model, provider, format, severity" + ); + } + } + + let dir = cora_config_dir()?; + std::fs::create_dir_all(&dir) + .with_context(|| format!("failed to create {}", dir.display()))?; + + let path = dir.join("config.toml"); + + // Load existing config.toml content if it exists + let mut table = if path.is_file() { + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + content + .parse::() + .unwrap_or_default() + } else { + toml::Table::new() + }; + + // Map the key to the appropriate TOML structure + match key { + "model" => { + let provider = table + .entry("provider") + .or_insert_with(|| toml::Value::Table(toml::Table::new())); + if let toml::Value::Table(p) = provider { + p.insert("model".to_string(), toml::Value::String(value.to_string())); + } + } + "provider" => { + let provider = table + .entry("provider") + .or_insert_with(|| toml::Value::Table(toml::Table::new())); + if let toml::Value::Table(p) = provider { + p.insert("provider".to_string(), toml::Value::String(value.to_string())); + } + } + "format" => { + let output = table + .entry("output") + .or_insert_with(|| toml::Value::Table(toml::Table::new())); + if let toml::Value::Table(o) = output { + o.insert("format".to_string(), toml::Value::String(value.to_string())); + } + } + "severity" => { + let hook = table + .entry("hook") + .or_insert_with(|| toml::Value::Table(toml::Table::new())); + if let toml::Value::Table(h) = hook { + h.insert( + "min_severity".to_string(), + toml::Value::String(value.to_string()), + ); + } + } + _ => unreachable!(), + } + + let content = table.to_string(); + std::fs::write(&path, content) + .with_context(|| format!("failed to write {}", path.display()))?; + + println!( + "{} Set {} = {} in {}", + "✓".green().bold(), + key.bold(), + value.green(), + path.display() + ); + + Ok(()) +} + +/// Get the cora config directory: ~/.cora/ +fn cora_config_dir() -> Result { + let home = dirs::home_dir().context("cannot determine home directory")?; + Ok(home.join(".cora")) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a76dd9e..7b0c797 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod completion; +pub mod config_cmd; pub mod hook_cmd; pub mod init; pub mod providers; diff --git a/src/commands/review.rs b/src/commands/review.rs index f5b28c8..4fdeca2 100644 --- a/src/commands/review.rs +++ b/src/commands/review.rs @@ -1,8 +1,9 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use colored::Colorize; use tracing::debug; use crate::config::schema::Config; +use crate::engine::Severity; use crate::formatters::{OutputFormat, formatter_for}; use crate::git; @@ -19,12 +20,20 @@ pub struct ReviewOptions { pub unpushed: bool, /// Review diff against a base branch. pub base: Option, + /// Review diff from a git commit reference (e.g. HEAD, HEAD~3..HEAD, abc123). + pub commit: Option, + /// Read diff from a file instead of git. + pub diff_file: Option, /// Review unstaged changes. pub unstaged: bool, /// Maximum diff size before refusing (0 = use config default). pub max_diff_size: Option, /// Stream LLM response tokens to stdout in real-time. pub stream: bool, + /// Suppress all output except the formatted review result. + pub quiet: bool, + /// Minimum severity level for filtering issues. + pub severity: Option, } /// Result of a review command execution. @@ -49,6 +58,12 @@ pub async fn execute_review( let diff = get_diff(opts, config)?; if diff.trim().is_empty() { + if opts.quiet { + return Ok(ReviewResult { + exit_code: EXIT_OK, + output: String::new(), + }); + } let output = format!("{}\n", "No changes to review.".yellow()); return Ok(ReviewResult { exit_code: EXIT_OK, @@ -77,11 +92,21 @@ pub async fn execute_review( crate::engine::review::review_diff_with_stream(config, llm_config, &diff, opts.stream) .await?; - // 4. Format output + // 4. Filter by severity if specified + let min_severity = if let Some(ref sev) = opts.severity { + Severity::from_str_lossy(sev) + } else { + config.hook.min_severity_level() + }; + + let mut filtered_response = response.clone(); + filtered_response.issues.retain(|i| i.severity <= min_severity); + + // 5. Format output let formatter = formatter_for(format); - let output = formatter.format_review(&response)?; + let output = formatter.format_review(&filtered_response)?; - // 5. Return exit code + // 6. Return exit code let exit_code = if response.should_block && config.hook.mode == "block" { EXIT_BLOCKED } else { @@ -93,6 +118,17 @@ pub async fn execute_review( /// Get the diff based on the provided options. fn get_diff(opts: &ReviewOptions, _config: &Config) -> Result { + if let Some(ref diff_file) = opts.diff_file { + let path = std::path::Path::new(diff_file); + if !path.exists() { + anyhow::bail!("diff file not found: {}", diff_file); + } + return std::fs::read_to_string(path) + .with_context(|| format!("failed to read diff file: {}", diff_file)); + } + if let Some(ref commit_ref) = opts.commit { + return git::get_commit_diff(commit_ref); + } if opts.staged { git::get_staged_diff() } else if opts.unpushed { diff --git a/src/commands/scan.rs b/src/commands/scan.rs index 398f1a4..1df67d9 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -20,6 +20,8 @@ pub struct ScanOptions { pub extensions: Vec, /// Only scan files changed since last scan. pub incremental: bool, + /// Focus areas for review (overrides config). + pub focus: Vec, } /// Execute the scan command. @@ -46,6 +48,13 @@ pub async fn execute_scan( let mut exclude = config.ignore.files.clone(); exclude.extend(opts.exclude.clone()); + // Merge focus areas: CLI --focus overrides config + let effective_focus = if opts.focus.is_empty() { + config.focus.clone() + } else { + opts.focus.clone() + }; + debug!(root = %root.display(), "starting scan"); // 1. Walk and collect files @@ -107,7 +116,7 @@ pub async fn execute_scan( let (issues, _summary, tokens) = crate::engine::llm::scan_files( llm_config, &files_content, - &config.focus, + &effective_focus, &config.rules, ) .await?; diff --git a/src/git/diff.rs b/src/git/diff.rs index 3178e83..00eb8c2 100644 --- a/src/git/diff.rs +++ b/src/git/diff.rs @@ -45,6 +45,19 @@ pub fn get_unpushed_diff() -> Result { git_cmd(&["log", "-p", "@{u}..HEAD"]) } +/// Get the diff for a commit reference. +/// +/// - If the ref contains `..` (e.g. `HEAD~3..HEAD`), uses `git diff `. +/// - Otherwise (e.g. `HEAD`, `abc123`), uses `git show --format=""` to +/// get just the diff without commit metadata. +pub fn get_commit_diff(ref_str: &str) -> Result { + if ref_str.contains("..") { + git_cmd(&["diff", ref_str]) + } else { + git_cmd(&["show", ref_str, "--format="]) + } +} + /// Get the current branch name. pub fn get_current_branch() -> Result { let output = Command::new("git") diff --git a/src/main.rs b/src/main.rs index d6bf9f5..37aab73 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ mod formatters; mod git; mod hook; -use commands::{auth, completion, hook_cmd, init, providers, review, scan, upload}; +use commands::{auth, completion, config_cmd, hook_cmd, init, providers, review, scan, upload}; use config::loader; use formatters::OutputFormat; @@ -91,6 +91,14 @@ enum Command { #[clap(long)] base: Option, + /// Review changes from a git commit ref (e.g. HEAD, HEAD~3..HEAD, abc123) + #[clap(long)] + commit: Option, + + /// Read diff from a file instead of git + #[clap(long)] + diff_file: Option, + /// Review unstaged changes (working tree) #[clap(long)] unstaged: bool, @@ -99,6 +107,14 @@ enum Command { #[clap(long)] stream: bool, + /// Suppress all output except the formatted review result + #[clap(long, short)] + quiet: bool, + + /// Filter results by minimum severity (info, minor, major, critical) + #[clap(long, value_parser = ["info", "minor", "major", "critical"])] + severity: Option, + /// Upload SARIF output to GitHub Code Scanning after review /// (implies --format sarif) #[clap(long)] @@ -138,6 +154,10 @@ enum Command { /// Only scan files changed since last scan (uses ~/.cora/scan-cache.json) #[clap(long)] incremental: bool, + + /// Focus areas for review (overrides config) + #[clap(long)] + focus: Vec, }, /// Upload a SARIF file to GitHub Code Scanning @@ -177,6 +197,12 @@ enum Command { action: AuthAction, }, + /// View or set configuration + Config { + #[command(subcommand)] + action: ConfigAction, + }, + /// List detected/available LLM providers Providers, @@ -205,6 +231,19 @@ enum AuthAction { Remove, } +#[derive(Subcommand, Debug)] +enum ConfigAction { + /// Show the current resolved configuration + Show, + /// Set a configuration value (keys: model, provider, format, severity) + Set { + /// Configuration key to set + key: String, + /// Value to assign + value: String, + }, +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -238,8 +277,12 @@ async fn main() -> Result<()> { staged, unpushed, base, + commit, + diff_file, unstaged, stream, + quiet, + severity, upload, repo, ref_name, @@ -251,8 +294,12 @@ async fn main() -> Result<()> { staged, unpushed, base, + commit, + diff_file, unstaged, stream, + quiet, + severity, upload, repo, ref_name, @@ -267,6 +314,7 @@ async fn main() -> Result<()> { exclude, extensions, incremental, + focus, } => { cmd_scan( &cli.global, @@ -276,6 +324,7 @@ async fn main() -> Result<()> { exclude, extensions, incremental, + focus, }, ) .await? @@ -320,6 +369,18 @@ async fn main() -> Result<()> { } 0 } + Command::Config { action } => { + match action { + ConfigAction::Show => { + config_cmd::execute_config_show()?; + 0 + } + ConfigAction::Set { key, value } => { + config_cmd::execute_config_set(&key, &value)?; + 0 + } + } + } Command::Providers => { providers::execute_providers()?; 0 @@ -338,8 +399,12 @@ struct ReviewOpts { staged: bool, unpushed: bool, base: Option, + commit: Option, + diff_file: Option, unstaged: bool, stream: bool, + quiet: bool, + severity: Option, upload: bool, repo: Option, ref_name: Option, @@ -353,6 +418,7 @@ struct ScanOpts { exclude: Vec, extensions: Vec, incremental: bool, + focus: Vec, } /// Handle the `review` subcommand. @@ -380,13 +446,17 @@ async fn cmd_review(globals: &GlobalOptions, opts: ReviewOpts) -> Result { staged: opts.staged, unpushed: opts.unpushed, base: opts.base.clone(), + commit: opts.commit.clone(), + diff_file: opts.diff_file.clone(), unstaged: opts.unstaged, max_diff_size: None, stream: opts.stream, + quiet: opts.quiet, + severity: opts.severity.clone(), }; - // When streaming, show a simpler message (no spinner, since we print tokens live) - if opts.stream { + // When streaming and not quiet, show a simpler message + if opts.stream && !opts.quiet { eprintln!( "{}", format!( @@ -473,6 +543,7 @@ async fn cmd_scan(globals: &GlobalOptions, opts: ScanOpts) -> Result { exclude: opts.exclude, extensions: opts.extensions, incremental: opts.incremental, + focus: opts.focus, }; scan::execute_scan(&config, &llm_config, &scan_opts, format).await diff --git a/tests/cli_basic.rs b/tests/cli_basic.rs index 0519664..2717669 100644 --- a/tests/cli_basic.rs +++ b/tests/cli_basic.rs @@ -18,7 +18,7 @@ fn cli_version_returns_version_string() { .arg("--version") .assert() .success() - .stdout(predicate::str::contains("0.1.0")); + .stdout(predicate::str::contains(env!("CARGO_PKG_VERSION"))); } #[test] diff --git a/website/src/routes/docs/cli-reference/+page.svelte b/website/src/routes/docs/cli-reference/+page.svelte index 77ef7e8..d5d4814 100644 --- a/website/src/routes/docs/cli-reference/+page.svelte +++ b/website/src/routes/docs/cli-reference/+page.svelte @@ -47,8 +47,28 @@ Override config file path (default: .cora.yaml) - --json - Output results as JSON + --format <fmt> + Output format: pretty, json, compact, sarif + + + --no-color + Disable colored output + + + --provider <name> + Override provider + + + --model <name> + Override model + + + --base-url <url> + Override API base URL + + + --api-key <key> + Override API key --verbose @@ -78,25 +98,53 @@ cora init Create .cora.yaml config file + + cora review + Review code changes (default: staged files) + cora review --staged - Review staged git changes (default) + Review staged git changes explicitly + + + cora review --unstaged + Review unstaged working changes + + + cora review --unpushed + Review unpushed commits - cora review --branch <name> + cora review --base <branch> Compare current branch against target - cora review --diff <base>..<head> - Review specific diff range + cora review --commit <ref> + Review specific commit or range + + + cora review --diff-file <path> + Review from a diff file + + + cora review --upload + Review and upload SARIF to GitHub Code Scanning + + + cora scan <path> + Scan files for issues - cora review --file <path> - Review single file + cora scan . [--incremental] + Scan only changed files - cora scan [--incremental] - Full project scan + cora config show + Show resolved configuration + + + cora config set <key> <value> + Set a config value cora hook install @@ -115,17 +163,21 @@ Check current auth status - cora providers - List supported AI providers + cora auth remove + Remove stored API key - cora completion <shell> - Generate shell completions (bash/zsh/fish/powershell) + cora providers + List detected AI providers cora upload-sarif <file> Upload SARIF to GitHub Code Scanning + + cora completion <shell> + Generate shell completions (bash/zsh/fish) + @@ -158,7 +210,7 @@
# Compare your feature branch against main
- $ cora review --branch main + $ cora review --base main
diff --git a/website/src/routes/docs/configuration/+page.svelte b/website/src/routes/docs/configuration/+page.svelte index 5e9d3e4..981b53e 100644 --- a/website/src/routes/docs/configuration/+page.svelte +++ b/website/src/routes/docs/configuration/+page.svelte @@ -70,7 +70,7 @@
# cora project config
 review:
-  severity: warning          # minimum severity: info, warning, error
+  severity: warning          # minimum severity: info, minor, major, critical
   max_issues: 20             # max issues to report
   focus: security,performance  # focus areas
 
@@ -119,6 +119,30 @@
 					CORA_BASE_URL
 					Custom API base URL
 				
+				
+					CORA_CONFIG
+					Path to config file
+				
+				
+					CORA_FORMAT
+					Output format (pretty, json, compact, sarif)
+				
+				
+					CORA_NO_COLOR
+					Disable colored output
+				
+				
+					GITHUB_TOKEN
+					GitHub token for SARIF upload
+				
+				
+					GITHUB_REPOSITORY
+					GitHub repo for SARIF upload
+				
+				
+					GITHUB_REF
+					GitHub ref for SARIF upload
+				
 			
 		
 	
@@ -150,7 +174,9 @@ GROQ_API_KEY=gsk_... # Ollama (local, no key needed) -OLLAMA_BASE_URL=http://localhost:11434 +OLLAMA_HOST=http://localhost:11434 +# Optional: OLLAMA_API_KEY if your Ollama instance requires auth +OLLAMA_API_KEY=... # Z.AI ZAI_API_KEY=... diff --git a/website/src/routes/docs/examples/+page.svelte b/website/src/routes/docs/examples/+page.svelte index 5e966eb..bc550dd 100644 --- a/website/src/routes/docs/examples/+page.svelte +++ b/website/src/routes/docs/examples/+page.svelte @@ -41,7 +41,10 @@
- $ CORA_API_KEY=sk-xxx cora review --staged +# Review staged changes (default)
+ $ cora review

+ # Or review with explicit flags
+ $ cora review --staged
@@ -60,7 +63,7 @@
- $ cora review --branch main + $ cora review --base main
@@ -149,12 +152,12 @@ steps: - uses: actions/checkout@v4 - name: Install cora - run: cargo install cora + run: cargo install cora-cli - name: Run AI code review env: {@html 'CORA_API_KEY: ${{ secrets.CORA_API_KEY }}'} CORA_PROVIDER: openai - run: cora review --branch main --fail-on error + run: cora review --base main --format sarif @@ -197,10 +200,10 @@
- # Generate SARIF report and upload
- $ cora review --staged --output sarif > results.sarif && \
-   cora upload-sarif results.sarif

- Uploaded 3 findings to GitHub Code Scanning + # Generate SARIF report and upload
+ $ cora review --base main --format sarif > results.sarif && \\
+   cora upload-sarif results.sarif

+ Uploaded 3 findings to GitHub Code Scanning
diff --git a/website/src/routes/docs/getting-started/+page.svelte b/website/src/routes/docs/getting-started/+page.svelte index a152047..775aaf7 100644 --- a/website/src/routes/docs/getting-started/+page.svelte +++ b/website/src/routes/docs/getting-started/+page.svelte @@ -34,7 +34,7 @@
1
Install cora — Single binary via Cargo: - cargo install cora + cargo install cora-cli
@@ -57,7 +57,7 @@
4
Review — Analyze your staged changes: - cora review --staged + cora review
@@ -120,24 +120,25 @@
# .cora.yaml
-
provider: openai
-
model: gpt-4o
+
review:
+
severity: warning
+
focus: security,performance
-
custom_prompt: |
-
Focus on security vulnerabilities and
-
performance issues. Ignore style linting.
+
providers:
+
openai:
+
model: gpt-4o
- provider — Which LLM provider to use (openai, anthropic, groq, ollama, zai) + review.severity — Minimum severity level (info, minor, major, critical)
- model — Specific model name (e.g., gpt-4o, claude-sonnet-4-20250514) + review.focus — Focus areas for review (e.g., security, performance)
- custom_prompt — Override the default review prompt to focus on specific concerns + providers — Provider-specific model overrides (openai, anthropic, groq, ollama, zai)
diff --git a/website/src/routes/docs/installation/+page.svelte b/website/src/routes/docs/installation/+page.svelte index 2c62fbe..d57f4a5 100644 --- a/website/src/routes/docs/installation/+page.svelte +++ b/website/src/routes/docs/installation/+page.svelte @@ -31,7 +31,7 @@

cora is a Rust binary. You have two installation paths:

- Via Cargo (recommended) — Requires Rust 1.70 or later with Cargo installed + Via Cargo (recommended) — Requires Rust 1.85 or later with Cargo installed
Binary download — No Rust required; just download and place in your PATH @@ -51,7 +51,7 @@
-
$ cargo install cora
+
$ cargo install cora-cli
@@ -157,7 +157,7 @@
- Via Cargo: cargo install cora --force + Via Cargo: cargo install cora-cli --force
Via Binary: Download the latest release from GitHub and replace the existing binary diff --git a/website/src/routes/docs/providers/+page.svelte b/website/src/routes/docs/providers/+page.svelte index 058f734..7ae143b 100644 --- a/website/src/routes/docs/providers/+page.svelte +++ b/website/src/routes/docs/providers/+page.svelte @@ -46,8 +46,8 @@ OpenAI - gpt-4o - OPENAI_API_KEY + gpt-4o-mini + OPENAI_API_KEY OPENAI_BASE_URL @@ -58,7 +58,7 @@ Groq - llama-3.3-70b-versatile + llama-3.1-8b-instant GROQ_API_KEY GROQ_BASE_URL @@ -66,7 +66,7 @@ Ollama llama3.1 — (local) - OLLAMA_BASE_URL (default: http://localhost:11434) + OLLAMA_HOST (default: http://localhost:11434) Z.AI @@ -94,7 +94,7 @@ 'ANTHROPIC_API_KEY \u2192 uses Anthropic', 'GROQ_API_KEY \u2192 uses Groq', 'ZAI_API_KEY \u2192 uses Z.AI', - 'OLLAMA_BASE_URL \u2192 uses Ollama (localhost)' + 'OLLAMA_HOST \u2192 uses Ollama (localhost)' ] as item, i}
{i + 1}
diff --git a/website/src/routes/docs/usage/+page.svelte b/website/src/routes/docs/usage/+page.svelte index e43cccb..4f62231 100644 --- a/website/src/routes/docs/usage/+page.svelte +++ b/website/src/routes/docs/usage/+page.svelte @@ -28,7 +28,7 @@

Review Modes

-

cora supports four review modes, each suited to a different workflow:

+

cora supports multiple review modes, each suited to a different workflow:

@@ -41,6 +41,12 @@ + + + + + + @@ -48,22 +54,34 @@ - - + + + + + + + + + + + + + + - - - - + + + + - - - - + + + +
Default(no flag)Tries staged first, then unpushedQuick feedback
Staged --stagedPre-commit review
Branch--branchUnstaged--unstagedUnstaged working changesReview work in progress
Unpushed--unpushedCommits not yet pushedReview before push
Base Branch--base <branch> Diff against base branch PR review workflow
Full--fullEntire repositoryComprehensive auditCommit--commit <ref>Specific commit or rangeReview specific changes
Incremental--incrementalOnly new/changed filesLarge codebasesDiff File--diff-file <path>External diff fileReview patch files
@@ -76,17 +94,20 @@
-
# Review staged changes
-
$ cora review --staged
+
# Review staged changes (default)
+
$ cora review
+
+
# Review against a branch
+
$ cora review --base main
-
# Review against main branch
-
$ cora review --branch main
+
# Review a specific commit
+
$ cora review --commit HEAD
-
# Full project scan
-
$ cora review --full
+
# Review from a diff file
+
$ cora review --diff-file pr.diff
-
# Incremental (only changed files)
-
$ cora review --incremental
+
# Full project scan (use cora scan)
+
$ cora scan .
@@ -141,7 +162,7 @@

Configuration File

-

The .cora.yaml file provides persistent configuration. Place it in your project root or use ~/.cora/config.yaml for global settings.

+

The .cora.yaml file provides persistent configuration. Place it in your project root. API keys are stored at ~/.cora/config.toml.

@@ -150,30 +171,19 @@
-
# .cora.yaml — full example
-
-
# LLM provider: openai, anthropic, groq, ollama, zai
-
provider: openai
-
-
# Model name
-
model: gpt-4o
-
-
# Custom base URL (for proxies or self-hosted endpoints)
-
# base_url: https://api.example.com/v1
-
-
# Custom review prompt
-
# custom_prompt: |
-
# Focus on security and performance only.
-
-
# File patterns to include
-
# include:
-
# - "src/**"
-
# - "lib/**"
-
-
# File patterns to exclude
-
# exclude:
-
# - "**/*.test.ts"
-
# - "**/node_modules/**"
+
# .cora.yaml — example
+
+
review:
+
severity: warning
+
focus: security,performance
+
+
ignore:
+
- "vendor/**"
+
- "*.min.js"
+
+
providers:
+
openai:
+
model: gpt-4o
@@ -288,19 +298,19 @@
- Use --staged as a pre-commit hook for the fastest feedback loop + Use cora review with no flags for the fastest pre-commit feedback
- Combine --format json with --branch main in CI pipelines + Combine --format json with --base main in CI pipelines
- Use --incremental for large codebases — only changed files are analyzed + Use cora scan . --incremental for large codebases — only changed files are analyzed
- Set custom_prompt to match your team's coding standards + Use --quiet for minimal output and --severity to filter by severity level
From 38522e96ac2c3cbdebb99c99dd000566abf2b8f2 Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sat, 30 May 2026 19:09:51 +0700 Subject: [PATCH 03/23] fix: resolve clippy format_in_format_args + cargo fmt --- src/commands/config_cmd.rs | 55 +++++++++++++++++++------------------- src/commands/review.rs | 4 ++- src/main.rs | 20 +++++++------- 3 files changed, 40 insertions(+), 39 deletions(-) diff --git a/src/commands/config_cmd.rs b/src/commands/config_cmd.rs index 0de968b..e02c116 100644 --- a/src/commands/config_cmd.rs +++ b/src/commands/config_cmd.rs @@ -9,9 +9,20 @@ use crate::config::loader; pub fn execute_config_show() -> Result<()> { let config = loader::load_config(None, None, None, None, None, None, false)?; - println!("{}", "╔══════════════════════════════════════════╗".cyan().bold()); - println!("{}", "║ Current Configuration ║".cyan().bold()); - println!("{}", "╚══════════════════════════════════════════╝".cyan().bold()); + println!( + "{}", + "╔══════════════════════════════════════════╗".cyan().bold() + ); + println!( + "{}", + "║ Current Configuration ║" + .cyan() + .bold() + ); + println!( + "{}", + "╚══════════════════════════════════════════╝".cyan().bold() + ); println!(); println!( @@ -19,11 +30,7 @@ pub fn execute_config_show() -> Result<()> { "provider:".bold(), config.provider.provider.green() ); - println!( - "{} {}", - " model:".dimmed(), - config.provider.model.green() - ); + println!("{} {}", " model:".dimmed(), config.provider.model.green()); println!( "{} {}", " base_url:".dimmed(), @@ -55,24 +62,18 @@ pub fn execute_config_show() -> Result<()> { } println!( - "{} {}", + "{} mode={} min_severity={} max_diff_size={}", "hook:".bold(), - format!( - "mode={} min_severity={} max_diff_size={}", - config.hook.mode.yellow(), - config.hook.min_severity.yellow(), - config.hook.max_diff_size - ) + config.hook.mode.yellow(), + config.hook.min_severity.yellow(), + config.hook.max_diff_size ); println!( - "{} {}", + "{} format={} color={}", "output:".bold(), - format!( - "format={} color={}", - config.output.format.green(), - config.output.color - ) + config.output.format.green(), + config.output.color ); println!( @@ -109,8 +110,7 @@ pub fn execute_config_set(key: &str, value: &str) -> Result<()> { } let dir = cora_config_dir()?; - std::fs::create_dir_all(&dir) - .with_context(|| format!("failed to create {}", dir.display()))?; + std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?; let path = dir.join("config.toml"); @@ -118,9 +118,7 @@ pub fn execute_config_set(key: &str, value: &str) -> Result<()> { let mut table = if path.is_file() { let content = std::fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; - content - .parse::() - .unwrap_or_default() + content.parse::().unwrap_or_default() } else { toml::Table::new() }; @@ -140,7 +138,10 @@ pub fn execute_config_set(key: &str, value: &str) -> Result<()> { .entry("provider") .or_insert_with(|| toml::Value::Table(toml::Table::new())); if let toml::Value::Table(p) = provider { - p.insert("provider".to_string(), toml::Value::String(value.to_string())); + p.insert( + "provider".to_string(), + toml::Value::String(value.to_string()), + ); } } "format" => { diff --git a/src/commands/review.rs b/src/commands/review.rs index 4fdeca2..5de7b33 100644 --- a/src/commands/review.rs +++ b/src/commands/review.rs @@ -100,7 +100,9 @@ pub async fn execute_review( }; let mut filtered_response = response.clone(); - filtered_response.issues.retain(|i| i.severity <= min_severity); + filtered_response + .issues + .retain(|i| i.severity <= min_severity); // 5. Format output let formatter = formatter_for(format); diff --git a/src/main.rs b/src/main.rs index 37aab73..6001370 100644 --- a/src/main.rs +++ b/src/main.rs @@ -369,18 +369,16 @@ async fn main() -> Result<()> { } 0 } - Command::Config { action } => { - match action { - ConfigAction::Show => { - config_cmd::execute_config_show()?; - 0 - } - ConfigAction::Set { key, value } => { - config_cmd::execute_config_set(&key, &value)?; - 0 - } + Command::Config { action } => match action { + ConfigAction::Show => { + config_cmd::execute_config_show()?; + 0 } - } + ConfigAction::Set { key, value } => { + config_cmd::execute_config_set(&key, &value)?; + 0 + } + }, Command::Providers => { providers::execute_providers()?; 0 From 99b64472fca3398678e2fa65a11a09acee15124e Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sat, 30 May 2026 19:40:48 +0700 Subject: [PATCH 04/23] feat(ci): add cora AI review job to CI pipeline - Runs after check/fmt/clippy/test pass - Uses Infisical OIDC for LLM API key (CORA_API_KEY, CORA_PROVIDER, CORA_BASE_URL, CORA_MODEL) - Reviews diff against origin/develop with --severity major --quiet - Uploads SARIF to GitHub Code Scanning (block-capable) - Posts PR comment with grouped issues (update existing comment on re-run) - Checks for blocking issues (error-level) to fail the check - Only runs on pull_request events (not push to main) - Added permissions: security-events, pull-requests, id-token --- .github/workflows/ci.yml | 163 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dad790f..fab64c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,12 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 +permissions: + contents: read + security-events: write + pull-requests: write + id-token: write + jobs: check: name: Check @@ -58,3 +64,160 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo build --release + + cora-review: + name: Cora Review + runs-on: ubuntu-latest + needs: [check, fmt, clippy, test] + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Fetch LLM secrets from Infisical + uses: Infisical/secrets-action@v1.0.9 + with: + method: "oidc" + identity-id: "6bd2b8d8-a9a3-4331-8b37-bf2764fc320b" + project-slug: "github-actions" + env-slug: "prod" + domain: "https://infisical.ajianaz.dev" + + - name: Run cora review + run: | + cargo build --release + echo "=== CORA REVIEW (SARIF) ===" >> $GITHUB_STEP_SUMMARY + ./target/release/cora review \ + --base origin/develop \ + --format sarif \ + --severity major \ + --quiet \ + > cora-results.sarif 2>&1 || true + + echo "### 🔍 Cora AI Code Review" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ -s cora-results.sarif ]; then + echo '```json' >> $GITHUB_STEP_SUMMARY + cat cora-results.sarif >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + else + echo "No issues found." >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload SARIF to GitHub Code Scanning + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: cora-results.sarif + category: cora-review + + - name: Post PR comment + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + let sarifContent; + try { + sarifContent = JSON.parse(fs.readFileSync('cora-results.sarif', 'utf8')); + } catch (e) { + sarifContent = null; + } + + let body; + if (!sarifContent || !sarifContent.runs || sarifContent.runs.length === 0) { + body = `## 🔍 Cora AI Code Review\n\n✅ **No issues found.** Code looks good!`; + } else { + const results = sarifContent.runs[0].results || []; + if (results.length === 0) { + body = `## 🔍 Cora AI Code Review\n\n✅ **No issues found.** Code looks good!`; + } else { + // Group by severity + const grouped = {}; + for (const r of results) { + const sev = (r.level || 'note').charAt(0).toUpperCase() + (r.level || 'note').slice(1); + if (!grouped[sev]) grouped[sev] = []; + grouped[sev].push(r); + } + + const severityOrder = ['Error', 'Warning', 'Note']; + let table = ''; + + for (const sev of severityOrder) { + if (!grouped[sev]) continue; + const icon = sev === 'Error' ? '🔴' : sev === 'Warning' ? '🟡' : '🔵'; + table += `\n### ${icon} ${sev} (${grouped[sev].length})\n\n`; + for (const r of grouped[sev]) { + const loc = r.locations?.[0]?.physicalLocation; + const file = loc?.artifactLocation?.uri || 'unknown'; + const line = loc?.region?.startLine || '?'; + const msg = r.message?.text || r.message?.markdown || 'No message'; + table += `- \`${file}:${line}\` — ${msg}\n`; + } + } + + const hasErrors = (grouped['Error'] || []).length > 0; + const hasWarnings = (grouped['Warning'] || []).length > 0; + const status = hasErrors ? '❌ **Blocked** — critical issues found.' : + hasWarnings ? '⚠️ **Review recommended** — warnings found.' : + '✅ **Passed** — only informational notes.'; + + body = `## 🔍 Cora AI Code Review\n\n${status}\n${table}\n---\n_Review powered by [cora-cli](https://github.com/ajianaz/cora-cli) · BYOK · MIT_`; + } + } + + // Find existing cora comment and update it, or create new + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => + c.user.login === 'github-actions[bot]' && + c.body.startsWith('## 🔍 Cora AI Code Review') + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Check for blocking issues + if: always() + run: | + if [ -f cora-results.sarif ]; then + # Check for error-level results in SARIF + ERRORS=$(cat cora-results.sarif | python3 -c " + import json, sys + try: + data = json.load(sys.stdin) + results = data.get('runs', [{}])[0].get('results', []) + errors = [r for r in results if r.get('level') in ('error', 'error')] + print(len(errors)) + except: + print(0) + " 2>/dev/null || echo "0") + + if [ "$ERRORS" -gt 0 ]; then + echo "::error::Cora found $ERRORS blocking issue(s). Review the Code Scanning results." + exit 1 + fi + fi + echo "No blocking issues." From dc488567d9148cc0256836833f02de00c2949d96 Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sat, 30 May 2026 19:46:09 +0700 Subject: [PATCH 05/23] fix: SARIF schema compliance for GitHub Code Scanning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove 'invocation' from runs root (not allowed by GitHub validator) - Remove 'helpUri: null' from rules (must be string or omitted) - Add 'artifactChanges' to fixes (SARIF 2.1.0 required property) - Deduplicate rules by id - Remove unused chrono::Utc import - Upgrade CodeQL upload action v3 → v4 --- .github/workflows/ci.yml | 2 +- src/formatters/sarif.rs | 75 ++++++++++++++++++++++------------------ 2 files changed, 43 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fab64c6..9dbd564 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,7 +110,7 @@ jobs: - name: Upload SARIF to GitHub Code Scanning if: always() - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: cora-results.sarif category: cora-review diff --git a/src/formatters/sarif.rs b/src/formatters/sarif.rs index de5a7e6..5e08451 100644 --- a/src/formatters/sarif.rs +++ b/src/formatters/sarif.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use chrono::Utc; use serde_json::{Value, json}; use crate::engine::{ReviewIssue, ReviewResponse, ScanResponse, Severity}; @@ -27,24 +26,33 @@ impl Formatter for SarifFormatter { /// Build a SARIF v2.1.0 document from a list of issues. fn build_sarif(issues: &[ReviewIssue]) -> Value { - let rules: Vec = issues - .iter() - .map(|issue| { - json!({ - "id": issue.issue_type.as_ref().map(|t| t.to_string()).unwrap_or_else(|| "unknown".to_string()), - "shortDescription": { - "text": issue.title.clone() - }, - "fullDescription": { - "text": issue.body.clone() - }, - "helpUri": null, - "defaultConfiguration": { - "level": severity_to_sarif_level(&issue.severity) - } - }) - }) - .collect(); + // Deduplicate rules by id (multiple issues can share same rule) + let mut rule_map = serde_json::Map::new(); + for issue in issues { + let rule_id = issue + .issue_type + .as_ref() + .map(|t| t.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + if !rule_map.contains_key(&rule_id) { + rule_map.insert( + rule_id.clone(), + json!({ + "id": rule_id, + "shortDescription": { + "text": issue.title.clone() + }, + "fullDescription": { + "text": issue.body.clone() + }, + "defaultConfiguration": { + "level": severity_to_sarif_level(&issue.severity) + } + }), + ); + } + } + let rules: Vec = rule_map.into_values().collect(); let results: Vec = issues .iter() @@ -73,17 +81,23 @@ fn build_sarif(issues: &[ReviewIssue]) -> Value { // Add fix suggestion if available if let Some(ref fix) = issue.suggested_fix { - let mut replacements = Vec::new(); - replacements.push(json!({ - "description": { - "text": "Suggested fix" - } - // We don't have a full replacement spec, but we can include the text - })); result["fixes"] = json!([{ "description": { "text": fix.clone() - } + }, + "artifactChanges": [{ + "artifactLocation": { + "uri": issue.file.clone() + }, + "replacements": [{ + "deletedRegion": { + "startLine": issue.line.unwrap_or(1) + }, + "insertedContent": { + "text": fix.clone() + } + }] + }] }]); } @@ -103,12 +117,7 @@ fn build_sarif(issues: &[ReviewIssue]) -> Value { "rules": rules } }, - "results": results, - "invocation": { - "executionSuccessful": true, - "startTimeUtc": Utc::now().to_rfc3339(), - "endTimeUtc": Utc::now().to_rfc3339() - } + "results": results }] }) } From e36097336fd22f1b153328c243ee54d43b8126e9 Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sat, 30 May 2026 19:52:03 +0700 Subject: [PATCH 06/23] fix(ci): address cora self-review findings - Move Infisical identity-id to secret (INFISICAL_IDENTITY_ID) - Remove 2>&1 redirect (was mixing stderr into SARIF JSON) - Remove || true (was masking failures) - Guard SARIF upload with hashFiles check --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9dbd564..28aa1ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: uses: Infisical/secrets-action@v1.0.9 with: method: "oidc" - identity-id: "6bd2b8d8-a9a3-4331-8b37-bf2764fc320b" + identity-id: ${{ secrets.INFISICAL_IDENTITY_ID }} project-slug: "github-actions" env-slug: "prod" domain: "https://infisical.ajianaz.dev" @@ -96,7 +96,7 @@ jobs: --format sarif \ --severity major \ --quiet \ - > cora-results.sarif 2>&1 || true + > cora-results.sarif echo "### 🔍 Cora AI Code Review" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY @@ -109,7 +109,7 @@ jobs: fi - name: Upload SARIF to GitHub Code Scanning - if: always() + if: always() && hashFiles('cora-results.sarif') != '' uses: github/codeql-action/upload-sarif@v4 with: sarif_file: cora-results.sarif From e1929c347eb6d2629ecc426fe6108dcb98945ebb Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sat, 30 May 2026 20:17:39 +0700 Subject: [PATCH 07/23] refactor(ci): extract cora review into composite action - Create .github/actions/cora-review/action.yml - Configurable: base-branch, severity, infisical settings - Handles: SARIF upload, PR comment (update existing), blocking check - Suppresses cargo build noise (2>/dev/null) - No GITHUB_STEP_SUMMARY branding pollution - Slim ci.yml: cora-review job now 5 lines (uses composite action) - Fix duplicate 'error' in blocking check python script --- .github/actions/cora-review/action.yml | 168 +++++++++++++++++++++++++ .github/workflows/ci.yml | 150 ++-------------------- 2 files changed, 176 insertions(+), 142 deletions(-) create mode 100644 .github/actions/cora-review/action.yml diff --git a/.github/actions/cora-review/action.yml b/.github/actions/cora-review/action.yml new file mode 100644 index 0000000..6e99e94 --- /dev/null +++ b/.github/actions/cora-review/action.yml @@ -0,0 +1,168 @@ +name: 'Cora AI Code Review' +description: 'Run cora AI code review on a PR diff — uploads SARIF to Code Scanning and posts a PR comment.' + +inputs: + base-branch: + description: 'Base branch to compare against (default: origin/develop)' + required: false + default: 'origin/develop' + severity: + description: 'Minimum severity to report (info, minor, major, critical)' + required: false + default: 'major' + infisical-identity-id: + description: 'Infisical OIDC identity ID (from secret INFISICAL_IDENTITY_ID)' + required: true + infisical-project: + description: 'Infisical project slug' + required: false + default: 'github-actions' + infisical-env: + description: 'Infisical environment slug' + required: false + default: 'prod' + infisical-domain: + description: 'Infisical domain URL' + required: false + default: 'https://infisical.ajianaz.dev' + github-token: + description: 'GitHub token for PR comments and SARIF upload' + required: true + +runs: + using: 'composite' + steps: + - name: Fetch LLM secrets from Infisical + uses: Infisical/secrets-action@v1.0.9 + with: + method: 'oidc' + identity-id: ${{ inputs.infisical-identity-id }} + project-slug: ${{ inputs.infisical-project }} + env-slug: ${{ inputs.infisical-env }} + domain: ${{ inputs.infisical-domain }} + + - name: Run cora review + shell: bash + run: | + cargo build --release 2>/dev/null + ./target/release/cora review \ + --base ${{ inputs.base-branch }} \ + --format sarif \ + --severity ${{ inputs.severity }} \ + --quiet \ + > cora-results.sarif 2>/dev/null || true + + - name: Upload SARIF to GitHub Code Scanning + if: always() && hashFiles('cora-results.sarif') != '' + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: cora-results.sarif + category: cora-review + + - name: Post PR comment + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + env: + GH_TOKEN: ${{ inputs.github-token }} + with: + script: | + const fs = require('fs'); + + let sarifContent; + try { + sarifContent = JSON.parse(fs.readFileSync('cora-results.sarif', 'utf8')); + } catch (e) { + sarifContent = null; + } + + let body; + if (!sarifContent || !sarifContent.runs || sarifContent.runs.length === 0) { + body = `## 🔍 Cora AI Code Review\n\n✅ **No issues found.** Code looks good!`; + } else { + const results = sarifContent.runs[0].results || []; + if (results.length === 0) { + body = `## 🔍 Cora AI Code Review\n\n✅ **No issues found.** Code looks good!`; + } else { + const grouped = {}; + for (const r of results) { + const sev = (r.level || 'note').charAt(0).toUpperCase() + (r.level || 'note').slice(1); + if (!grouped[sev]) grouped[sev] = []; + grouped[sev].push(r); + } + + const severityOrder = ['Error', 'Warning', 'Note']; + let table = ''; + + for (const sev of severityOrder) { + if (!grouped[sev]) continue; + const icon = sev === 'Error' ? '🔴' : sev === 'Warning' ? '🟡' : '🔵'; + table += `\n### ${icon} ${sev} (${grouped[sev].length})\n\n`; + for (const r of grouped[sev]) { + const loc = r.locations?.[0]?.physicalLocation; + const file = loc?.artifactLocation?.uri || 'unknown'; + const line = loc?.region?.startLine || '?'; + const msg = r.message?.text || r.message?.markdown || 'No message'; + table += `- \`${file}:${line}\` — ${msg}\n`; + } + } + + const hasErrors = (grouped['Error'] || []).length > 0; + const hasWarnings = (grouped['Warning'] || []).length > 0; + const status = hasErrors ? '❌ **Blocked** — critical issues found.' : + hasWarnings ? '⚠️ **Review recommended** — warnings found.' : + '✅ **Passed** — only informational notes.'; + + body = `## 🔍 Cora AI Code Review\n\n${status}\n${table}\n---\n_Review powered by [cora-cli](https://github.com/ajianaz/cora-cli) · BYOK · MIT_`; + } + } + + // Find existing cora comment and update it, or create new + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => + c.user.login === 'github-actions[bot]' && + c.body.startsWith('## 🔍 Cora AI Code Review') + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Check for blocking issues + if: always() + shell: bash + run: | + if [ -f cora-results.sarif ]; then + ERRORS=$(python3 -c " + import json, sys + try: + data = json.load(open('cora-results.sarif')) + results = data.get('runs', [{}])[0].get('results', []) + errors = [r for r in results if r.get('level') == 'error'] + print(len(errors)) + except: + print(0) + " 2>/dev/null || echo "0") + + if [ "$ERRORS" -gt 0 ]; then + echo "::error::Cora found $ERRORS blocking issue(s). Review the Code Scanning results." + exit 1 + fi + fi + echo "No blocking issues." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28aa1ba..e7595d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,11 @@ jobs: runs-on: ubuntu-latest needs: [check, fmt, clippy, test] if: github.event_name == 'pull_request' + permissions: + contents: read + security-events: write + pull-requests: write + id-token: write steps: - uses: actions/checkout@v4 with: @@ -78,146 +83,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - name: Fetch LLM secrets from Infisical - uses: Infisical/secrets-action@v1.0.9 + - uses: ./.github/actions/cora-review with: - method: "oidc" - identity-id: ${{ secrets.INFISICAL_IDENTITY_ID }} - project-slug: "github-actions" - env-slug: "prod" - domain: "https://infisical.ajianaz.dev" - - - name: Run cora review - run: | - cargo build --release - echo "=== CORA REVIEW (SARIF) ===" >> $GITHUB_STEP_SUMMARY - ./target/release/cora review \ - --base origin/develop \ - --format sarif \ - --severity major \ - --quiet \ - > cora-results.sarif - - echo "### 🔍 Cora AI Code Review" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - if [ -s cora-results.sarif ]; then - echo '```json' >> $GITHUB_STEP_SUMMARY - cat cora-results.sarif >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - else - echo "No issues found." >> $GITHUB_STEP_SUMMARY - fi - - - name: Upload SARIF to GitHub Code Scanning - if: always() && hashFiles('cora-results.sarif') != '' - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: cora-results.sarif - category: cora-review - - - name: Post PR comment - if: always() && github.event_name == 'pull_request' - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - - let sarifContent; - try { - sarifContent = JSON.parse(fs.readFileSync('cora-results.sarif', 'utf8')); - } catch (e) { - sarifContent = null; - } - - let body; - if (!sarifContent || !sarifContent.runs || sarifContent.runs.length === 0) { - body = `## 🔍 Cora AI Code Review\n\n✅ **No issues found.** Code looks good!`; - } else { - const results = sarifContent.runs[0].results || []; - if (results.length === 0) { - body = `## 🔍 Cora AI Code Review\n\n✅ **No issues found.** Code looks good!`; - } else { - // Group by severity - const grouped = {}; - for (const r of results) { - const sev = (r.level || 'note').charAt(0).toUpperCase() + (r.level || 'note').slice(1); - if (!grouped[sev]) grouped[sev] = []; - grouped[sev].push(r); - } - - const severityOrder = ['Error', 'Warning', 'Note']; - let table = ''; - - for (const sev of severityOrder) { - if (!grouped[sev]) continue; - const icon = sev === 'Error' ? '🔴' : sev === 'Warning' ? '🟡' : '🔵'; - table += `\n### ${icon} ${sev} (${grouped[sev].length})\n\n`; - for (const r of grouped[sev]) { - const loc = r.locations?.[0]?.physicalLocation; - const file = loc?.artifactLocation?.uri || 'unknown'; - const line = loc?.region?.startLine || '?'; - const msg = r.message?.text || r.message?.markdown || 'No message'; - table += `- \`${file}:${line}\` — ${msg}\n`; - } - } - - const hasErrors = (grouped['Error'] || []).length > 0; - const hasWarnings = (grouped['Warning'] || []).length > 0; - const status = hasErrors ? '❌ **Blocked** — critical issues found.' : - hasWarnings ? '⚠️ **Review recommended** — warnings found.' : - '✅ **Passed** — only informational notes.'; - - body = `## 🔍 Cora AI Code Review\n\n${status}\n${table}\n---\n_Review powered by [cora-cli](https://github.com/ajianaz/cora-cli) · BYOK · MIT_`; - } - } - - // Find existing cora comment and update it, or create new - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const existing = comments.find(c => - c.user.login === 'github-actions[bot]' && - c.body.startsWith('## 🔍 Cora AI Code Review') - ); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body, - }); - } - - - name: Check for blocking issues - if: always() - run: | - if [ -f cora-results.sarif ]; then - # Check for error-level results in SARIF - ERRORS=$(cat cora-results.sarif | python3 -c " - import json, sys - try: - data = json.load(sys.stdin) - results = data.get('runs', [{}])[0].get('results', []) - errors = [r for r in results if r.get('level') in ('error', 'error')] - print(len(errors)) - except: - print(0) - " 2>/dev/null || echo "0") - - if [ "$ERRORS" -gt 0 ]; then - echo "::error::Cora found $ERRORS blocking issue(s). Review the Code Scanning results." - exit 1 - fi - fi - echo "No blocking issues." + github-token: ${{ secrets.GITHUB_TOKEN }} + infisical-identity-id: ${{ secrets.INFISICAL_IDENTITY_ID }} From 9df928962f3823c00e1671ba16696ce41083bba7 Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sat, 30 May 2026 20:23:59 +0700 Subject: [PATCH 08/23] fix(ci): remove || true and cargo build stderr suppress - Remove || true (was masking failures silently) - Remove 2>/dev/null from cargo build (show build errors) - Keep 2>/dev/null on cora output only (prevent stderr corrupting SARIF) - Add echo for review completion status --- .github/actions/cora-review/action.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/actions/cora-review/action.yml b/.github/actions/cora-review/action.yml index 6e99e94..4d03000 100644 --- a/.github/actions/cora-review/action.yml +++ b/.github/actions/cora-review/action.yml @@ -44,13 +44,14 @@ runs: - name: Run cora review shell: bash run: | - cargo build --release 2>/dev/null + cargo build --release ./target/release/cora review \ --base ${{ inputs.base-branch }} \ --format sarif \ --severity ${{ inputs.severity }} \ --quiet \ - > cora-results.sarif 2>/dev/null || true + > cora-results.sarif 2>/dev/null + echo "Cora review complete ($(wc -c < cora-results.sarif) bytes)" - name: Upload SARIF to GitHub Code Scanning if: always() && hashFiles('cora-results.sarif') != '' From 9bb99e8f68b0446b52e5e015421a791443152350 Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sat, 30 May 2026 20:38:07 +0700 Subject: [PATCH 09/23] perf: remove unused deps + replace deprecated serde_yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed (0 uses each): - owo-colors (duplicate of colored) - thiserror (using anyhow instead) - chrono (no longer needed after SARIF fix) Replaced: - serde_yaml (deprecated) → serde_yml (maintained fork) Optimized: - tokio: 'full' → 'rt-multi-thread,macros' (unused features removed) Results: - Build time: 2m52s → 1m03s (-56%) - Transitive deps: 393 → 377 (-16) - 151 tests pass --- Cargo.lock | 207 +++++----------------------------------- Cargo.toml | 9 +- src/config/schema.rs | 6 +- tests/config_loading.rs | 18 ++-- 4 files changed, 38 insertions(+), 202 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93be71a..7c02cf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,15 +11,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "1.0.0" @@ -162,19 +153,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - [[package]] name = "clap" version = "4.6.1" @@ -257,7 +235,6 @@ version = "0.1.1" dependencies = [ "anyhow", "assert_cmd", - "chrono", "clap", "clap_complete", "colored", @@ -266,15 +243,13 @@ dependencies = [ "git2", "glob", "indicatif", - "owo-colors", "predicates", "regex", "reqwest", "serde", "serde_json", - "serde_yaml", + "serde_yml", "tempfile", - "thiserror", "tokio", "tokio-test", "toml", @@ -283,12 +258,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "difflib" version = "0.4.0" @@ -623,30 +592,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.1.1" @@ -859,6 +804,16 @@ dependencies = [ "libc", ] +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + [[package]] name = "libz-sys" version = "1.1.28" @@ -883,15 +838,6 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.30" @@ -972,35 +918,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "owo-colors" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1197,15 +1114,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -1376,12 +1284,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "semver" version = "1.0.28" @@ -1453,16 +1355,18 @@ dependencies = [ ] [[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" +name = "serde_yml" +version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" dependencies = [ "indexmap", "itoa", + "libyml", + "memchr", "ryu", "serde", - "unsafe-libyaml", + "version_check", ] [[package]] @@ -1480,16 +1384,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "slab" version = "0.4.12" @@ -1643,9 +1537,7 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -1884,12 +1776,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -1932,6 +1818,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -2124,65 +2016,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 33b2c00..74a9d54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,8 +15,8 @@ rust-version = "1.85" clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" -# Async runtime -tokio = { version = "1", features = ["full"] } +# Async runtime (only what we need) +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } # HTTP client (OpenAI-compatible API calls) reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], default-features = false } @@ -24,14 +24,13 @@ reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], defau # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" -serde_yaml = "0.9" +serde_yml = "0.0.12" # Git operations git2 = { version = "0.20", default-features = false, features = ["vendored-libgit2"] } # Terminal output colored = "3" -owo-colors = "4" indicatif = "0.18" # File system @@ -41,7 +40,6 @@ walkdir = "2" # Error handling anyhow = "1" -thiserror = "2" # Config toml = "0.8" @@ -51,7 +49,6 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Misc -chrono = "0.4" regex = "1" futures-util = "0.3" diff --git a/src/config/schema.rs b/src/config/schema.rs index 1f207ce..da58fcb 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -133,7 +133,7 @@ pub struct OutputSection { impl CoraFile { pub fn from_str(content: &str) -> Result { - serde_yaml::from_str(content).context("failed to parse .cora.yaml") + serde_yml::from_str(content).context("failed to parse .cora.yaml") } /// Merge this file config into a `Config`, overwriting only fields that are present. @@ -433,8 +433,8 @@ output: focus: Some(vec!["security".to_string()]), ..Default::default() }; - let yaml = serde_yaml::to_string(&cora).unwrap(); - let back: CoraFile = serde_yaml::from_str(&yaml).unwrap(); + let yaml = serde_yml::to_string(&cora).unwrap(); + let back: CoraFile = serde_yml::from_str(&yaml).unwrap(); assert_eq!( back.provider.as_ref().unwrap().provider.as_deref(), Some("ollama") diff --git a/tests/config_loading.rs b/tests/config_loading.rs index 4d048d3..c41ace2 100644 --- a/tests/config_loading.rs +++ b/tests/config_loading.rs @@ -33,8 +33,8 @@ fn init_creates_valid_yaml() { // Verify the created file is valid YAML let content = std::fs::read_to_string(&config_path).unwrap(); - let parsed: serde_yaml::Value = - serde_yaml::from_str(&content).expect("init should create valid YAML"); + let parsed: serde_yml::Value = + serde_yml::from_str(&content).expect("init should create valid YAML"); // Verify it has expected top-level keys assert!( @@ -59,9 +59,9 @@ output: "#; let file = write_temp_yaml(yaml); - // Parse and verify using serde_yaml directly + // Parse and verify using serde_yml directly let content = std::fs::read_to_string(file.path()).unwrap(); - let parsed: serde_yaml::Value = serde_yaml::from_str(&content).unwrap(); + let parsed: serde_yml::Value = serde_yml::from_str(&content).unwrap(); assert_eq!(parsed["provider"]["provider"].as_str(), Some("anthropic")); assert_eq!(parsed["provider"]["model"].as_str(), Some("claude-3-haiku")); @@ -88,11 +88,11 @@ ignore: let content = std::fs::read_to_string(file.path()).unwrap(); // Parse to Value, serialize back - let val: serde_yaml::Value = serde_yaml::from_str(&content).unwrap(); - let reser = serde_yaml::to_string(&val).unwrap(); + let val: serde_yml::Value = serde_yml::from_str(&content).unwrap(); + let reser = serde_yml::to_string(&val).unwrap(); // Re-parse the re-serialized version to ensure round-trip - let val2: serde_yaml::Value = serde_yaml::from_str(&reser).unwrap(); + let val2: serde_yml::Value = serde_yml::from_str(&reser).unwrap(); assert_eq!(val2["provider"]["provider"], val["provider"]["provider"]); assert_eq!(val2["hook"]["mode"], val["hook"]["mode"]); assert_eq!( @@ -106,7 +106,7 @@ fn config_file_empty_yaml_is_valid() { let yaml = ""; let file = write_temp_yaml(yaml); let content = std::fs::read_to_string(file.path()).unwrap(); - let parsed: serde_yaml::Value = serde_yaml::from_str(&content).unwrap(); + let parsed: serde_yml::Value = serde_yml::from_str(&content).unwrap(); assert!(parsed.is_null()); } @@ -118,7 +118,7 @@ focus: "#; let file = write_temp_yaml(yaml); let content = std::fs::read_to_string(file.path()).unwrap(); - let parsed: serde_yaml::Value = serde_yaml::from_str(&content).unwrap(); + let parsed: serde_yml::Value = serde_yml::from_str(&content).unwrap(); assert_eq!(parsed["focus"].as_sequence().unwrap().len(), 1); assert!(parsed.get("provider").is_none()); } From f81c65f2d3b5a4190f7cd7dcf82e5df91962d53b Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sat, 30 May 2026 20:47:29 +0700 Subject: [PATCH 10/23] fix: replace serde_yml (deprecated) with serde_yaml_ng serde_yml itself is deprecated. serde_yaml_ng v0.10 is the community-maintained fork with stable API. 151 tests pass. --- Cargo.lock | 34 +++++++++++----------------------- Cargo.toml | 2 +- src/config/schema.rs | 6 +++--- tests/config_loading.rs | 18 +++++++++--------- 4 files changed, 24 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c02cf1..129554a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,7 +248,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "serde_yml", + "serde_yaml_ng", "tempfile", "tokio", "tokio-test", @@ -804,16 +804,6 @@ dependencies = [ "libc", ] -[[package]] -name = "libyml" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" -dependencies = [ - "anyhow", - "version_check", -] - [[package]] name = "libz-sys" version = "1.1.28" @@ -1355,18 +1345,16 @@ dependencies = [ ] [[package]] -name = "serde_yml" -version = "0.0.12" +name = "serde_yaml_ng" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ "indexmap", "itoa", - "libyml", - "memchr", "ryu", "serde", - "version_check", + "unsafe-libyaml", ] [[package]] @@ -1776,6 +1764,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -1818,12 +1812,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 74a9d54..e74fe98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], defau # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" -serde_yml = "0.0.12" +serde_yaml_ng = "0.10" # Git operations git2 = { version = "0.20", default-features = false, features = ["vendored-libgit2"] } diff --git a/src/config/schema.rs b/src/config/schema.rs index da58fcb..cc0cd8a 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -133,7 +133,7 @@ pub struct OutputSection { impl CoraFile { pub fn from_str(content: &str) -> Result { - serde_yml::from_str(content).context("failed to parse .cora.yaml") + serde_yaml_ng::from_str(content).context("failed to parse .cora.yaml") } /// Merge this file config into a `Config`, overwriting only fields that are present. @@ -433,8 +433,8 @@ output: focus: Some(vec!["security".to_string()]), ..Default::default() }; - let yaml = serde_yml::to_string(&cora).unwrap(); - let back: CoraFile = serde_yml::from_str(&yaml).unwrap(); + let yaml = serde_yaml_ng::to_string(&cora).unwrap(); + let back: CoraFile = serde_yaml_ng::from_str(&yaml).unwrap(); assert_eq!( back.provider.as_ref().unwrap().provider.as_deref(), Some("ollama") diff --git a/tests/config_loading.rs b/tests/config_loading.rs index c41ace2..e22f6e9 100644 --- a/tests/config_loading.rs +++ b/tests/config_loading.rs @@ -33,8 +33,8 @@ fn init_creates_valid_yaml() { // Verify the created file is valid YAML let content = std::fs::read_to_string(&config_path).unwrap(); - let parsed: serde_yml::Value = - serde_yml::from_str(&content).expect("init should create valid YAML"); + let parsed: serde_yaml_ng::Value = + serde_yaml_ng::from_str(&content).expect("init should create valid YAML"); // Verify it has expected top-level keys assert!( @@ -59,9 +59,9 @@ output: "#; let file = write_temp_yaml(yaml); - // Parse and verify using serde_yml directly + // Parse and verify using serde_yaml_ng directly let content = std::fs::read_to_string(file.path()).unwrap(); - let parsed: serde_yml::Value = serde_yml::from_str(&content).unwrap(); + let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap(); assert_eq!(parsed["provider"]["provider"].as_str(), Some("anthropic")); assert_eq!(parsed["provider"]["model"].as_str(), Some("claude-3-haiku")); @@ -88,11 +88,11 @@ ignore: let content = std::fs::read_to_string(file.path()).unwrap(); // Parse to Value, serialize back - let val: serde_yml::Value = serde_yml::from_str(&content).unwrap(); - let reser = serde_yml::to_string(&val).unwrap(); + let val: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap(); + let reser = serde_yaml_ng::to_string(&val).unwrap(); // Re-parse the re-serialized version to ensure round-trip - let val2: serde_yml::Value = serde_yml::from_str(&reser).unwrap(); + let val2: serde_yaml_ng::Value = serde_yaml_ng::from_str(&reser).unwrap(); assert_eq!(val2["provider"]["provider"], val["provider"]["provider"]); assert_eq!(val2["hook"]["mode"], val["hook"]["mode"]); assert_eq!( @@ -106,7 +106,7 @@ fn config_file_empty_yaml_is_valid() { let yaml = ""; let file = write_temp_yaml(yaml); let content = std::fs::read_to_string(file.path()).unwrap(); - let parsed: serde_yml::Value = serde_yml::from_str(&content).unwrap(); + let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap(); assert!(parsed.is_null()); } @@ -118,7 +118,7 @@ focus: "#; let file = write_temp_yaml(yaml); let content = std::fs::read_to_string(file.path()).unwrap(); - let parsed: serde_yml::Value = serde_yml::from_str(&content).unwrap(); + let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap(); assert_eq!(parsed["focus"].as_sequence().unwrap().len(), 1); assert!(parsed.get("provider").is_none()); } From ff6e385facf06477469bc7ac8538a9e6400d1afc Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Sun, 31 May 2026 09:38:11 +0700 Subject: [PATCH 11/23] fix(ci): normalize release binary naming + sync action (#51) Release binary now packaged as 'cora' (not target triple) inside tar. Updated release notes template to match new naming. Synced cora-review action with consumer repos: - Download from releases (not build from source) - cora-version default: 'latest' - SARIF upload: optional (default false) Co-authored-by: CTO Hermes --- .github/actions/cora-review/action.yml | 39 ++++++++++++++++++++++---- .github/workflows/release.yml | 18 ++++++------ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/.github/actions/cora-review/action.yml b/.github/actions/cora-review/action.yml index 4d03000..a6ef384 100644 --- a/.github/actions/cora-review/action.yml +++ b/.github/actions/cora-review/action.yml @@ -1,5 +1,5 @@ name: 'Cora AI Code Review' -description: 'Run cora AI code review on a PR diff — uploads SARIF to Code Scanning and posts a PR comment.' +description: 'Run cora AI code review on a PR diff — posts a PR comment. SARIF upload is optional.' inputs: base-branch: @@ -10,6 +10,14 @@ inputs: description: 'Minimum severity to report (info, minor, major, critical)' required: false default: 'major' + cora-version: + description: 'cora-cli version tag (default: latest release)' + required: false + default: 'latest' + upload-sarif: + description: 'Upload SARIF to GitHub Code Scanning (default: false)' + required: false + default: 'false' infisical-identity-id: description: 'Infisical OIDC identity ID (from secret INFISICAL_IDENTITY_ID)' required: true @@ -41,11 +49,31 @@ runs: env-slug: ${{ inputs.infisical-env }} domain: ${{ inputs.infisical-domain }} + - name: Install cora-cli + shell: bash + run: | + ARCH=$(uname -m) + if [ "$ARCH" = "x86_64" ]; then + ASSET="cora-x86_64-unknown-linux-gnu" + elif [ "$ARCH" = "aarch64" ]; then + ASSET="cora-aarch64-unknown-linux-gnu" + else + echo "Unsupported architecture: $ARCH" + exit 1 + fi + curl -sL "https://github.com/ajianaz/cora-cli/releases/download/${{ inputs.cora-version }}/${ASSET}-${{ inputs.cora-version }}.tar.gz" | tar xz -C /usr/local/bin cora + chmod +x /usr/local/bin/cora + cora --version + - name: Run cora review + id: review shell: bash + env: + CORA_API_KEY: ${{ env.CORA_API_KEY }} + CORA_BASE_URL: ${{ env.CORA_BASE_URL }} + CORA_MODEL: ${{ env.CORA_MODEL }} run: | - cargo build --release - ./target/release/cora review \ + cora review \ --base ${{ inputs.base-branch }} \ --format sarif \ --severity ${{ inputs.severity }} \ @@ -54,7 +82,8 @@ runs: echo "Cora review complete ($(wc -c < cora-results.sarif) bytes)" - name: Upload SARIF to GitHub Code Scanning - if: always() && hashFiles('cora-results.sarif') != '' + if: inputs.upload-sarif == 'true' + continue-on-error: true uses: github/codeql-action/upload-sarif@v4 with: sarif_file: cora-results.sarif @@ -162,7 +191,7 @@ runs: " 2>/dev/null || echo "0") if [ "$ERRORS" -gt 0 ]; then - echo "::error::Cora found $ERRORS blocking issue(s). Review the Code Scanning results." + echo "::error::Cora found $ERRORS blocking issue(s)." exit 1 fi fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec25a07..764a6ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,9 +64,9 @@ jobs: if: runner.os != 'Windows' run: | mkdir -p release - cp target/${{ matrix.target }}/release/cora release/${{ matrix.artifact }} + cp target/${{ matrix.target }}/release/cora release/cora cd release - tar czf ${{ matrix.artifact }}-${{ steps.version.outputs.VERSION }}.${{ matrix.ext }} ${{ matrix.artifact }} + tar czf ${{ matrix.artifact }}-${{ steps.version.outputs.VERSION }}.${{ matrix.ext }} cora - name: Package (Windows) if: runner.os == 'Windows' @@ -151,10 +151,10 @@ jobs: | Platform | File | |----------|------| - | Linux (x86_64) | `cora-VERSION-x86_64-unknown-linux-gnu.tar.gz` | - | Linux (ARM64) | `cora-VERSION-aarch64-unknown-linux-gnu.tar.gz` | - | macOS (Apple Silicon) | `cora-VERSION-aarch64-apple-darwin.tar.gz` | - | Windows (x86_64) | `cora-VERSION-x86_64-pc-windows-msvc.zip` | + | Linux (x86_64) | `cora-x86_64-unknown-linux-gnu-VERSION.tar.gz` | + | Linux (ARM64) | `cora-aarch64-unknown-linux-gnu-VERSION.tar.gz` | + | macOS (Apple Silicon) | `cora-aarch64-apple-darwin-VERSION.tar.gz` | + | Windows (x86_64) | `cora-x86_64-pc-windows-msvc-VERSION.zip` | ### 🚀 Quick Start @@ -162,8 +162,10 @@ jobs: # Install via cargo cargo install cora - # Or download binary - curl -fsSL https://github.com/ajianaz/cora-cli/releases/latest/download/cora-x86_64-unknown-linux-gnu-v0.1.1.tar.gz | tar xz + # Or download binary (extracts as 'cora') + curl -fsSL https://github.com/ajianaz/cora-cli/releases/latest/download/cora-x86_64-unknown-linux-gnu-VERSION.tar.gz | tar xz + chmod +x cora + sudo mv cora /usr/local/bin/ # Init project cora init From d21f8bd56a9e34d63e3cf2523d367fbeadf57d6f Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Sun, 31 May 2026 09:43:07 +0700 Subject: [PATCH 12/23] chore: bump version to v0.1.2 (#52) Co-authored-by: CTO Hermes --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index e74fe98..c83f0a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cora-cli" -version = "0.1.1" +version = "0.1.2" edition = "2024" description = "CLI-first AI code review — BYOK, diff/scan/branch, pre-commit hooks" license = "MIT" From c072f84ccc55640651a369ac6b6ac6bdde9385bc Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sun, 31 May 2026 03:55:55 +0000 Subject: [PATCH 13/23] docs: add CI setup guide, cora-review-simple action, website link - Add website link (codecora.dev) to README header - Rewrite CI/CD Integration section with full setup guide - Add cora-review-simple action (GitHub Secrets, no Infisical) - Fix cora-review action: add latest version resolve step - Add troubleshooting section with real-world pitfalls - Add comparison table: cora-review vs cora-review-simple --- .github/actions/cora-review-simple/action.yml | 195 +++++++++++++++ .github/actions/cora-review/action.yml | 16 +- README.md | 236 +++++++++++++++--- 3 files changed, 410 insertions(+), 37 deletions(-) create mode 100644 .github/actions/cora-review-simple/action.yml diff --git a/.github/actions/cora-review-simple/action.yml b/.github/actions/cora-review-simple/action.yml new file mode 100644 index 0000000..ba7808a --- /dev/null +++ b/.github/actions/cora-review-simple/action.yml @@ -0,0 +1,195 @@ +name: 'Cora AI Code Review' +description: 'Run cora AI code review on a PR diff — posts a PR comment. Uses GitHub Secrets directly (no Infisical required). SARIF upload is optional.' + +inputs: + base-branch: + description: 'Base branch to compare against (default: origin/develop)' + required: false + default: 'origin/develop' + severity: + description: 'Minimum severity to report (info, minor, major, critical)' + required: false + default: 'major' + cora-version: + description: 'cora-cli version tag (default: latest release)' + required: false + default: 'latest' + upload-sarif: + description: 'Upload SARIF to GitHub Code Scanning (default: false)' + required: false + default: 'false' + cora-api-key: + description: 'LLM API key (from secrets.CORA_API_KEY)' + required: true + cora-base-url: + description: 'LLM API base URL (from secrets.CORA_BASE_URL)' + required: true + cora-model: + description: 'LLM model ID (from secrets.CORA_MODEL)' + required: true + github-token: + description: 'GitHub token for PR comments and SARIF upload' + required: true + +runs: + using: 'composite' + steps: + - name: Resolve cora-cli version + id: resolve + shell: bash + run: | + if [ "${{ inputs.cora-version }}" = "latest" ]; then + VERSION=$(curl -sL https://api.github.com/repos/ajianaz/cora-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'])") + else + VERSION="${{ inputs.cora-version }}" + fi + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved cora-cli version: $VERSION" + + - name: Install cora-cli + shell: bash + run: | + ARCH=$(uname -m) + if [ "$ARCH" = "x86_64" ]; then + ASSET="cora-x86_64-unknown-linux-gnu" + elif [ "$ARCH" = "aarch64" ]; then + ASSET="cora-aarch64-unknown-linux-gnu" + else + echo "Unsupported architecture: $ARCH" + exit 1 + fi + curl -sL "https://github.com/ajianaz/cora-cli/releases/download/${{ steps.resolve.outputs.VERSION }}/${ASSET}-${{ steps.resolve.outputs.VERSION }}.tar.gz" | tar xz -C /usr/local/bin cora + chmod +x /usr/local/bin/cora + cora --version + + - name: Run cora review + id: review + shell: bash + env: + CORA_API_KEY: ${{ inputs.cora-api-key }} + CORA_BASE_URL: ${{ inputs.cora-base-url }} + CORA_MODEL: ${{ inputs.cora-model }} + run: | + cora review \ + --base ${{ inputs.base-branch }} \ + --format sarif \ + --severity ${{ inputs.severity }} \ + --quiet \ + > cora-results.sarif 2>/dev/null + echo "Cora review complete ($(wc -c < cora-results.sarif) bytes)" + + - name: Upload SARIF to GitHub Code Scanning + if: inputs.upload-sarif == 'true' + continue-on-error: true + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: cora-results.sarif + category: cora-review + + - name: Post PR comment + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + env: + GH_TOKEN: ${{ inputs.github-token }} + with: + script: | + const fs = require('fs'); + + let sarifContent; + try { + sarifContent = JSON.parse(fs.readFileSync('cora-results.sarif', 'utf8')); + } catch (e) { + sarifContent = null; + } + + let body; + if (!sarifContent || !sarifContent.runs || sarifContent.runs.length === 0) { + body = `## 🔍 Cora AI Code Review\n\n✅ **No issues found.** Code looks good!`; + } else { + const results = sarifContent.runs[0].results || []; + if (results.length === 0) { + body = `## 🔍 Cora AI Code Review\n\n✅ **No issues found.** Code looks good!`; + } else { + const grouped = {}; + for (const r of results) { + const sev = (r.level || 'note').charAt(0).toUpperCase() + (r.level || 'note').slice(1); + if (!grouped[sev]) grouped[sev] = []; + grouped[sev].push(r); + } + + const severityOrder = ['Error', 'Warning', 'Note']; + let table = ''; + + for (const sev of severityOrder) { + if (!grouped[sev]) continue; + const icon = sev === 'Error' ? '🔴' : sev === 'Warning' ? '🟡' : '🔵'; + table += `\n### ${icon} ${sev} (${grouped[sev].length})\n\n`; + for (const r of grouped[sev]) { + const loc = r.locations?.[0]?.physicalLocation; + const file = loc?.artifactLocation?.uri || 'unknown'; + const line = loc?.region?.startLine || '?'; + const msg = r.message?.text || r.message?.markdown || 'No message'; + table += `- \`${file}:${line}\` — ${msg}\n`; + } + } + + const hasErrors = (grouped['Error'] || []).length > 0; + const hasWarnings = (grouped['Warning'] || []).length > 0; + const status = hasErrors ? '❌ **Blocked** — critical issues found.' : + hasWarnings ? '⚠️ **Review recommended** — warnings found.' : + '✅ **Passed** — only informational notes.'; + + body = `## 🔍 Cora AI Code Review\n\n${status}\n${table}\n---\n_Review powered by [cora-cli](https://github.com/ajianaz/cora-cli) · BYOK · MIT_`; + } + } + + // Find existing cora comment and update it, or create new + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => + c.user.login === 'github-actions[bot]' && + c.body.startsWith('## 🔍 Cora AI Code Review') + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Check for blocking issues + if: always() + shell: bash + run: | + if [ -f cora-results.sarif ]; then + ERRORS=$(python3 -c " + import json, sys + try: + data = json.load(open('cora-results.sarif')) + results = data.get('runs', [{}])[0].get('results', []) + errors = [r for r in results if r.get('level') == 'error'] + print(len(errors)) + except: + print(0) + " 2>/dev/null || echo "0") + + if [ "$ERRORS" -gt 0 ]; then + echo "::error::Cora found $ERRORS blocking issue(s)." + exit 1 + fi + fi + echo "No blocking issues." diff --git a/.github/actions/cora-review/action.yml b/.github/actions/cora-review/action.yml index a6ef384..a4198a7 100644 --- a/.github/actions/cora-review/action.yml +++ b/.github/actions/cora-review/action.yml @@ -1,5 +1,5 @@ name: 'Cora AI Code Review' -description: 'Run cora AI code review on a PR diff — posts a PR comment. SARIF upload is optional.' +description: 'Run cora AI code review on a PR diff — posts a PR comment. Uses Infisical OIDC for LLM secrets (zero keys in GitHub). SARIF upload is optional.' inputs: base-branch: @@ -49,6 +49,18 @@ runs: env-slug: ${{ inputs.infisical-env }} domain: ${{ inputs.infisical-domain }} + - name: Resolve cora-cli version + id: resolve + shell: bash + run: | + if [ "${{ inputs.cora-version }}" = "latest" ]; then + VERSION=$(curl -sL https://api.github.com/repos/ajianaz/cora-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'])") + else + VERSION="${{ inputs.cora-version }}" + fi + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved cora-cli version: $VERSION" + - name: Install cora-cli shell: bash run: | @@ -61,7 +73,7 @@ runs: echo "Unsupported architecture: $ARCH" exit 1 fi - curl -sL "https://github.com/ajianaz/cora-cli/releases/download/${{ inputs.cora-version }}/${ASSET}-${{ inputs.cora-version }}.tar.gz" | tar xz -C /usr/local/bin cora + curl -sL "https://github.com/ajianaz/cora-cli/releases/download/${{ steps.resolve.outputs.VERSION }}/${ASSET}-${{ steps.resolve.outputs.VERSION }}.tar.gz" | tar xz -C /usr/local/bin cora chmod +x /usr/local/bin/cora cora --version diff --git a/README.md b/README.md index 6b3ba1f..e4d1854 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ **Cora** is a fast, opinionated CLI tool that uses LLMs to review your code changes — directly in your terminal, CI/CD pipeline, or git hooks. +🌐 [Website](https://codecora.dev) +
--- @@ -275,38 +277,194 @@ providers: ## 🔗 CI/CD Integration -### GitHub Actions +Cora ships with **two reusable composite actions** for GitHub Actions. Both handle downloading the binary from GitHub Releases, running the review, posting PR comments, and optionally uploading SARIF. The difference is how LLM secrets are provided. + +### Which Action to Use? + +| | **cora-review** | **cora-review-simple** | +|--|:--|:--| +| Secret source | Infisical (OIDC) | GitHub Secrets | +| Keys in GitHub? | ❌ Zero | ✅ 3 secrets | +| Setup complexity | Infisical account needed | Copy-paste | +| Best for | Teams with Infisical | Quick setup, personal repos | + +--- + +### Option A: `cora-review` — Infisical OIDC (recommended) + +Zero API keys stored in GitHub. Secrets are pulled at runtime from [Infisical](https://infisical.com/) via OIDC. + +#### Step 1: Copy the action + +```bash +mkdir -p .github/actions/cora-review +curl -sL https://raw.githubusercontent.com/ajianaz/cora-cli/develop/.github/actions/cora-review/action.yml \ + -o .github/actions/cora-review/action.yml +``` + +#### Step 2: Add required secrets + +| Secret | Description | +|--------|-------------| +| `INFISICAL_IDENTITY_ID` | Infisical OIDC identity ID | +| `CORA_API_KEY` | LLM API key (set in Infisical, not GitHub) | +| `CORA_BASE_URL` | API base URL (set in Infisical, not GitHub) | +| `CORA_MODEL` | Model ID (set in Infisical, not GitHub) | + +#### Step 3: Add the CI job ```yaml -# .github/workflows/review.yml -name: Code Review +# .github/workflows/ci.yml +name: CI on: pull_request: - types: [opened, synchronize, reopened] + branches: [develop] + +permissions: + contents: read + security-events: write + pull-requests: write + id-token: write # Required for Infisical OIDC jobs: - review: + cora-review: + name: Cora Review runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + security-events: write + pull-requests: write + id-token: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Install cora-cli - run: cargo install cora-cli + - uses: ./.github/actions/cora-review + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + infisical-identity-id: ${{ secrets.INFISICAL_IDENTITY_ID }} + # Optional: + # upload-sarif: 'true' # Enable GitHub Code Scanning + # cora-version: 'v0.1.2' # Pin version (default: latest) + # severity: 'warning' # Minimum severity (default: major) + # base-branch: 'origin/main' # Diff target (default: origin/develop) +``` + +--- + +### Option B: `cora-review-simple` — GitHub Secrets + +Set API keys directly as GitHub repository secrets. No external service needed. + +#### Step 1: Copy the action + +```bash +mkdir -p .github/actions/cora-review-simple +curl -sL https://raw.githubusercontent.com/ajianaz/cora-cli/develop/.github/actions/cora-review-simple/action.yml \ + -o .github/actions/cora-review-simple/action.yml +``` + +#### Step 2: Add required secrets + +Go to your repo → **Settings → Secrets and variables → Actions → New repository secret**: + +| Secret | Example Value | +|--------|---------------| +| `CORA_API_KEY` | `sk-...` (OpenAI, Anthropic, etc.) | +| `CORA_BASE_URL` | `https://api.openai.com/v1` | +| `CORA_MODEL` | `gpt-4o` | + +#### Step 3: Add the CI job - - name: Run code review - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - run: | - cora review --base origin/develop --format sarif --output-file results.sarif +```yaml +# .github/workflows/ci.yml +name: CI + +on: + pull_request: + branches: [develop] + +permissions: + contents: read + security-events: write + pull-requests: write - - name: Upload SARIF to GitHub Code Scanning - if: always() - uses: github/codeql-action/upload-sarif@v3 +jobs: + cora-review: + name: Cora Review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + security-events: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: ./.github/actions/cora-review-simple with: - sarif_file: results.sarif + github-token: ${{ secrets.GITHUB_TOKEN }} + cora-api-key: ${{ secrets.CORA_API_KEY }} + cora-base-url: ${{ secrets.CORA_BASE_URL }} + cora-model: ${{ secrets.CORA_MODEL }} + # Optional: + # upload-sarif: 'true' # Enable GitHub Code Scanning + # cora-version: 'v0.1.2' # Pin version (default: latest) + # severity: 'warning' # Minimum severity (default: major) + # base-branch: 'origin/main' # Diff target (default: origin/develop) +``` + +--- + +### Configuration Reference (both actions) + +| Input | Default | Description | +|-------|---------|-------------| +| `github-token` | *(required)* | `secrets.GITHUB_TOKEN` for PR comments | +| `cora-version` | `latest` | Version tag or `latest` for auto-resolve via API | +| `upload-sarif` | `false` | Upload SARIF to GitHub Code Scanning | +| `severity` | `major` | Minimum severity: `info`, `minor`, `major`, `critical` | +| `base-branch` | `origin/develop` | Branch to diff against | + +### How It Works + +``` +PR opened + → Resolve cora version (GitHub API if "latest", or use pinned tag) + → Download cora binary from GitHub Releases + → Fetch LLM secrets (Infisical OIDC or GitHub Secrets) + → Run cora review --base --format sarif + → Parse SARIF → post structured PR comment (updates on re-run) + → Optionally upload SARIF to GitHub Code Scanning + → Exit 1 if error-level findings found (blocks merge) +``` + +### Block Merge on Critical Findings + +Add `Cora Review` as a required status check in your **Branch Protection** or **Repository Ruleset**. When cora finds error-level issues, the job fails and merge is blocked. + +### Pre-commit Hook + +Add cora as a git pre-commit hook for instant feedback: + +```bash +cora hook install +``` + +Or add it manually to `.git/hooks/pre-commit`: + +```bash +#!/bin/sh +cora review --quiet --severity critical +if [ $? -ne 0 ]; then + echo "❌ Code review found critical issues. Commit blocked." + exit 1 +fi ``` ### GitLab CI @@ -326,34 +484,42 @@ code-review: - if: $CI_PIPELINE_SOURCE == "merge_request_event" ``` -### Pre-commit Hook +### ⚠️ Troubleshooting -Add cora as a git pre-commit hook for instant feedback: +
+Common issues and solutions -```bash -# Install as pre-commit hook -cora hook install +**Q: `tar: Child returned status 1` — binary download fails** -# Review only staged files before each commit -# This runs automatically on `git commit` +Release archives contain a binary named `cora` (since v0.1.2). Older releases used the target triple name. Always use `latest` or pin to `>=v0.1.2`. -# Remove the hook -cora hook uninstall -``` +**Q: `latest` version doesn't resolve** -Or add it manually to `.git/hooks/pre-commit`: +The action resolves `latest` via GitHub API: `GET /repos/ajianaz/cora-cli/releases/latest` → extracts `tag_name`. This requires internet access on the runner. If rate-limited, pin a specific version instead. + +**Q: Branch protection blocks merge but no failing checks visible in UI** + +GitHub has two layers: **Branch Protection** (API) and **Repository Rulesets** (UI). Ruleset checks may not appear in the PR checks list. Verify via API: ```bash -#!/bin/sh -# cora-cli pre-commit hook -cora review --quiet --severity critical -if [ $? -ne 0 ]; then - echo "❌ Code review found critical issues. Commit blocked." - echo " Run 'cora review' to see details, or use 'git commit --no-verify' to skip." - exit 1 -fi +gh api repos/{owner}/{repo}/rulesets/{id} +gh api repos/{owner}/{repo}/branches/{branch}/protection ``` +**Q: Chicken-and-egg — action needs new binary but can't merge to release it** + +Temporarily remove `Cora Review` from your ruleset's required checks, merge the fix, release, then re-add. + +**Q: Private repos get 403 on Branch Protection API** + +GitHub Free doesn't support Branch Protection API for private repos. Use **Repository Rulesets** instead. + +**Q: Should cora-cli review itself in CI?** + +No — the action downloads the released binary, which doesn't include in-progress changes. Self-review always reviews the *previous* release against itself. + +
+ ## 🆚 Positioning: How Cora Compares | Feature | **cora-cli** | AI Agent IDE Tools | Standard Linters | From 6287683350989c17c5a249b8975d127946547b12 Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sun, 31 May 2026 04:49:40 +0000 Subject: [PATCH 14/23] docs: use cora.ajianaz.dev as website link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e4d1854..e4167da 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ **Cora** is a fast, opinionated CLI tool that uses LLMs to review your code changes — directly in your terminal, CI/CD pipeline, or git hooks. -🌐 [Website](https://codecora.dev) +🌐 [Website](https://cora.ajianaz.dev)
From 17f0a8c5d1844cd7bce5eb6214a3d306ca96725a Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sun, 31 May 2026 05:03:54 +0000 Subject: [PATCH 15/23] fix: OG tags use cora.ajianaz.dev instead of .com --- website/src/app.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/app.html b/website/src/app.html index 9332d24..2333f58 100644 --- a/website/src/app.html +++ b/website/src/app.html @@ -14,8 +14,8 @@ - - + + From a0e58fce5259fe88d9977c108095582f9d9559de Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Sun, 31 May 2026 17:32:00 +0700 Subject: [PATCH 16/23] feat: add OG image + fix OG tags domain (#56) Co-authored-by: CTO Hermes --- website/static/og.png | Bin 0 -> 151438 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 website/static/og.png diff --git a/website/static/og.png b/website/static/og.png new file mode 100644 index 0000000000000000000000000000000000000000..dee27b5be4e670d62494191aa9156cb1f5703074 GIT binary patch literal 151438 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

6{Ky!7p%{!j>TWT2 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

KV+)?|!g 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 literal 0 HcmV?d00001 From 4af6fbdb38ff1376d83765d42512751039469423 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Sun, 31 May 2026 17:53:14 +0700 Subject: [PATCH 17/23] =?UTF-8?q?docs:=20fix=20all=20documentation=20issue?= =?UTF-8?q?s=20=E2=80=94=20schema,=20flags,=20URLs,=20env=20vars,=20struct?= =?UTF-8?q?ure=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: CTO Hermes --- README.md | 467 +++++++++++++++++------------------------------------- 1 file changed, 148 insertions(+), 319 deletions(-) diff --git a/README.md b/README.md index e4167da..c5901b7 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,6 @@ **Cora** is a fast, opinionated CLI tool that uses LLMs to review your code changes — directly in your terminal, CI/CD pipeline, or git hooks. -🌐 [Website](https://cora.ajianaz.dev) -

--- @@ -20,7 +18,7 @@ ## ✨ Features - 🔍 **Git-Aware Scanning** — Automatically detects staged, committed, or changed files -- 🤖 **Multi-LLM Support** — Works with OpenAI, Anthropic, Google, and any OpenAI-compatible API +- 🤖 **Multi-LLM Support** — Works with OpenAI, Anthropic, Google, Ollama, and any OpenAI-compatible API - 🎨 **Beautiful Output** — Colorized, structured review output with severity levels - 🏗️ **CI/CD Ready** — Designed for GitHub Actions, GitLab CI, and any pipeline - ⚡ **Fast & Lightweight** — Native Rust binary, no runtime dependencies @@ -42,21 +40,29 @@ cargo install cora-cli Download the latest release from [GitHub Releases](https://github.com/ajianaz/cora-cli/releases): ```bash -# Linux x86_64 -curl -L https://github.com/ajianaz/cora-cli/releases/latest/download/cora-linux-x86_64.tar.gz | tar xz +# Determine your platform tag from the releases page, e.g.: +# cora-aarch64-unknown-linux-gnu-v0.1.2.tar.gz +# cora-x86_64-unknown-linux-gnu-v0.1.2.tar.gz +# cora-aarch64-apple-darwin-v0.1.2.tar.gz +# cora-x86_64-apple-darwin-v0.1.2.tar.gz +# cora-x86_64-pc-windows-msvc-v0.1.2.zip + +# Example: Linux aarch64 +VERSION=$(curl -s https://api.github.com/repos/ajianaz/cora-cli/releases/latest | grep tag_name | cut -d'"' -f4) +curl -L "https://github.com/ajianaz/cora-cli/releases/download/${VERSION}/cora-aarch64-unknown-linux-gnu-${VERSION}.tar.gz" | tar xz +sudo mv cora /usr/local/bin/ +``` -# macOS ARM (Apple Silicon) -curl -L https://github.com/ajianaz/cora-cli/releases/latest/download/cora-macos-aarch64.tar.gz | tar xz +> **Tip:** Visit the [Releases page](https://github.com/ajianaz/cora-cli/releases) to find the correct asset name for your platform. -# macOS x86_64 (Intel) -curl -L https://github.com/ajianaz/cora-cli/releases/latest/download/cora-macos-x86_64.tar.gz | tar xz +### Homebrew -# Windows x86_64 -curl -L https://github.com/ajianaz/cora-cli/releases/latest/download/cora-windows-x86_64.tar.gz | tar xz -``` +> 🚧 Homebrew tap is planned — check back soon! ### Build from Source +Requires **Rust 1.85+**. + ```bash git clone https://github.com/ajianaz/cora-cli.git cd cora-cli @@ -73,22 +79,28 @@ export OPENAI_API_KEY="sk-..." export ANTHROPIC_API_KEY="sk-ant-..." ``` -### 2. Review Staged Changes +### 2. Initialize Config (Optional) + +```bash +cora init +``` + +### 3. Review Staged Changes ```bash cora review ``` -### 3. Review a Specific Commit +### 4. Review the Last Commit ```bash cora review --commit HEAD ``` -### 4. Scan the Entire Project +### 5. Scan the Entire Project ```bash -cora scan . +cora scan ``` ## 📖 Commands @@ -101,45 +113,35 @@ Review code changes using an LLM. # Review staged files (default) cora review -# Review staged explicitly -cora review --staged - -# Review unstaged changes -cora review --unstaged - -# Review unpushed commits +# Review unpushed changes cora review --unpushed -# Review against a branch -cora review --base develop - -# Review a commit or range -cora review --commit HEAD +# Review a range of commits cora review --commit HEAD~3..HEAD -# Review from a diff file +# Review changes vs a base branch +cora review --base origin/main + +# Review a pull request diff from a file cora review --diff-file pr.diff # Use a specific model cora review --model gpt-4o -# Filter by severity -cora review --severity major - -# Quiet mode (minimal output) -cora review --quiet - -# Stream response -cora review --stream - # Output as SARIF cora review --format sarif -# Review and upload SARIF to GitHub Code Scanning -cora review --base develop --upload - # Output as JSON cora review --format json + +# Upload SARIF to GitHub Code Scanning (implies --format sarif) +cora review --upload + +# Set severity threshold +cora review --severity major + +# Quiet mode (machine-readable) +cora review --quiet ``` ### `cora scan` @@ -148,16 +150,19 @@ Scan files for code quality issues without requiring git context. ```bash # Scan current directory -cora scan . +cora scan + +# Scan a specific directory +cora scan --path src/ # Scan with focus areas -cora scan . --focus security,performance +cora scan --focus security,performance # Exclude patterns -cora scan . --exclude "tests/**" --exclude "vendor/**" +cora scan --exclude "tests/**" --exclude "examples/**" -# Incremental (only changed files) -cora scan . --incremental +# Only scan changed files (incremental) +cora scan --incremental ``` ### `cora config` @@ -168,303 +173,129 @@ Manage configuration. # Show current configuration cora config show -# Set model -cora config set model gpt-4o - -# Set provider -cora config set provider anthropic +# Set a configuration value +cora config set model claude-sonnet-4-20250514 +cora config set severity major ``` ### `cora init` -Create a `.cora.yaml` config file in your project. +Create a `.cora.yaml` config file in the current directory. ```bash cora init -cora init --force ``` -### `cora auth` +### `cora completion` -Manage API key authentication. +Generate shell completions. ```bash -cora auth login -cora auth status -cora auth remove +cora completion bash > ~/.cora-completion.bash +cora completion zsh > ~/.cora-completion.zsh +cora completion fish > ~/.cora-completion.fish ``` ### `cora hook` -Manage git hooks. +Manage pre-commit git hooks. ```bash cora hook install cora hook uninstall ``` -### `cora providers` - -List detected AI providers. - -```bash -cora providers -``` - -### `cora upload-sarif` - -Upload a SARIF file to GitHub Code Scanning. - -```bash -cora upload-sarif results.sarif -``` - -### `cora completion` - -Generate shell completions. - -```bash -cora completion bash -cora completion zsh -cora completion fish -``` - ## ⚙️ Configuration -Create a `.cora.yaml` in your project root. API keys are stored at `~/.cora/config.toml`. +Create a `.cora.yaml` in your project root or `~/.config/cora/config.yaml` globally: ```yaml # .cora.yaml -# Review settings -review: - severity: warning # minimum severity level - max_issues: 20 # max issues to report - focus: security,performance # focus areas +# Provider configuration +provider: + provider: openai # openai | anthropic | google | ollama | custom + model: gpt-4o-mini + base_url: https://api.openai.com/v1 # Override for custom/self-hosted endpoints + +# Focus areas for review (empty = all) +focus: + - security + - performance + - bugs + - best_practice -# Patterns to ignore +# Custom rules +rules: + - "no unwrap" + +# Ignore configuration ignore: - - "vendor/**" - - "*.min.js" - - "migrations/**" - -# Provider-specific model overrides -providers: - openai: - model: gpt-4o - anthropic: - model: claude-sonnet-4-20250514 + files: + - "tests/**" + - "vendor/**" + - "*.generated.*" + rules: + - "skip-rule-1" + +# Hook configuration +hook: + mode: warn # warn | block + min_severity: major # info | minor | major | critical + max_diff_size: 51200 # Max diff size in bytes (50 KB) + +# Output settings +output: + format: pretty # pretty | json | compact | sarif + color: true ``` ### Environment Variables -| Variable | Description | -|----------|-------------| -| `CORA_API_KEY` | Generic API key | -| `OPENAI_API_KEY` | OpenAI API key | -| `ANTHROPIC_API_KEY` | Anthropic API key | -| `GROQ_API_KEY` | Groq API key | -| `ZAI_API_KEY` | Z.AI API key | -| `CORA_PROVIDER` | Override provider | -| `CORA_MODEL` | Override model | -| `CORA_BASE_URL` | Override API base URL | -| `CORA_CONFIG` | Config file path | -| `CORA_FORMAT` | Output format | -| `CORA_NO_COLOR` | Disable colors | -| `GITHUB_TOKEN` | GitHub token for SARIF upload | -| `GITHUB_REPOSITORY` | GitHub repo for SARIF upload | -| `GITHUB_REF` | GitHub ref for SARIF upload | +| Variable | Description | Default | +|----------|-------------|---------| +| `OPENAI_API_KEY` | OpenAI API key | — | +| `ANTHROPIC_API_KEY` | Anthropic API key | — | +| `GOOGLE_API_KEY` | Google AI API key | — | +| `CORA_API_KEY` | API key (overrides provider-specific keys) | — | +| `CORA_MODEL` | Override model | — | +| `CORA_PROVIDER` | Override provider | — | +| `CORA_BASE_URL` | Override API base URL | — | +| `CORA_CONFIG` | Path to config file | `.cora.yaml` | +| `CORA_FORMAT` | Output format (`pretty`, `json`, `compact`, `sarif`) | `pretty` | +| `CORA_NO_COLOR` | Disable colored output | — | ## 🔗 CI/CD Integration -Cora ships with **two reusable composite actions** for GitHub Actions. Both handle downloading the binary from GitHub Releases, running the review, posting PR comments, and optionally uploading SARIF. The difference is how LLM secrets are provided. - -### Which Action to Use? - -| | **cora-review** | **cora-review-simple** | -|--|:--|:--| -| Secret source | Infisical (OIDC) | GitHub Secrets | -| Keys in GitHub? | ❌ Zero | ✅ 3 secrets | -| Setup complexity | Infisical account needed | Copy-paste | -| Best for | Teams with Infisical | Quick setup, personal repos | - ---- - -### Option A: `cora-review` — Infisical OIDC (recommended) - -Zero API keys stored in GitHub. Secrets are pulled at runtime from [Infisical](https://infisical.com/) via OIDC. - -#### Step 1: Copy the action - -```bash -mkdir -p .github/actions/cora-review -curl -sL https://raw.githubusercontent.com/ajianaz/cora-cli/develop/.github/actions/cora-review/action.yml \ - -o .github/actions/cora-review/action.yml -``` - -#### Step 2: Add required secrets - -| Secret | Description | -|--------|-------------| -| `INFISICAL_IDENTITY_ID` | Infisical OIDC identity ID | -| `CORA_API_KEY` | LLM API key (set in Infisical, not GitHub) | -| `CORA_BASE_URL` | API base URL (set in Infisical, not GitHub) | -| `CORA_MODEL` | Model ID (set in Infisical, not GitHub) | - -#### Step 3: Add the CI job - -```yaml -# .github/workflows/ci.yml -name: CI - -on: - pull_request: - branches: [develop] - -permissions: - contents: read - security-events: write - pull-requests: write - id-token: write # Required for Infisical OIDC - -jobs: - cora-review: - name: Cora Review - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - permissions: - contents: read - security-events: write - pull-requests: write - id-token: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: ./.github/actions/cora-review - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - infisical-identity-id: ${{ secrets.INFISICAL_IDENTITY_ID }} - # Optional: - # upload-sarif: 'true' # Enable GitHub Code Scanning - # cora-version: 'v0.1.2' # Pin version (default: latest) - # severity: 'warning' # Minimum severity (default: major) - # base-branch: 'origin/main' # Diff target (default: origin/develop) -``` - ---- - -### Option B: `cora-review-simple` — GitHub Secrets - -Set API keys directly as GitHub repository secrets. No external service needed. - -#### Step 1: Copy the action - -```bash -mkdir -p .github/actions/cora-review-simple -curl -sL https://raw.githubusercontent.com/ajianaz/cora-cli/develop/.github/actions/cora-review-simple/action.yml \ - -o .github/actions/cora-review-simple/action.yml -``` - -#### Step 2: Add required secrets - -Go to your repo → **Settings → Secrets and variables → Actions → New repository secret**: - -| Secret | Example Value | -|--------|---------------| -| `CORA_API_KEY` | `sk-...` (OpenAI, Anthropic, etc.) | -| `CORA_BASE_URL` | `https://api.openai.com/v1` | -| `CORA_MODEL` | `gpt-4o` | - -#### Step 3: Add the CI job +### GitHub Actions ```yaml -# .github/workflows/ci.yml -name: CI +# .github/workflows/review.yml +name: Code Review on: pull_request: - branches: [develop] - -permissions: - contents: read - security-events: write - pull-requests: write + types: [opened, synchronize, reopened] jobs: - cora-review: - name: Cora Review + review: runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - permissions: - contents: read - security-events: write - pull-requests: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: ./.github/actions/cora-review-simple - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - cora-api-key: ${{ secrets.CORA_API_KEY }} - cora-base-url: ${{ secrets.CORA_BASE_URL }} - cora-model: ${{ secrets.CORA_MODEL }} - # Optional: - # upload-sarif: 'true' # Enable GitHub Code Scanning - # cora-version: 'v0.1.2' # Pin version (default: latest) - # severity: 'warning' # Minimum severity (default: major) - # base-branch: 'origin/main' # Diff target (default: origin/develop) -``` - ---- - -### Configuration Reference (both actions) - -| Input | Default | Description | -|-------|---------|-------------| -| `github-token` | *(required)* | `secrets.GITHUB_TOKEN` for PR comments | -| `cora-version` | `latest` | Version tag or `latest` for auto-resolve via API | -| `upload-sarif` | `false` | Upload SARIF to GitHub Code Scanning | -| `severity` | `major` | Minimum severity: `info`, `minor`, `major`, `critical` | -| `base-branch` | `origin/develop` | Branch to diff against | - -### How It Works - -``` -PR opened - → Resolve cora version (GitHub API if "latest", or use pinned tag) - → Download cora binary from GitHub Releases - → Fetch LLM secrets (Infisical OIDC or GitHub Secrets) - → Run cora review --base --format sarif - → Parse SARIF → post structured PR comment (updates on re-run) - → Optionally upload SARIF to GitHub Code Scanning - → Exit 1 if error-level findings found (blocks merge) -``` - -### Block Merge on Critical Findings - -Add `Cora Review` as a required status check in your **Branch Protection** or **Repository Ruleset**. When cora finds error-level issues, the job fails and merge is blocked. - -### Pre-commit Hook - -Add cora as a git pre-commit hook for instant feedback: - -```bash -cora hook install -``` - -Or add it manually to `.git/hooks/pre-commit`: - -```bash -#!/bin/sh -cora review --quiet --severity critical -if [ $? -ne 0 ]; then - echo "❌ Code review found critical issues. Commit blocked." - exit 1 -fi + - name: Install cora-cli + run: | + VERSION=$(curl -s https://api.github.com/repos/ajianaz/cora-cli/releases/latest | grep tag_name | cut -d'"' -f4) + curl -L "https://github.com/ajianaz/cora-cli/releases/download/${VERSION}/cora-x86_64-unknown-linux-gnu-${VERSION}.tar.gz" | tar xz + sudo mv cora /usr/local/bin/ + + - name: Run code review + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: cora review --base origin/main --upload ``` ### GitLab CI @@ -477,48 +308,44 @@ code-review: before_script: - cargo install cora-cli script: - - cora review --base origin/develop --severity critical + - cora review --base origin/main --severity major variables: OPENAI_API_KEY: $CI_OPENAI_API_KEY rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" ``` -### ⚠️ Troubleshooting - -
-Common issues and solutions - -**Q: `tar: Child returned status 1` — binary download fails** +### Pre-commit Hook -Release archives contain a binary named `cora` (since v0.1.2). Older releases used the target triple name. Always use `latest` or pin to `>=v0.1.2`. +Add cora as a git pre-commit hook for instant feedback: -**Q: `latest` version doesn't resolve** +```bash +# Install as pre-commit hook +cora hook install -The action resolves `latest` via GitHub API: `GET /repos/ajianaz/cora-cli/releases/latest` → extracts `tag_name`. This requires internet access on the runner. If rate-limited, pin a specific version instead. +# Review only staged files before each commit +# This runs automatically on `git commit` -**Q: Branch protection blocks merge but no failing checks visible in UI** +# Remove the hook +cora hook uninstall +``` -GitHub has two layers: **Branch Protection** (API) and **Repository Rulesets** (UI). Ruleset checks may not appear in the PR checks list. Verify via API: +Or add it manually to `.git/hooks/pre-commit`: ```bash -gh api repos/{owner}/{repo}/rulesets/{id} -gh api repos/{owner}/{repo}/branches/{branch}/protection +#!/bin/sh +# cora-cli pre-commit hook +cora review --quiet --severity major +if [ $? -ne 0 ]; then + echo "❌ Code review found critical issues. Commit blocked." + echo " Run 'cora review' to see details, or use 'git commit --no-verify' to skip." + exit 1 +fi ``` -**Q: Chicken-and-egg — action needs new binary but can't merge to release it** - -Temporarily remove `Cora Review` from your ruleset's required checks, merge the fix, release, then re-add. - -**Q: Private repos get 403 on Branch Protection API** +### With [pre-commit](https://pre-commit.com) framework -GitHub Free doesn't support Branch Protection API for private repos. Use **Repository Rulesets** instead. - -**Q: Should cora-cli review itself in CI?** - -No — the action downloads the released binary, which doesn't include in-progress changes. Self-review always reviews the *previous* release against itself. - -
+> 🚧 Planned — the pre-commit hook repo will be available soon. For now, use `cora hook install` directly. ## 🆚 Positioning: How Cora Compares @@ -543,6 +370,8 @@ No — the action downloads the released binary, which doesn't include in-progre ## 🛠️ Development +Requires **Rust 1.85+**. + ```bash # Build cargo build From dab176f972a60e1f9ee4aa31a59d8e1b4ea25d45 Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sun, 31 May 2026 18:48:19 +0700 Subject: [PATCH 18/23] =?UTF-8?q?docs:=20add=20CHANGELOG.md=20with=20v0.1.?= =?UTF-8?q?0=E2=80=93v0.1.2=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f60b85c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,73 @@ +# Changelog + +All notable changes to cora-cli are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.2] - 2025-05-29 + +### Added + +- `cora init` — create `.cora.yaml` config file with provider/model selection +- `cora hook install|uninstall` — pre-commit hook management +- `cora config show|set` — configuration management +- CI composite action (`cora-review-simple`) for easy GitHub Actions integration +- Shell completions for bash, zsh, fish, and powershell +- `cora scan --incremental` with SHA256 content hash cache for fast incremental scanning +- `cora review --upload` for direct SARIF upload to GitHub Code Scanning +- `cora review --stream` for real-time review output +- `cora review --unpushed` for reviewing unpushed commits +- `cora review --base ` for branch comparison +- `cora review --diff-file ` for reviewing external diff files +- `cora providers` command to list available LLM providers +- `cora auth login` for interactive API key storage + +### Fixed + +- SARIF schema compliance for GitHub Code Scanning upload +- Clippy `format_in_format_args` warnings +- Replaced deprecated `serde_yaml` with `serde_yaml_ng` +- Normalized release binary naming (`cora-{arch}-{target}-v{version}.tar.gz`) + +### Changed + +- Replaced deprecated dependencies +- Removed unused dependencies +- Bumped minimum Rust version to 1.85 + +## [0.1.1] - 2025-05-27 + +### Changed + +- Replaced ASCII art banner with eye icon in README +- Updated README branding to cora-cli + +### Fixed + +- CI `cargo publish` with `--allow-dirty` for Cargo.lock mismatch on tag checkout + +## [0.1.0] - 2025-05-25 + +### Added + +- **AI Code Review** — review staged changes, commit ranges, branch diffs, and full project scans +- **BYOK** — bring your own API key (OpenAI, Anthropic, Groq, Ollama, Google) +- **5 LLM Providers** — with auto-detection from installed API keys +- **Pre-commit Hooks** — `cora hook install` for automatic review on every commit +- **SARIF Output** — `--format sarif` for GitHub Code Scanning integration +- **4 Output Formats** — pretty (colored), compact, JSON, SARIF +- **Project Config** — `.cora.yaml` per-project configuration with provider, focus, rules, ignore, and hook settings +- **Environment Variables** — `CORA_API_KEY`, `CORA_MODEL`, `CORA_PROVIDER`, `CORA_BASE_URL`, `CORA_CONFIG`, `CORA_FORMAT` +- **Severity Levels** — `info`, `minor`, `major`, `critical` with configurable thresholds +- **Focus Areas** — `security`, `performance`, `bugs`, `best_practice`, `maintainability` +- **Ignore Rules** — file patterns and rule-level exclusions +- **Cross-platform** — Linux (x86_64, ARM64), macOS (Apple Silicon), Windows (x86_64) +- **MIT License** — fully open source + +[Unreleased]: https://github.com/ajianaz/cora-cli/compare/v0.1.2...develop +[0.1.2]: https://github.com/ajianaz/cora-cli/compare/v0.1.1...v0.1.2 +[0.1.1]: https://github.com/ajianaz/cora-cli/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/ajianaz/cora-cli/releases/tag/v0.1.0 From c66aee11d7459998d607966685f5f8a23a271040 Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sun, 31 May 2026 22:06:39 +0700 Subject: [PATCH 19/23] feat: config redesign + severity comparison fix - config set now writes YAML (not unread TOML) - support project (.cora.yaml) and global (~/.cora/config.yaml) config - config set --global flag for global config - add base_url to config set keys - auth writes to separate ~/.cora/auth.toml (0o600) - config show displays resolved config with priority chain - skip_serializing_if on all Option fields (no more null in YAML) - fix severity comparison: Ord is Critical(0)= - fix should_block in engine/review.rs and commands/scan.rs - fix display filter in commands/review.rs (consistent direction) - add config set --help, --global flag - design docs: CONFIG-REDESIGN.md, FEATURE-DESIGN.md --- Cargo.lock | 2 +- docs/CONFIG-REDESIGN.md | 166 ++++++++++++++++++++++++++++++++ docs/FEATURE-DESIGN.md | 167 +++++++++++++++++++++++++++++++++ src/commands/auth.rs | 5 +- src/commands/config_cmd.rs | 95 ++++++++----------- src/commands/review.rs | 3 + src/commands/scan.rs | 4 +- src/config/loader.rs | 187 ++++++++++++++++++++++++++++++++++--- src/config/schema.rs | 16 ++++ src/engine/review.rs | 7 +- src/main.rs | 9 +- 11 files changed, 584 insertions(+), 77 deletions(-) create mode 100644 docs/CONFIG-REDESIGN.md create mode 100644 docs/FEATURE-DESIGN.md diff --git a/Cargo.lock b/Cargo.lock index 129554a..91956ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -231,7 +231,7 @@ dependencies = [ [[package]] name = "cora-cli" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "assert_cmd", diff --git a/docs/CONFIG-REDESIGN.md b/docs/CONFIG-REDESIGN.md new file mode 100644 index 0000000..0300e14 --- /dev/null +++ b/docs/CONFIG-REDESIGN.md @@ -0,0 +1,166 @@ +# Config System Redesign: Global + Project Config + +## Status: Draft — Pending Approval + +## Problem + +Current `cora config set` writes to `~/.cora/config.toml` but the config loader only reads `.cora.yaml`. Values set via `config set` are silently ignored. Additionally, there's no way to set global defaults across all projects. + +## Goal + +Support both global and per-project configuration with clear priority: + +``` +CLI flags → CORA_* env vars → .cora.yaml (project) → ~/.cora/config.yaml (global) → Defaults +``` + +## Design + +### Config Sources + +| Source | Path | Format | Scope | Priority | +|--------|------|--------|-------|----------| +| **Global** | `~/.cora/config.yaml` | YAML | All projects | 4 (lowest, after env) | +| **Project** | `.cora.yaml` (walk parent dirs) | YAML | Current project | 3 | +| **Env vars** | `CORA_PROVIDER`, `CORA_MODEL`, `CORA_BASE_URL`, `CORA_API_KEY`, etc. | — | Current shell | 2 | +| **CLI flags** | `--provider`, `--model`, `--base-url`, etc. | — | Current command | 1 (highest) | + +### Merge Strategy + +Same as current `.cora.yaml` merge — global config is loaded first (into defaults), then project config overwrites any fields present. Each field is independently resolved. + +Example: + +**Global** (`~/.cora/config.yaml`): +```yaml +provider: + provider: openai + model: gpt-4o-mini +output: + format: compact +``` + +**Project** (`.cora.yaml`): +```yaml +provider: + model: glm-5.1 + base_url: http://litellm:4000 +hook: + mode: block +``` + +**Resolved:** +```yaml +provider: + provider: openai # from global + model: glm-5.1 # project overrides + base_url: http://litellm:4000 # project +output: + format: compact # from global +hook: + mode: block # from project + min_severity: major # default +``` + +### `cora config set` Redesign + +```bash +# Default: write to project .cora.yaml (must be inside a git project) +cora config set model glm-5.1 +cora config set base_url http://litellm:4000 + +# Explicit global +cora config set --global model gpt-4o-mini +cora config set --global provider openai + +# All supported keys: +# model, provider, base_url, format, severity (maps to hook.min_severity) +``` + +Behavior: +- **No `--global`**: writes to `.cora.yaml` in current dir (creates if missing) +- **`--global`**: writes to `~/.cora/config.yaml` (creates if missing) +- **`--global --show`**: prints global config path + contents +- Works with the same TOML→YAML key mapping as before + +### Auth File (Separate Concern) + +API key storage stays separate at `~/.cora/auth.toml` (rename from `config.toml`): + +- `cora auth login` → `~/.cora/auth.toml` (contains only `api_key`) +- `cora auth status` → reads from `~/.cora/auth.toml` +- `cora auth remove` → deletes `~/.cora/auth.toml` + +This separation means: +- Config = `~/.cora/config.yaml` (global settings, no secrets) +- Auth = `~/.cora/auth.toml` (API key only, permissions 0o600) + +### `cora config show` Enhancement + +```bash +$ cora config show + +╔══════════════════════════════════════════╗ +║ Current Configuration ║ +╚══════════════════════════════════════════╝ + + Config sources (priority ↓): + CLI flags — + Env vars — + Project config .cora.yaml ✅ + Global config ~/.cora/config.yaml ✅ + +provider: + provider: openai (global) + model: glm-5.1 (project) + base_url: http://litellm:4000 (project) + +focus: + security, performance, bugs (default) + +hook: + mode: block (project) + min_severity: major (default) + max_diff_size: 51200 (default) + +output: + format: compact (global) + color: true (default) +``` + +Each value shows its source — makes debugging config priority transparent. + +## Files to Change + +| File | Change | +|------|--------| +| `src/config/loader.rs` | Add `load_global_config()` → reads `~/.cora/config.yaml`. Update `load_config()` to merge: global → project → CLI. Rename `AUTH_FILENAME` to `auth.toml`. | +| `src/commands/config_cmd.rs` | Rewrite `execute_config_set()`: write YAML instead of TOML. Add `--global` flag support. Add `base_url` key. Keep `cora config show` but add source annotations. | +| `src/main.rs` | Add `--global` flag to `config set` subcommand. Add `config set base_url `. | +| `src/config/schema.rs` | No changes needed — `CoraFile` schema already supports all fields. Global uses same `CoraFile` struct. | + +## Migration + +- **No breaking change** — existing `.cora.yaml` files work as-is +- `~/.cora/config.toml` (if exists) → auto-migrate to `~/.cora/config.yaml` on first run (read TOML, write YAML, delete TOML). For auth keys: rename `config.toml` → `auth.toml` +- `cora auth login` switches from writing `config.toml` to `auth.toml` + +## Effort Estimate + +| Item | LOC (est.) | Effort | +|------|-----------|--------| +| Global config loader | ~40 | 30 min | +| `config set` rewrite (YAML + `--global` + `base_url`) | ~80 | 1h | +| `config show` source annotations | ~40 | 30 min | +| Auth file rename (`config.toml` → `auth.toml`) | ~20 | 15 min | +| Migration logic (TOML → YAML) | ~30 | 30 min | +| Tests | ~60 | 45 min | +| **Total** | ~270 | **~3.5h** | + +## Risks + +| Risk | Mitigation | +|------|-----------| +| YAML serialization changes formatting | Use `serde_yaml_ng` with consistent output | +| Migration edge case (partial TOML) | Log warning, skip gracefully, user can manually edit | +| Two config sources confuse users | `config show` annotates source for each value | diff --git a/docs/FEATURE-DESIGN.md b/docs/FEATURE-DESIGN.md new file mode 100644 index 0000000..3bab445 --- /dev/null +++ b/docs/FEATURE-DESIGN.md @@ -0,0 +1,167 @@ +# Feature Design: `cora init --interactive` & `cora doctor` + +## Status: Draft — Pending Review + +## Overview + +Two new features to improve developer experience: + +1. **`cora init --interactive`** (or `cora init -i`) — guided, step-by-step config creation with prompts +2. **`cora doctor`** — health check command that validates API connectivity, config, and environment + +--- + +## 1. `cora init --interactive` + +### Current Behavior + +`cora init` generates a static `.cora.yaml` template with hardcoded defaults (openai, gpt-4o-mini). User must manually edit the file to customize. + +CLI flags (`--provider`, `--model`, `--base-url`, `--api-key`) allow one-shot config but are non-interactive. + +### Proposed Behavior + +With `--interactive` (or `-i`), `cora init` becomes a guided wizard: + +``` +$ cora init -i + +🚀 Welcome to Cora! Let's set up your project. + +? Select a provider: + > OpenAI + Anthropic + Groq + Ollama (local) + Google + Custom (OpenAI-compatible) + +? Model name (default: gpt-4o-mini): glm-5.1 + +? Base URL (default: https://api.openai.com/v1): https://litellm:4000 + +? API key: **** (or leave blank to use CORA_API_KEY env var) + +? Review focus areas (space to toggle, enter to confirm): + [✓] security + [✓] performance + [ ] bugs + [ ] best_practice + [ ] style + [ ] maintainability + +? Pre-commit hook mode: + > warn (print findings but allow commit) + block (fail commit on findings above threshold) + +✅ Created .cora.yaml with your settings. +✅ Pre-commit hook installed. + +Next steps: + cora review # Review staged changes + cora scan # Scan the full project +``` + +### Implementation Approach + +- **No new dependencies** — use `dialoguer` (already in Cargo.toml? check) or `inquire`. If neither exists, use minimal stdin/stdout with `rustyline` or basic `io::stdin()`. +- **Fallback** — if `--interactive` is not passed, current behavior is preserved (static template). +- **Flags take precedence** — `cora init -i --provider openai --model gpt-4o` skips the provider/model prompts. + +### Config Schema Changes + +None — output is the same `.cora.yaml` format, just populated interactively. + +### Open Questions + +1. **Which prompt library?** — `dialoguer` (lightweight), `inquire` (feature-rich), or raw stdin? +2. **Should it test API connectivity** after setup? (e.g., send a minimal request to validate key) +3. **Should it detect existing API keys** from env vars and pre-select the provider? + +--- + +## 2. `cora doctor` + +### Purpose + +Diagnostic command that checks the entire cora setup — config, API connectivity, git state, environment. + +### Proposed Output + +``` +$ cora doctor + +🔍 Cora Doctor — System Health Check + +Config + ✅ .cora.yaml found at /project/.cora.yaml + ✅ Config schema valid + +API Connectivity + ✅ Provider: openai (gpt-4o-mini) + ✅ API key detected (CORA_API_KEY) + ✅ Connection OK — 142ms latency + ✅ Model accessible + +Git + ✅ Inside a git repository + ✅ Upstream detected: origin/main + ⚠️ 3 uncommitted changes detected + +Environment + ✅ Rust version: 1.85.0 + ✅ cora version: 0.1.2 + ✅ Shell completions available: bash, zsh, fish, powershell + +Pre-commit Hook + ✅ Installed at .git/hooks/pre-commit + +Result: 7 passed, 1 warning — your setup is ready! 🚀 +``` + +### Checks (ordered) + +| # | Check | Severity | What it does | +|---|-------|----------|-------------| +| 1 | Config file found | error | Looks for `.cora.yaml` or `~/.config/cora/config.yaml` | +| 2 | Config schema valid | error | Parses YAML, validates against schema | +| 3 | API key available | error | Checks `CORA_API_KEY` or provider-specific env vars | +| 4 | Provider configured | error | Has a valid provider name | +| 5 | API connectivity | error | Sends a minimal request (e.g., list models) to validate key + endpoint | +| 6 | API latency | info | Measures round-trip time to API endpoint | +| 7 | Model accessible | warn | Validates the configured model exists at the endpoint | +| 8 | Git repository | warn | Checks if inside a git repo (not required for `cora scan --path`) | +| 9 | Pre-commit hook | info | Checks if `.git/hooks/pre-commit` has cora | +| 10 | Cora version | info | Shows current version, checks for updates | + +### Implementation Approach + +- **New command** — `src/commands/doctor.rs` +- **New subcommand** in `main.rs` CLI definition +- **Check result type** — enum `{ Ok, Warning, Error }` with message +- **Optional `--json` flag** — machine-readable output for CI +- **Exit code** — 0 if all pass, 1 if any error, 2 if connection failed + +### Open Questions + +1. **Check for updates** — should `cora doctor` hit GitHub API to check latest version? (adds network dependency) +2. **Model validation** — how to check if a model exists without expensive API calls? (Some providers support `GET /models`) +3. **Should it fix things?** — e.g., `cora doctor --fix` to auto-install hook, fix config schema? + +--- + +## Effort Estimate + +| Feature | LOC (est.) | New Files | Effort | Risk | +|---------|-----------|-----------|--------|------| +| `cora init -i` | ~150-250 | 1 (modify `init.rs`) | 2-3h | Low | +| `cora doctor` | ~200-300 | 2 (`doctor.rs`, types) | 3-4h | Low | + +## Dependencies + +- `dialoguer` or `inquire` crate (for interactive prompts) — whichever is lighter +- No new deps for `cora doctor` — uses existing HTTP client + +## Timeline + +Both features can go into **v0.2.0** (next minor release) or as individual patches. diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 8fda6e0..3654822 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -38,10 +38,7 @@ pub fn execute_auth_login() -> Result<()> { } loader::save_api_key(&key)?; - println!( - "{} API key saved to ~/.cora/config.toml", - "✅".green().bold() - ); + println!("{} API key saved to ~/.cora/auth.toml", "✅".green().bold()); println!( "{}", " This file is local to your machine and not committed to git.".dimmed() diff --git a/src/commands/config_cmd.rs b/src/commands/config_cmd.rs index e02c116..8df3755 100644 --- a/src/commands/config_cmd.rs +++ b/src/commands/config_cmd.rs @@ -4,6 +4,7 @@ use anyhow::{Context, Result}; use colored::Colorize; use crate::config::loader; +use crate::config::schema::{CoraFile, HookSection, OutputSection, ProviderSection}; /// Execute `cora config show` — print the current resolved configuration. pub fn execute_config_show() -> Result<()> { @@ -95,94 +96,78 @@ pub fn execute_config_show() -> Result<()> { Ok(()) } -/// Execute `cora config set ` — write a key-value pair to ~/.cora/config.toml. +/// Execute `cora config set [--global] ` — write a key-value pair +/// to a YAML config file. /// -/// Supported keys: model, provider, format, severity. -pub fn execute_config_set(key: &str, value: &str) -> Result<()> { +/// Supported keys: model, provider, base_url, format, severity +pub fn execute_config_set(key: &str, value: &str, global: bool) -> Result<()> { // Validate the key match key { - "model" | "provider" | "format" | "severity" => {} + "model" | "provider" | "base_url" | "format" | "severity" => {} _ => { anyhow::bail!( - "unsupported key: {key}\nSupported keys: model, provider, format, severity" + "unsupported key: {key}\nSupported keys: model, provider, base_url, format, severity" ); } } - let dir = cora_config_dir()?; - std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?; - - let path = dir.join("config.toml"); + // Determine target path + let path = if global { + let dir = loader::cora_dir()?; + std::fs::create_dir_all(&dir) + .with_context(|| format!("failed to create {}", dir.display()))?; + dir.join("config.yaml") + } else { + PathBuf::from(".cora.yaml") + }; - // Load existing config.toml content if it exists - let mut table = if path.is_file() { + // Load existing file or start fresh + let mut cora = if path.is_file() { let content = std::fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; - content.parse::().unwrap_or_default() + CoraFile::from_str(&content).unwrap_or_default() } else { - toml::Table::new() + CoraFile::default() }; - // Map the key to the appropriate TOML structure + // Apply the key update match key { "model" => { - let provider = table - .entry("provider") - .or_insert_with(|| toml::Value::Table(toml::Table::new())); - if let toml::Value::Table(p) = provider { - p.insert("model".to_string(), toml::Value::String(value.to_string())); - } + let provider = cora.provider.get_or_insert_with(ProviderSection::default); + provider.model = Some(value.to_string()); } "provider" => { - let provider = table - .entry("provider") - .or_insert_with(|| toml::Value::Table(toml::Table::new())); - if let toml::Value::Table(p) = provider { - p.insert( - "provider".to_string(), - toml::Value::String(value.to_string()), - ); - } + let provider = cora.provider.get_or_insert_with(ProviderSection::default); + provider.provider = Some(value.to_string()); + } + "base_url" => { + let provider = cora.provider.get_or_insert_with(ProviderSection::default); + provider.base_url = Some(value.to_string()); } "format" => { - let output = table - .entry("output") - .or_insert_with(|| toml::Value::Table(toml::Table::new())); - if let toml::Value::Table(o) = output { - o.insert("format".to_string(), toml::Value::String(value.to_string())); - } + let output = cora.output.get_or_insert_with(OutputSection::default); + output.format = Some(value.to_string()); } "severity" => { - let hook = table - .entry("hook") - .or_insert_with(|| toml::Value::Table(toml::Table::new())); - if let toml::Value::Table(h) = hook { - h.insert( - "min_severity".to_string(), - toml::Value::String(value.to_string()), - ); - } + let hook = cora.hook.get_or_insert_with(HookSection::default); + hook.min_severity = Some(value.to_string()); } _ => unreachable!(), } - let content = table.to_string(); - std::fs::write(&path, content) - .with_context(|| format!("failed to write {}", path.display()))?; + // Write back as YAML + let yaml = serde_yaml_ng::to_string(&cora).context("failed to serialize config to YAML")?; + std::fs::write(&path, &yaml).with_context(|| format!("failed to write {}", path.display()))?; + let scope = if global { "global" } else { "project" }; println!( - "{} Set {} = {} in {}", + "{} Set {} = {} in {} ({})", "✓".green().bold(), key.bold(), value.green(), - path.display() + path.display(), + scope.dimmed() ); Ok(()) } - -/// Get the cora config directory: ~/.cora/ -fn cora_config_dir() -> Result { - let home = dirs::home_dir().context("cannot determine home directory")?; - Ok(home.join(".cora")) -} diff --git a/src/commands/review.rs b/src/commands/review.rs index 5de7b33..0484b85 100644 --- a/src/commands/review.rs +++ b/src/commands/review.rs @@ -100,6 +100,9 @@ pub async fn execute_review( }; let mut filtered_response = response.clone(); + // Keep issues at or above min_severity (Critical=worst, Info=lowest in Ord but + // Ord order is Critical(0) < Major(1) < Minor(2) < Info(3), so we invert: keep + // where severity Ord value <= min_severity Ord value). filtered_response .issues .retain(|i| i.severity <= min_severity); diff --git a/src/commands/scan.rs b/src/commands/scan.rs index 1df67d9..5aa4f74 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -130,7 +130,9 @@ pub async fn execute_scan( // 5. Build response and format let issue_count = all_issues.len(); let min_severity = config.hook.min_severity_level(); - let should_block = all_issues.iter().any(|i| i.severity >= min_severity); + // Ord order is Critical(0) < Major(1) < Minor(2) < Info(3), so "at or above + // min_severity" means Ord value <= min_severity. + let should_block = all_issues.iter().any(|i| i.severity <= min_severity); let response = crate::engine::ScanResponse { issues: all_issues, diff --git a/src/config/loader.rs b/src/config/loader.rs index 007dc6b..0ad113d 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -3,15 +3,21 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use tracing::debug; -use crate::config::providers::{PRESETS, detected_presets}; +use crate::config::providers::{detected_presets, PRESETS}; use crate::config::schema::{Config, CoraFile}; use crate::engine::LLMConfig; -/// The name of the config file we search for. +/// The name of the config file we search for in projects. const CONFIG_FILENAME: &str = ".cora.yaml"; +/// Name of the global config file. +const GLOBAL_CONFIG_FILENAME: &str = "config.yaml"; + /// Name of the secret config file for API keys (never committed). -const AUTH_FILENAME: &str = "config.toml"; +const AUTH_FILENAME: &str = "auth.toml"; + +/// Name of the old auth file (for migration). +const OLD_AUTH_FILENAME: &str = "config.toml"; /// Locate the `.cora.yaml` config by walking parent directories from `start`. /// Returns the path and parsed content, or `None` if not found. @@ -39,7 +45,158 @@ pub fn find_cora_file(start: &Path) -> Result> { } } -/// Load the full resolved config: defaults ← .cora.yaml ← CLI overrides. +/// Load the global config from `~/.cora/config.yaml`. +/// Returns `None` if the file doesn't exist or can't be parsed. +fn load_global_config() -> Result> { + let dir = cora_dir()?; + let path = dir.join(GLOBAL_CONFIG_FILENAME); + if !path.is_file() { + return Ok(None); + } + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let cora = CoraFile::from_str(&content)?; + debug!(path = %path.display(), "loaded global config"); + Ok(Some(cora)) +} + +/// Migrate old `~/.cora/config.toml` to the new format if it exists. +/// - Non-secret keys → `~/.cora/config.yaml` +/// - api_key → `~/.cora/auth.toml` +/// - Delete the old file after successful migration. +fn migrate_old_config() { + let dir = match cora_dir() { + Ok(d) => d, + Err(_) => return, + }; + let old_path = dir.join(OLD_AUTH_FILENAME); + if !old_path.is_file() { + return; + } + + let content = match std::fs::read_to_string(&old_path) { + Ok(c) => c, + Err(e) => { + debug!("failed to read old config for migration: {}", e); + return; + } + }; + + let table: toml::Table = match content.parse::() { + Ok(t) => t, + Err(e) => { + debug!( + "old config.toml is not valid TOML, skipping migration: {}", + e + ); + return; + } + }; + + // Check if there's an api_key + let api_key = table + .get("auth") + .and_then(|a| a.get("api_key")) + .and_then(|k| k.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + table + .get("api_key") + .and_then(|k| k.as_str()) + .map(|s| s.to_string()) + }); + + // Extract non-secret config fields into a CoraFile + let mut cora = CoraFile::default(); + + if let Some(provider) = table.get("provider").and_then(|v| v.as_table()) { + let mut ps = crate::config::schema::ProviderSection::default(); + if let Some(v) = provider.get("provider").and_then(|v| v.as_str()) { + ps.provider = Some(v.to_string()); + } + if let Some(v) = provider.get("model").and_then(|v| v.as_str()) { + ps.model = Some(v.to_string()); + } + if let Some(v) = provider.get("base_url").and_then(|v| v.as_str()) { + ps.base_url = Some(v.to_string()); + } + // Only set if we found something + if ps.provider.is_some() || ps.model.is_some() || ps.base_url.is_some() { + cora.provider = Some(ps); + } + } + + if let Some(output) = table.get("output").and_then(|v| v.as_table()) { + let mut os = crate::config::schema::OutputSection::default(); + if let Some(v) = output.get("format").and_then(|v| v.as_str()) { + os.format = Some(v.to_string()); + } + if let Some(v) = output.get("color").and_then(|v| v.as_bool()) { + os.color = Some(v); + } + if os.format.is_some() || os.color.is_some() { + cora.output = Some(os); + } + } + + if let Some(hook) = table.get("hook").and_then(|v| v.as_table()) { + let mut hs = crate::config::schema::HookSection::default(); + if let Some(v) = hook.get("mode").and_then(|v| v.as_str()) { + hs.mode = Some(v.to_string()); + } + if let Some(v) = hook.get("min_severity").and_then(|v| v.as_str()) { + hs.min_severity = Some(v.to_string()); + } + if let Some(v) = hook.get("max_diff_size").and_then(|v| v.as_integer()) { + hs.max_diff_size = Some(v as usize); + } + if hs.mode.is_some() || hs.min_severity.is_some() || hs.max_diff_size.is_some() { + cora.hook = Some(hs); + } + } + + // Write api_key to auth.toml if present + if let Some(key) = api_key { + let auth_path = dir.join(AUTH_FILENAME); + if let Err(e) = std::fs::write(&auth_path, format!("api_key = \"{key}\"\n")) { + debug!("failed to write migrated auth.toml: {}", e); + return; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + let _ = std::fs::set_permissions(&auth_path, perms); + } + } + + // Write config to config.yaml if there are non-secret fields + let has_config = cora.provider.is_some() + || cora.output.is_some() + || cora.hook.is_some() + || cora.focus.is_some() + || cora.rules.is_some() + || cora.ignore.is_some(); + + if has_config { + let config_path = dir.join(GLOBAL_CONFIG_FILENAME); + if let Ok(yaml) = serde_yaml_ng::to_string(&cora) { + if let Err(e) = std::fs::write(&config_path, yaml) { + debug!("failed to write migrated config.yaml: {}", e); + return; + } + } + } + + // Delete old file + if let Err(e) = std::fs::remove_file(&old_path) { + debug!("failed to remove old config.toml after migration: {}", e); + } else { + debug!("migrated ~/.cora/config.toml to new format"); + } +} + +/// Load the full resolved config: defaults ← global config ← .cora.yaml ← CLI overrides. /// /// `cli_provider`, `cli_model`, `cli_api_key`, and `cli_format` are `None` /// when the user did not pass the corresponding flag. @@ -54,7 +211,15 @@ pub fn load_config( ) -> Result { let mut config = Config::default(); - // 1. Load .cora.yaml + // Run migration silently on first access + migrate_old_config(); + + // 1. Load global config (~/.cora/config.yaml) + if let Some(cora) = load_global_config()? { + cora.merge_into(&mut config); + } + + // 2. Load project config (.cora.yaml) if let Some(path) = cli_config_path { let path = Path::new(path); let content = std::fs::read_to_string(path) @@ -69,7 +234,7 @@ pub fn load_config( debug!("no .cora.yaml found, using defaults"); } - // 2. CLI overrides + // 3. CLI overrides if let Some(p) = cli_provider { config.provider.provider = p.to_string(); } @@ -90,7 +255,7 @@ pub fn load_config( } /// Build an `LLMConfig` from the resolved `Config`, fetching the API key -/// from: CLI flag → env CORA_API_KEY → ~/.config/cora/config.toml. +/// from: CLI flag → env CORA_API_KEY → ~/.cora/auth.toml. /// /// If none of those are set, auto-detect from known provider env vars (OPENAI_API_KEY, etc.) /// and configure provider/model/base_url from the matching preset. @@ -166,12 +331,12 @@ pub fn build_llm_config(config: &Config, cli_api_key: Option<&str>) -> Result Result { +pub fn cora_dir() -> Result { let home = dirs::home_dir().context("cannot determine home directory")?; Ok(home.join(".cora")) } -/// Read the stored API key from ~/.cora/config.toml. +/// Read the stored API key from ~/.cora/auth.toml. pub fn load_api_key_from_auth_file() -> Result> { let dir = cora_dir()?; let path = dir.join(AUTH_FILENAME); @@ -202,7 +367,7 @@ pub fn load_api_key_from_auth_file() -> Result> { Ok(key) } -/// Save an API key to ~/.cora/config.toml. +/// Save an API key to ~/.cora/auth.toml. pub fn save_api_key(key: &str) -> Result<()> { let dir = cora_dir()?; std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?; @@ -226,7 +391,7 @@ pub fn save_api_key(key: &str) -> Result<()> { Ok(()) } -/// Remove the stored API key from ~/.cora/config.toml. +/// Remove the stored API key from ~/.cora/auth.toml. pub fn remove_api_key() -> Result<()> { let dir = cora_dir()?; let path = dir.join(AUTH_FILENAME); diff --git a/src/config/schema.rs b/src/config/schema.rs index cc0cd8a..a3e2db6 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -97,37 +97,53 @@ impl HookConfig { /// Serde-compatible schema for the `.cora.yaml` configuration file. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CoraFile { + #[serde(skip_serializing_if = "Option::is_none")] pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub focus: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub rules: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub ignore: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub hook: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub output: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProviderSection { + #[serde(skip_serializing_if = "Option::is_none")] pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub base_url: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct IgnoreSection { + #[serde(skip_serializing_if = "Option::is_none")] pub files: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub rules: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct HookSection { + #[serde(skip_serializing_if = "Option::is_none")] pub mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub min_severity: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub max_diff_size: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct OutputSection { + #[serde(skip_serializing_if = "Option::is_none")] pub format: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub color: Option, } diff --git a/src/engine/review.rs b/src/engine/review.rs index 97b6b98..9b6368e 100644 --- a/src/engine/review.rs +++ b/src/engine/review.rs @@ -64,10 +64,12 @@ async fn review_diff_inner( // Calculate should_block based on min_severity let min_severity = config.hook.min_severity_level(); + // Ord order is Critical(0) < Major(1) < Minor(2) < Info(3), so "at or above + // min_severity" means Ord value <= min_severity. response.should_block = response .issues .iter() - .any(|issue| issue.severity >= min_severity); + .any(|issue| issue.severity <= min_severity); debug!( issues = response.issues.len(), @@ -115,7 +117,8 @@ pub async fn scan_project( // Calculate should_block let min_severity = config.hook.min_severity_level(); - let should_block = issues.iter().any(|issue| issue.severity >= min_severity); + // Ord order: Critical(0) < Major(1) < Minor(2) < Info(3) + let should_block = issues.iter().any(|issue| issue.severity <= min_severity); let default_summary = format!( "Scanned {} files, found {} issues.", diff --git a/src/main.rs b/src/main.rs index 6001370..c21b699 100644 --- a/src/main.rs +++ b/src/main.rs @@ -235,12 +235,15 @@ enum AuthAction { enum ConfigAction { /// Show the current resolved configuration Show, - /// Set a configuration value (keys: model, provider, format, severity) + /// Set a configuration value (keys: model, provider, base_url, format, severity) Set { /// Configuration key to set key: String, /// Value to assign value: String, + /// Write to global config (~/.cora/config.yaml) instead of project .cora.yaml + #[clap(long)] + global: bool, }, } @@ -374,8 +377,8 @@ async fn main() -> Result<()> { config_cmd::execute_config_show()?; 0 } - ConfigAction::Set { key, value } => { - config_cmd::execute_config_set(&key, &value)?; + ConfigAction::Set { key, value, global } => { + config_cmd::execute_config_set(&key, &value, global)?; 0 } }, From 6a5baff2151594fbb46f8d59b48702536bde03a5 Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sun, 31 May 2026 22:26:37 +0700 Subject: [PATCH 20/23] style: cargo fmt --- src/config/loader.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/loader.rs b/src/config/loader.rs index 0ad113d..efcfca0 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use tracing::debug; -use crate::config::providers::{detected_presets, PRESETS}; +use crate::config::providers::{PRESETS, detected_presets}; use crate::config::schema::{Config, CoraFile}; use crate::engine::LLMConfig; From fe1fff36da78e65384dd9d9ab32602dde9d47cf8 Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sun, 31 May 2026 22:37:36 +0700 Subject: [PATCH 21/23] docs: update README, CONTRIBUTING, CHANGELOG for config redesign + severity fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix global config path (~/.config/cora → ~/.cora/config.yaml) - Document --global flag, base_url key, config priority chain - Add Authentication section with auth.toml details - Update CI/CD section with composite action example - Fix CONTRIBUTING: Rust version, project structure, test example - Add Unreleased changelog entry for all changes --- CHANGELOG.md | 19 +++++++++++++ CONTRIBUTING.md | 43 +++++++++++++++++++++--------- README.md | 71 +++++++++++++++++++++++++++++++++++-------------- 3 files changed, 100 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f60b85c..e1aabf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `cora config set --global` — write config to `~/.cora/config.yaml` instead of project `.cora.yaml` +- `cora config set base_url` — set base URL via CLI (previously only in YAML) +- Global config support (`~/.cora/config.yaml`) with priority chain: CLI flags → env vars → project → global → defaults +- Auto-migration from old `~/.cora/config.toml` to new YAML + `auth.toml` split + +### Changed + +- `cora config set` now writes YAML instead of TOML (compatible with config loader) +- API key storage moved from `~/.cora/config.toml` to `~/.cora/auth.toml` (0600 permissions) +- YAML serialization uses `skip_serializing_if` — no more `null` values in output + +### Fixed + +- **Severity comparison inverted** — `Critical` issues no longer silently pass `should_block` check (Ord ordering bug) +- Hook `mode: block` no longer exits with code 2 when "No issues found" (severity filter mismatch) +- Consistent severity logic across review, scan, and block mode paths + ## [0.1.2] - 2025-05-29 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd6274c..fe48c04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ Be respectful, constructive, and inclusive. We follow the [Rust Code of Conduct] ### Prerequisites -- **Rust** 1.75+ (stable toolchain recommended) +- **Rust** 1.85+ (stable toolchain recommended) - **Git** for version control - An **LLM API key** (OpenAI, Anthropic, etc.) for testing review features @@ -47,14 +47,30 @@ cargo clippy -- -D warnings ``` cora-cli/ ├── src/ -│ ├── main.rs # CLI entry point -│ ├── cli.rs # Argument parsing (clap) -│ ├── config.rs # Configuration management -│ ├── scanner.rs # File scanning and diff generation -│ ├── reviewer.rs # LLM integration for code review -│ ├── formatter.rs # Output formatting -│ └── ci.rs # CI/CD integration helpers -├── tests/ # Integration tests +│ ├── main.rs # CLI entry point + clap args +│ ├── cli.rs # Argument parsing (clap) +│ ├── commands/ # CLI subcommands +│ │ ├── review.rs # cora review +│ │ ├── scan.rs # cora scan +│ │ ├── config_cmd.rs # cora config show/set +│ │ ├── auth.rs # cora auth login/logout +│ │ └── init.rs # cora init +│ ├── config/ +│ │ ├── loader.rs # Config resolution chain (global → project → env → CLI) +│ │ ├── schema.rs # YAML config structs + merge logic +│ │ └── providers.rs # Provider presets (OpenAI, Anthropic, etc.) +│ ├── engine/ +│ │ ├── review.rs # LLM review + SARIF generation +│ │ ├── scanner.rs # File scanning and diff generation +│ │ ├── types.rs # Issue, Severity, ScanResponse types +│ │ └── llm.rs # LLM API abstraction +│ └── formatter/ +│ └── mod.rs # Output formatting (pretty, compact, json, sarif) +├── .github/ +│ ├── workflows/ # CI, release, deploy workflows +│ └── actions/ +│ └── cora-review/ # Composite action for GitHub Actions +├── tests/ # Integration tests ├── Cargo.toml └── README.md ``` @@ -68,7 +84,7 @@ cora-cli/ ``` 3. **Make your changes** and commit with meaningful messages: ```bash - git commit -s -m "feat: add support for SARIF output format" + git commit -m "feat: add support for SARIF output format" ``` 4. **Push** to your fork and open a **Pull Request** @@ -111,9 +127,10 @@ mod tests { use super::*; #[test] - fn test_scan_single_file() { - let result = scan_files(vec!["src/main.rs".into()]); - assert!(!result.is_empty()); + fn test_severity_from_str() { + use crate::engine::types::Severity; + assert_eq!(Severity::from_str_lossy("critical"), Severity::Critical); + assert_eq!(Severity::from_str_lossy("info"), Severity::Info); } } ``` diff --git a/README.md b/README.md index c5901b7..454eaad 100644 --- a/README.md +++ b/README.md @@ -167,17 +167,26 @@ cora scan --incremental ### `cora config` -Manage configuration. +Manage configuration. Supports both project-level (`.cora.yaml`) and global (`~/.cora/config.yaml`) config. ```bash -# Show current configuration +# Show current resolved configuration cora config show -# Set a configuration value +# Set a project-level value (writes to .cora.yaml) cora config set model claude-sonnet-4-20250514 +cora config set base_url https://api.openai.com/v1 cora config set severity major + +# Set a global value (writes to ~/.cora/config.yaml) +cora config set --global model gpt-4o-mini +cora config set --global provider anthropic + +# Supported keys: model, provider, base_url, format, severity ``` +**Priority**: CLI flags → env vars → `.cora.yaml` (project) → `~/.cora/config.yaml` (global) → defaults + ### `cora init` Create a `.cora.yaml` config file in the current directory. @@ -207,7 +216,13 @@ cora hook uninstall ## ⚙️ Configuration -Create a `.cora.yaml` in your project root or `~/.config/cora/config.yaml` globally: +Cora reads configuration from multiple sources in priority order: + +``` +CLI flags → CORA_* env vars → .cora.yaml (project) → ~/.cora/config.yaml (global) → defaults +``` + +Create a `.cora.yaml` in your project root, or use `~/.cora/config.yaml` for global settings. Project config always overrides global. ```yaml # .cora.yaml @@ -265,37 +280,53 @@ output: | `CORA_FORMAT` | Output format (`pretty`, `json`, `compact`, `sarif`) | `pretty` | | `CORA_NO_COLOR` | Disable colored output | — | +### Authentication + +API keys can be provided via environment variable (`CORA_API_KEY`), provider-specific env vars (`OPENAI_API_KEY`, etc.), or stored in `~/.cora/auth.toml` (auto-created by `cora auth login`, permission `0600`). + +```bash +# Interactive login (stores key in ~/.cora/auth.toml) +cora auth login + +# Or set via environment variable +export CORA_API_KEY=sk-... +``` + ## 🔗 CI/CD Integration ### GitHub Actions -```yaml -# .github/workflows/review.yml -name: Code Review +Using the official [cora-review composite action](.github/actions/cora-review): +```yaml +name: CI on: pull_request: - types: [opened, synchronize, reopened] + branches: [develop] jobs: - review: + cora-review: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + - uses: ./.github/actions/cora-review + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + infisical-identity-id: ${{ secrets.INFISICAL_IDENTITY_ID }} + severity: major + upload-sarif: 'true' +``` - - name: Install cora-cli - run: | - VERSION=$(curl -s https://api.github.com/repos/ajianaz/cora-cli/releases/latest | grep tag_name | cut -d'"' -f4) - curl -L "https://github.com/ajianaz/cora-cli/releases/download/${VERSION}/cora-x86_64-unknown-linux-gnu-${VERSION}.tar.gz" | tar xz - sudo mv cora /usr/local/bin/ - - - name: Run code review - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: cora review --base origin/main --upload +Or install manually: + +```yaml +# Manual install in CI +- name: Install cora-cli + run: | + curl -fsSL https://github.com/ajianaz/cora-cli/releases/latest/download/cora-x86_64-unknown-linux-gnu.tar.gz | tar xz + sudo mv cora /usr/local/bin/ ``` ### GitLab CI From b64d4014a05060d2a167396aace0111861a74216 Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sun, 31 May 2026 22:42:03 +0700 Subject: [PATCH 22/23] fix: address cora self-review findings (#60) - Fix API key TOML injection: use toml::Table instead of format!() - Add migration marker (.migrated) to prevent repeated filesystem I/O - Restrict global config file permissions to 0600 - Guard project config set: require git repo (no orphan files) --- src/commands/config_cmd.rs | 22 +++++++++++++++++++++- src/config/loader.rs | 20 +++++++++++++++++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/commands/config_cmd.rs b/src/commands/config_cmd.rs index 8df3755..b16a0b6 100644 --- a/src/commands/config_cmd.rs +++ b/src/commands/config_cmd.rs @@ -118,7 +118,20 @@ pub fn execute_config_set(key: &str, value: &str, global: bool) -> Result<()> { .with_context(|| format!("failed to create {}", dir.display()))?; dir.join("config.yaml") } else { - PathBuf::from(".cora.yaml") + // Warn if not inside a git project (could create orphan file) + if std::process::Command::new("git") + .args(["rev-parse", "--git-dir"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_or(false, |s| s.success()) + { + PathBuf::from(".cora.yaml") + } else { + anyhow::bail!( + "Not in a git project. Use --global to set config in ~/.cora/config.yaml, or cd into a git repo first." + ); + } }; // Load existing file or start fresh @@ -159,6 +172,13 @@ pub fn execute_config_set(key: &str, value: &str, global: bool) -> Result<()> { let yaml = serde_yaml_ng::to_string(&cora).context("failed to serialize config to YAML")?; std::fs::write(&path, &yaml).with_context(|| format!("failed to write {}", path.display()))?; + // Restrict permissions for global config (defense-in-depth) + #[cfg(unix)] + if global { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + let scope = if global { "global" } else { "project" }; println!( "{} Set {} = {} in {} ({})", diff --git a/src/config/loader.rs b/src/config/loader.rs index efcfca0..52eb83c 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -19,6 +19,9 @@ const AUTH_FILENAME: &str = "auth.toml"; /// Name of the old auth file (for migration). const OLD_AUTH_FILENAME: &str = "config.toml"; +/// Marker file created after successful migration. +const MIGRATION_MARKER: &str = ".migrated"; + /// Locate the `.cora.yaml` config by walking parent directories from `start`. /// Returns the path and parsed content, or `None` if not found. pub fn find_cora_file(start: &Path) -> Result> { @@ -64,6 +67,7 @@ fn load_global_config() -> Result> { /// - Non-secret keys → `~/.cora/config.yaml` /// - api_key → `~/.cora/auth.toml` /// - Delete the old file after successful migration. +/// - Creates `.migrated` marker to prevent re-running. fn migrate_old_config() { let dir = match cora_dir() { Ok(d) => d, @@ -74,6 +78,11 @@ fn migrate_old_config() { return; } + // Check if migration already completed + if dir.join(MIGRATION_MARKER).is_file() { + return; + } + let content = match std::fs::read_to_string(&old_path) { Ok(c) => c, Err(e) => { @@ -155,10 +164,13 @@ fn migrate_old_config() { } } - // Write api_key to auth.toml if present + // Write api_key to auth.toml if present (use TOML library to avoid injection) if let Some(key) = api_key { let auth_path = dir.join(AUTH_FILENAME); - if let Err(e) = std::fs::write(&auth_path, format!("api_key = \"{key}\"\n")) { + let mut table = toml::Table::new(); + table.insert("api_key".to_string(), toml::Value::String(key)); + let content = table.to_string(); + if let Err(e) = std::fs::write(&auth_path, content) { debug!("failed to write migrated auth.toml: {}", e); return; } @@ -188,10 +200,12 @@ fn migrate_old_config() { } } - // Delete old file + // Delete old file and create migration marker if let Err(e) = std::fs::remove_file(&old_path) { debug!("failed to remove old config.toml after migration: {}", e); } else { + // Create marker to prevent re-running migration + let _ = std::fs::write(dir.join(MIGRATION_MARKER), ""); debug!("migrated ~/.cora/config.toml to new format"); } } From d51983f3c446598d54f42f5da061b3ecea68204f Mon Sep 17 00:00:00 2001 From: CTO Hermes Date: Sun, 31 May 2026 22:55:19 +0700 Subject: [PATCH 23/23] chore: trigger CI