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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions docs/sonarqube-mcp.md
Original file line number Diff line number Diff line change
@@ -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
```
140 changes: 140 additions & 0 deletions scripts/sonarqube_gate.py
Original file line number Diff line number Diff line change
@@ -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()
40 changes: 40 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}
Expand Down Expand Up @@ -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<String>,
/// Project key (e.g. "myorg_myproject")
pub project_key: Option<String>,
/// Organisation key (required for SonarCloud, omit for self-hosted)
pub organization: Option<String>,
/// 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)]
Expand Down Expand Up @@ -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(),
}
}
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ mod scheduler;
mod secrets;
mod self_heal;
mod session_review;
mod sonarqube;
mod strategy;
#[cfg(feature = "telegram")]
mod telegram;
Expand Down
Loading
Loading