diff --git a/docs/sonarqube-mcp.md b/docs/sonarqube-mcp.md new file mode 100644 index 0000000..3e96c07 --- /dev/null +++ b/docs/sonarqube-mcp.md @@ -0,0 +1,179 @@ +# SonarQube MCP Quality Gate Integration + +This document describes the 3-phase integration of SonarQube into Sparks. + +--- + +## Overview + +SonarQube is integrated as a mandatory quality gate in the Sparks CI pipeline. Before any +PR is opened, the agent verifies that the analysis is passing. The integration is designed +so that Phase 1 requires zero code changes — only MCP configuration. + +--- + +## Phase 1 — MCP Container (zero code change) + +Add the `sonarqube-mcp` Docker container to Sparks' MCP server list. Once added, agents +can call SonarQube analysis tools directly via the MCP protocol for on-demand quality +checks during development. + +### MCP config snippet (`~/.sparks/config.toml`) + +```toml +[mcp] +enabled = true + +[[mcp.servers]] +name = "sonarqube" +command = "docker" +args = [ + "run", "--rm", "-i", + "-e", "SONAR_TOKEN", + "-e", "SONAR_HOST_URL", + "ghcr.io/sapientpants/sonarqube-mcp-server:latest" +] +env = { SONAR_TOKEN = "${SPARKS_SONAR_TOKEN}", SONAR_HOST_URL = "https://sonarcloud.io" } +``` + +### Required environment variables for Phase 1 + +| Variable | Description | +|---------------------|-------------------------------------------------| +| `SPARKS_SONAR_TOKEN`| SonarQube / SonarCloud authentication token | +| `SONAR_HOST_URL` | Server URL (defaults to https://sonarcloud.io) | + +Once configured, agents can call tools such as `sonarqube_get_quality_gate_status`, +`sonarqube_get_metrics`, and `sonarqube_get_issues` via the MCP interface. + +--- + +## Phase 2 — CI Quality Gate (this PR) + +Phase 2 wires the quality gate into the Sparks CI pipeline as a mandatory step before PR +creation. Two integration points are provided: + +### 2a. Rust client (`src/sonarqube.rs`) + +`SonarClient::wait_for_gate()` polls until the quality gate passes, times out, or fails +definitively. It is driven by `SonarqubeConfig` from `config.toml`. + +```rust +use sparks::sonarqube::SonarClient; +use sparks::config::SonarqubeConfig; + +let client = SonarClient::new(config.sonarqube.clone()); +let result = client.wait_for_gate().await?; +if !result.is_passing() { + eprintln!("{}", result.summary()); + // block PR creation +} +``` + +### 2b. Python CLI poller (`scripts/sonarqube_gate.py`) + +For use in shell-based CI pipelines, pre-push hooks, and GitHub Actions: + +```bash +python3 scripts/sonarqube_gate.py \ + --project-key myorg_myproject \ + --timeout 120 \ + --poll 5 +# exits 0 on pass, 1 on failure or timeout +``` + +Environment-variable driven (no flags needed if env vars are set): + +```bash +export SONAR_PROJECT_KEY=myorg_myproject +export SPARKS_SONAR_TOKEN=squ_... +python3 scripts/sonarqube_gate.py +``` + +### GitHub Actions example + +```yaml +- name: Wait for SonarQube quality gate + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_PROJECT_KEY: myorg_myproject + SONAR_ORGANIZATION: myorg + run: python3 scripts/sonarqube_gate.py --timeout 180 +``` + +--- + +## Phase 3 — Cartograph Quality Signals (future) + +Phase 3 feeds historical SonarQube metrics into Cartograph's Dynamics layer as quality +signals. This enables the agent to: + +- Track quality trends over time alongside commit metadata +- Use declining code coverage or rising technical debt as signals for proactive refactoring +- Correlate quality regressions with specific contributors or module changes + +Implementation will extend `src/sonarqube.rs` with a metric history collector and expose +the data through a Cartograph-compatible signal feed. + +--- + +## Configuration Reference + +All fields live under `[sonarqube]` in `config.toml`. + +| Field | Type | Default | Description | +|----------------------|----------|----------------------------|----------------------------------------------------------| +| `server_url` | string | `https://sonarcloud.io` | SonarQube server URL | +| `token` | string? | — | Auth token (prefer `SPARKS_SONAR_TOKEN` env var) | +| `project_key` | string? | — | Project key, e.g. `myorg_myproject` | +| `organization` | string? | — | Organisation key (SonarCloud only) | +| `gate_timeout_secs` | integer | `120` | Max seconds to wait for quality gate | +| `poll_interval_secs` | integer | `5` | Seconds between API polls | +| `block_on_failure` | bool | `true` | Block PR creation when gate fails | + +### Environment variables + +| Variable | Description | +|-----------------------|-----------------------------------------------------| +| `SPARKS_SONAR_TOKEN` | Auth token (overrides `token` field in config) | +| `SONAR_TOKEN` | Alias accepted by the Python CLI poller | +| `SONAR_HOST_URL` | Server URL for CLI poller | +| `SONAR_PROJECT_KEY` | Project key for CLI poller | +| `SONAR_ORGANIZATION` | Organisation for CLI poller | + +--- + +## Typical workflow + +``` +write code + | + v +sparks commit (or push trigger) + | + v +SonarQube analysis runs (scanner in CI) + | + v +scripts/sonarqube_gate.py (or SonarClient::wait_for_gate()) + | + +-- PASS --> open PR + | + +-- FAIL --> report failed conditions --> fix --> repeat +``` + +--- + +## Self-hosted SonarQube + +For a self-hosted instance, change `server_url` and omit `organization`: + +```toml +[sonarqube] +server_url = "http://localhost:9000" +project_key = "myproject" +# token set via SPARKS_SONAR_TOKEN env var +gate_timeout_secs = 180 +poll_interval_secs = 10 +block_on_failure = true +``` diff --git a/scripts/sonarqube_gate.py b/scripts/sonarqube_gate.py new file mode 100755 index 0000000..50676f1 --- /dev/null +++ b/scripts/sonarqube_gate.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +SonarQube quality gate poller for Sparks CI pipeline. + +Usage: + python3 scripts/sonarqube_gate.py [--project-key KEY] [--timeout 120] [--poll 5] + +Environment variables: + SONAR_HOST_URL SonarQube server URL (default: https://sonarcloud.io) + SONAR_PROJECT_KEY Project key + SONAR_TOKEN Authentication token (also accepted as SPARKS_SONAR_TOKEN) + SONAR_ORGANIZATION Organisation key (required for SonarCloud) +""" +import argparse +import base64 +import json +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Optional + + +def get_gate_status( + server_url: str, + project_key: str, + token: Optional[str], + organization: Optional[str], +) -> dict: + url = f"{server_url.rstrip('/')}/api/qualitygates/project_status" + params: dict[str, str] = {"projectKey": project_key} + if organization: + params["organization"] = organization + url += "?" + urllib.parse.urlencode(params) + + req = urllib.request.Request(url) + if token: + creds = base64.b64encode(f"{token}:".encode()).decode() + req.add_header("Authorization", f"Basic {creds}") + + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + body = e.read().decode() + print(f"SonarQube API error {e.code}: {body}", file=sys.stderr) + sys.exit(1) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Poll SonarQube quality gate") + parser.add_argument( + "--server", + default=os.environ.get("SONAR_HOST_URL", "https://sonarcloud.io"), + help="SonarQube server URL", + ) + parser.add_argument( + "--project-key", + default=os.environ.get("SONAR_PROJECT_KEY"), + help="SonarQube project key", + ) + parser.add_argument( + "--token", + default=os.environ.get("SONAR_TOKEN") or os.environ.get("SPARKS_SONAR_TOKEN"), + help="Authentication token", + ) + parser.add_argument( + "--organization", + default=os.environ.get("SONAR_ORGANIZATION"), + help="Organisation key (SonarCloud only)", + ) + parser.add_argument( + "--timeout", + type=int, + default=120, + help="Maximum seconds to wait for gate (default: 120)", + ) + parser.add_argument( + "--poll", + type=int, + default=5, + help="Poll interval in seconds (default: 5)", + ) + parser.add_argument( + "--allow-warn", + action="store_true", + help="Treat WARN as passing (default: WARN is already passing)", + ) + args = parser.parse_args() + + if not args.project_key: + print( + "Error: --project-key or SONAR_PROJECT_KEY env var required", + file=sys.stderr, + ) + sys.exit(1) + + deadline = time.time() + args.timeout + attempts = 0 + + while True: + attempts += 1 + data = get_gate_status(args.server, args.project_key, args.token, args.organization) + status = data["projectStatus"]["status"] + conditions = data["projectStatus"].get("conditions", []) + + if status in ("OK", "WARN"): + print(f"Quality gate: {status} [PASS]") + sys.exit(0) + + if status == "ERROR": + print("Quality gate: FAILED") + failed = [c for c in conditions if c["status"] == "ERROR"] + for c in failed: + metric = c["metricKey"] + actual = c.get("actualValue", "?") + threshold = c.get("errorThreshold", "?") + print(f" - {metric} = {actual} (threshold: {threshold})") + sys.exit(1) + + if status == "NONE": + print("Quality gate: NONE (no analysis found)", file=sys.stderr) + sys.exit(1) + + # PENDING or IN_PROGRESS — keep polling + if time.time() >= deadline: + print( + f"Quality gate timed out after {args.timeout}s (status: {status})", + file=sys.stderr, + ) + sys.exit(1) + + print(f"Quality gate: {status} (attempt {attempts}, polling again in {args.poll}s...)") + time.sleep(args.poll) + + +if __name__ == "__main__": + main() diff --git a/src/config.rs b/src/config.rs index 2999c63..dd4e9e7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -69,6 +69,8 @@ pub struct Config { pub langfuse: LangfuseConfig, #[serde(default)] pub alerts: AlertsConfig, + #[serde(default)] + pub sonarqube: SonarqubeConfig, #[serde(skip)] inline_secret_labels: Vec, } @@ -763,6 +765,43 @@ impl Default for AlertsConfig { } } +// ── SonarQube config ───────────────────────────────────────────────── + +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct SonarqubeConfig { + /// SonarQube server URL (e.g. https://sonarcloud.io or http://localhost:9000) + #[serde(default = "default_sonar_url")] + pub server_url: String, + /// Authentication token (or set SPARKS_SONAR_TOKEN env var) + pub token: Option, + /// Project key (e.g. "myorg_myproject") + pub project_key: Option, + /// Organisation key (required for SonarCloud, omit for self-hosted) + pub organization: Option, + /// Quality gate timeout in seconds (default 120) + #[serde(default = "default_sonar_timeout")] + pub gate_timeout_secs: u64, + /// Poll interval in seconds (default 5) + #[serde(default = "default_sonar_poll")] + pub poll_interval_secs: u64, + /// Whether to block PR creation on quality gate failure (default true) + #[serde(default = "default_sonar_block")] + pub block_on_failure: bool, +} + +fn default_sonar_url() -> String { + "https://sonarcloud.io".into() +} +fn default_sonar_timeout() -> u64 { + 120 +} +fn default_sonar_poll() -> u64 { + 5 +} +fn default_sonar_block() -> bool { + true +} + #[derive(Debug, Deserialize, Clone)] pub struct MoodConfig { #[serde(default)] @@ -1452,6 +1491,7 @@ impl Default for Config { self_dev: SelfDevConfig::default(), langfuse: LangfuseConfig::default(), alerts: AlertsConfig::default(), + sonarqube: SonarqubeConfig::default(), inline_secret_labels: Vec::new(), } } diff --git a/src/main.rs b/src/main.rs index 21c9394..05447dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,7 @@ mod scheduler; mod secrets; mod self_heal; mod session_review; +mod sonarqube; mod strategy; #[cfg(feature = "telegram")] mod telegram; diff --git a/src/sonarqube.rs b/src/sonarqube.rs new file mode 100644 index 0000000..5af1b53 --- /dev/null +++ b/src/sonarqube.rs @@ -0,0 +1,308 @@ +//! SonarQube quality gate client. +//! +//! Polls the SonarQube API until the quality gate passes (or times out). +//! Used as a mandatory check in the CI pipeline before PRs are opened. + +use serde::Deserialize; + +use crate::config::SonarqubeConfig; + +/// The status of a SonarQube quality gate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GateStatus { + Ok, + Warn, + Error, + None, + Pending, +} + +impl GateStatus { + fn from_str(s: &str) -> Self { + match s { + "OK" => Self::Ok, + "WARN" => Self::Warn, + "ERROR" => Self::Error, + "NONE" => Self::None, + _ => Self::Pending, + } + } + + pub fn is_passing(&self) -> bool { + matches!(self, Self::Ok | Self::Warn) + } +} + +impl std::fmt::Display for GateStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Ok => write!(f, "OK"), + Self::Warn => write!(f, "WARN"), + Self::Error => write!(f, "ERROR"), + Self::None => write!(f, "NONE"), + Self::Pending => write!(f, "PENDING"), + } + } +} + +#[derive(Debug, Deserialize)] +struct ApiProjectStatus { + status: String, + conditions: Option>, +} + +#[derive(Debug, Deserialize)] +struct ApiCondition { + status: String, + #[serde(rename = "metricKey")] + metric_key: String, + #[serde(rename = "actualValue", default)] + actual_value: String, + #[serde(rename = "errorThreshold", default)] + error_threshold: String, +} + +#[derive(Debug, Deserialize)] +struct ApiProjectStatusResponse { + #[serde(rename = "projectStatus")] + project_status: ApiProjectStatus, +} + +/// A failed quality gate condition. +#[derive(Debug, Clone)] +pub struct FailedCondition { + pub metric: String, + pub actual: String, + pub threshold: String, +} + +/// Result of a quality gate check. +#[derive(Debug)] +pub struct GateResult { + pub status: GateStatus, + pub failed_conditions: Vec, +} + +impl GateResult { + pub fn is_passing(&self) -> bool { + self.status.is_passing() + } + + pub fn summary(&self) -> String { + if self.is_passing() { + format!("Quality gate: {} \u{2705}", self.status) + } else { + let conditions = self + .failed_conditions + .iter() + .map(|c| { + format!( + " \u{2022} {} = {} (threshold: {})", + c.metric, c.actual, c.threshold + ) + }) + .collect::>() + .join("\n"); + format!( + "Quality gate: {} \u{274c}\nFailed conditions:\n{}", + self.status, conditions + ) + } + } +} + +/// SonarQube API client. +pub struct SonarClient { + http: reqwest::Client, + config: SonarqubeConfig, +} + +impl SonarClient { + pub fn new(config: SonarqubeConfig) -> Self { + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Failed to build HTTP client"); + Self { http, config } + } + + fn token(&self) -> Option { + self.config + .token + .clone() + .or_else(|| std::env::var("SPARKS_SONAR_TOKEN").ok()) + } + + /// Fetch the current quality gate status for the configured project. + pub async fn get_gate_status(&self) -> anyhow::Result { + let project_key = self + .config + .project_key + .as_deref() + .ok_or_else(|| anyhow::anyhow!("sonarqube.project_key is required"))?; + + let base_url = format!( + "{}/api/qualitygates/project_status", + self.config.server_url.trim_end_matches('/') + ); + + let mut request = self + .http + .get(&base_url) + .query(&[("projectKey", project_key)]); + + if let Some(org) = &self.config.organization { + request = request.query(&[("organization", org.as_str())]); + } + if let Some(token) = self.token() { + // SonarQube uses HTTP Basic auth with the token as the username and an + // empty password (i.e. "Authorization: Basic base64(token:)"). + // Bearer auth is NOT supported and will return 401. + request = request.basic_auth(&token, Option::<&str>::None); + } + + let resp = request.send().await?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!("SonarQube API error {}: {}", status, body)); + } + + let data: ApiProjectStatusResponse = resp + .json() + .await + .map_err(|e| anyhow::anyhow!("Failed to parse SonarQube response: {}", e))?; + + let status = GateStatus::from_str(&data.project_status.status); + let failed_conditions = data + .project_status + .conditions + .unwrap_or_default() + .into_iter() + .filter(|c| c.status == "ERROR") + .map(|c| FailedCondition { + metric: c.metric_key, + actual: c.actual_value, + threshold: c.error_threshold, + }) + .collect(); + + Ok(GateResult { + status, + failed_conditions, + }) + } + + /// Poll until the quality gate passes, times out, or fails definitively. + pub async fn wait_for_gate(&self) -> anyhow::Result { + let timeout = tokio::time::Duration::from_secs(self.config.gate_timeout_secs); + let poll = tokio::time::Duration::from_secs(self.config.poll_interval_secs); + let deadline = tokio::time::Instant::now() + timeout; + + loop { + let result = self.get_gate_status().await?; + + match result.status { + GateStatus::Pending => { + // Analysis still running; keep waiting + } + GateStatus::Ok | GateStatus::Warn => return Ok(result), + GateStatus::Error | GateStatus::None => { + return Ok(result); + } + } + + if tokio::time::Instant::now() >= deadline { + return Err(anyhow::anyhow!( + "SonarQube quality gate timed out after {}s", + self.config.gate_timeout_secs + )); + } + tokio::time::sleep(poll).await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gate_status_from_str_all_variants() { + assert_eq!(GateStatus::from_str("OK"), GateStatus::Ok); + assert_eq!(GateStatus::from_str("WARN"), GateStatus::Warn); + assert_eq!(GateStatus::from_str("ERROR"), GateStatus::Error); + assert_eq!(GateStatus::from_str("NONE"), GateStatus::None); + assert_eq!(GateStatus::from_str("PENDING"), GateStatus::Pending); + assert_eq!(GateStatus::from_str("IN_PROGRESS"), GateStatus::Pending); + assert_eq!(GateStatus::from_str(""), GateStatus::Pending); + } + + #[test] + fn gate_status_is_passing() { + assert!(GateStatus::Ok.is_passing()); + assert!(GateStatus::Warn.is_passing()); + assert!(!GateStatus::Error.is_passing()); + assert!(!GateStatus::None.is_passing()); + assert!(!GateStatus::Pending.is_passing()); + } + + #[test] + fn gate_result_summary_passing() { + let result = GateResult { + status: GateStatus::Ok, + failed_conditions: vec![], + }; + let summary = result.summary(); + assert!(summary.contains("OK")); + assert!(result.is_passing()); + } + + #[test] + fn gate_result_summary_failing() { + let result = GateResult { + status: GateStatus::Error, + failed_conditions: vec![ + FailedCondition { + metric: "coverage".to_string(), + actual: "45.2".to_string(), + threshold: "80.0".to_string(), + }, + FailedCondition { + metric: "duplicated_lines_density".to_string(), + actual: "12.5".to_string(), + threshold: "3.0".to_string(), + }, + ], + }; + let summary = result.summary(); + assert!(summary.contains("ERROR")); + assert!(summary.contains("Failed conditions")); + assert!(summary.contains("coverage")); + assert!(summary.contains("45.2")); + assert!(summary.contains("80.0")); + assert!(summary.contains("duplicated_lines_density")); + assert!(!result.is_passing()); + } + + #[test] + fn gate_status_display() { + assert_eq!(GateStatus::Ok.to_string(), "OK"); + assert_eq!(GateStatus::Warn.to_string(), "WARN"); + assert_eq!(GateStatus::Error.to_string(), "ERROR"); + assert_eq!(GateStatus::None.to_string(), "NONE"); + assert_eq!(GateStatus::Pending.to_string(), "PENDING"); + } + + #[test] + fn failed_condition_fields() { + let c = FailedCondition { + metric: "new_bugs".to_string(), + actual: "3".to_string(), + threshold: "0".to_string(), + }; + assert_eq!(c.metric, "new_bugs"); + assert_eq!(c.actual, "3"); + assert_eq!(c.threshold, "0"); + } +}