Skip to content

Commit ac295f4

Browse files
feat(rhodibot): add offline check CLI subcommand for CI gating (#150)
rhodibot was webhook-server-only (axum, `--port`), with no way to run a one-shot RSR compliance check from CI. Consumers (e.g. ubicity #37) tried to invoke a non-existent `rhodibot check --owner X --repo Y` CLI. Add a `check` subcommand that runs `rsr::check_compliance` against a remote repo via the GitHub REST API (honours `GITHUB_TOKEN`; works unauthenticated on public repos), prints a pretty/json report, and exits non-zero when required checks fail — so it can gate CI directly. The server remains the default behaviour when no subcommand is given, so existing deployments are unaffected. Also fix a pre-existing compile error in integration_tests.rs (`Hmac::new_from_slice` needs `KeyInit` in scope) so `cargo test` is green for the crate. fmt + clippy + 48 tests pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 509a964 commit ac295f4

2 files changed

Lines changed: 90 additions & 5 deletions

File tree

‎bots/rhodibot/src/main.rs‎

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77
88
use anyhow::Result;
99
use axum::{
10+
Json, Router,
1011
extract::State,
1112
http::StatusCode,
1213
response::IntoResponse,
1314
routing::{get, post},
14-
Json, Router,
1515
};
16-
use clap::Parser;
16+
use clap::{Parser, Subcommand};
1717
use serde::Serialize;
1818
use std::sync::Arc;
1919
use tokio::net::TcpListener;
@@ -46,6 +46,31 @@ struct Cli {
4646
/// Webhook secret for verification
4747
#[arg(long, env = "GITHUB_WEBHOOK_SECRET")]
4848
webhook_secret: Option<String>,
49+
50+
#[command(subcommand)]
51+
command: Option<Command>,
52+
}
53+
54+
#[derive(Subcommand, Debug)]
55+
enum Command {
56+
/// Run a one-shot RSR compliance check against a remote repository and exit.
57+
///
58+
/// Uses the GitHub REST API (honours the `GITHUB_TOKEN` env var for rate
59+
/// limits / private repos; works unauthenticated on public repos). Exits
60+
/// non-zero when required checks fail, so it can gate CI directly.
61+
Check {
62+
/// Repository owner (user or org), e.g. `hyperpolymath`
63+
#[arg(long)]
64+
owner: String,
65+
66+
/// Repository name, e.g. `ubicity`
67+
#[arg(long)]
68+
repo: String,
69+
70+
/// Output format
71+
#[arg(long, default_value = "pretty", value_parser = ["pretty", "json"])]
72+
format: String,
73+
},
4974
}
5075

5176
/// Application state shared across handlers
@@ -70,14 +95,25 @@ async fn main() -> Result<()> {
7095
// Parse CLI arguments
7196
let cli = Cli::parse();
7297

73-
info!("Starting Rhodibot v{}", env!("CARGO_PKG_VERSION"));
74-
7598
// Build configuration
7699
let config = Config::new(
77100
cli.app_id,
78101
cli.private_key_path.as_deref(),
79102
cli.webhook_secret.clone(),
80103
)?;
104+
105+
// One-shot CLI mode: run a compliance check and exit with a CI-usable code.
106+
if let Some(Command::Check {
107+
owner,
108+
repo,
109+
format,
110+
}) = &cli.command
111+
{
112+
return run_check(&config, owner, repo, format).await;
113+
}
114+
115+
info!("Starting Rhodibot v{}", env!("CARGO_PKG_VERSION"));
116+
81117
let state = AppState {
82118
config: Arc::new(config),
83119
};
@@ -101,6 +137,55 @@ async fn main() -> Result<()> {
101137
Ok(())
102138
}
103139

140+
/// One-shot RSR compliance check for CI. Prints a report and exits non-zero
141+
/// when required checks fail (so the caller's job fails too).
142+
async fn run_check(config: &Config, owner: &str, repo: &str, format: &str) -> Result<()> {
143+
rhodibot::sanitize::validate_owner_repo(owner, repo)?;
144+
145+
let report = rsr::check_compliance(config, owner, repo).await?;
146+
147+
if format == "json" {
148+
println!("{}", serde_json::to_string_pretty(&report)?);
149+
} else {
150+
println!(
151+
"RSR compliance — {}/{} (policy: {})",
152+
report.owner, report.repo, report.policy
153+
);
154+
println!("{}", rsr::policy_summary(report.policy));
155+
println!(
156+
"Score: {}/{} ({:.0}%)",
157+
report.score, report.max_score, report.percentage
158+
);
159+
println!();
160+
for check in &report.checks {
161+
println!(
162+
" [{:?}] {} ({:?}, {}/{}) — {}",
163+
check.status,
164+
check.name,
165+
check.severity,
166+
check.points,
167+
check.max_points,
168+
check.message
169+
);
170+
}
171+
println!();
172+
println!("{}", report.summary);
173+
println!(
174+
"Required checks: {}",
175+
if report.required_passed {
176+
"PASSED"
177+
} else {
178+
"FAILED"
179+
}
180+
);
181+
}
182+
183+
if !report.required_passed {
184+
std::process::exit(1);
185+
}
186+
Ok(())
187+
}
188+
104189
/// Health check endpoint
105190
async fn health_check() -> impl IntoResponse {
106191
Json(HealthResponse {

‎bots/rhodibot/tests/integration_tests.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ mod webhook_tests {
535535
let payload = r#"{"action":"opened","repository":{"name":"test"}}"#;
536536

537537
// Compute expected signature
538-
use hmac::{Hmac, Mac};
538+
use hmac::{Hmac, KeyInit, Mac};
539539
use sha2::Sha256;
540540
type HmacSha256 = Hmac<Sha256>;
541541

0 commit comments

Comments
 (0)