ref:54e7ab9ecfc19a6026508cf9990bb236f571673f

feat(standards): Phase 7 CLI — --strict-standards flag

Adds `anvil requirement status --strict-standards --organization <slug>`, the CLI counterpart of the server's `GET /api/v1/:org/standards/strict` gate (#257). Hits the new endpoint, prints a per-(standard, repo) failure table on 422, and exits non-zero so CI scripts can wire it in directly. Pure-additive: the existing `anvil requirement status --repo <r>` behavior is unchanged. The flag is rejected without `--organization` with a clear message. Includes four wiremock-based unit tests covering: - missing `--organization` rejection - 200 `status:"ok"` → success - 422 uncovered payload → non-zero exit with formatted output - non-422 errors propagated verbatim Covers REQ-STD-038.
SHA: 54e7ab9ecfc19a6026508cf9990bb236f571673f
Author: CI <ci@anvil.test>
Date: 2026-05-24 17:13
Parents: 6532c17
1 files changed +177 -0
Type
src/commands/requirement.rs +177 −0
@@ -174,6 +174,16 @@
/// Fail on partial coverage too (default: only fail on uncovered)
#[arg(long)]
strict: bool,
/// Check the org-wide standards coverage gate (#257) instead of per-repo requirements.
/// Hits GET /api/v1/<org>/standards/strict and exits 1 if any mandatory standard
/// is uncovered for any applicable repo. Requires --organization.
#[arg(long)]
pub strict_standards: bool,
/// Organization slug — required when --strict-standards is set.
#[arg(long)]
pub organization: Option<String>,
}
/// Metadata flags shared by `create` and `update` for both kinds.
@@ -1197,7 +1207,85 @@
}
async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
if args.strict_standards {
return strict_standards_status(args.organization.as_deref()).await;
}
status_requirements(args).await
}
async fn strict_standards_status(
organization: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let org = organization.ok_or(
"--strict-standards requires --organization <slug> (the org whose mandatory standards to gate on)",
)?;
let client = Client::from_config()?;
strict_standards_with_client(&client, org).await
}
async fn strict_standards_with_client(
client: &Client,
org: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// The endpoint returns 422 on uncovered, so we go through the raw
// request path rather than `client.get` which would treat that as an
// error. We let `client.get` give us a generic Value and inspect the
// body for status === "ok" vs "uncovered".
match client
.get::<serde_json::Value>(&format!("/{org}/standards/strict"))
.await
{
Ok(body) => {
// 2xx with status=ok — coverage gate passed.
if body["status"] == "ok" {
output::success("Standards strict coverage gate passed");
Ok(())
} else {
// Unexpected envelope.
Err(format!("unexpected response body from /standards/strict: {body}").into())
}
}
Err(crate::client::ApiError::Api {
status: 422,
message,
}) => {
// Parse the uncovered list and print a per-(standard, repo) failure.
let body: serde_json::Value = serde_json::from_str(&message).map_err(|_| {
format!("standards gate failed with 422 but body was not JSON: {message}")
})?;
print_uncovered(&body);
let count = body["uncovered"].as_array().map(|a| a.len()).unwrap_or(0);
Err(format!("{count} uncovered mandatory standard(s)").into())
}
Err(other) => Err(Box::new(other)),
}
}
fn print_uncovered(body: &serde_json::Value) {
output::header("Standards Strict Coverage — FAILED");
let entries = body["uncovered"].as_array().cloned().unwrap_or_default();
println!(" {} uncovered mandatory pair(s):", entries.len());
println!();
let rows: Vec<Vec<String>> = entries
.iter()
.map(|e| {
vec![
e["standard"]["requirement_id"]
.as_str()
.unwrap_or("?")
.to_string(),
e["standard"]["title"].as_str().unwrap_or("?").to_string(),
e["repo"]["slug"].as_str().unwrap_or("?").to_string(),
e["status"].as_str().unwrap_or("?").to_string(),
]
})
.collect();
output::print_table(&["STANDARD", "TITLE", "REPO", "STATUS"], &rows);
println!();
}
async fn status_requirements(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(args.repo.as_deref())?;
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/requirements/matrix"))
@@ -1965,5 +2053,94 @@
.unwrap_err()
.to_string();
assert!(err.contains("bogus"), "got: {err}");
}
// ── Phase 7 (#257) — --strict-standards ──
#[tokio::test]
async fn strict_standards_rejects_missing_organization() {
let err = strict_standards_status(None).await.unwrap_err().to_string();
assert!(err.contains("--organization"), "got: {err}");
assert!(err.contains("--strict-standards"), "got: {err}");
}
#[tokio::test]
async fn strict_standards_returns_ok_on_2xx_status_ok() {
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/fangorn/standards/strict"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"status": "ok"})))
.mount(&server)
.await;
let client = Client::for_test(server.uri(), "tok");
let result = strict_standards_with_client(&client, "fangorn").await;
assert!(result.is_ok(), "expected ok, got {result:?}");
}
#[tokio::test]
async fn strict_standards_errors_on_422_uncovered() {
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/fangorn/standards/strict"))
.respond_with(ResponseTemplate::new(422).set_body_json(json!({
"status": "uncovered",
"uncovered": [
{
"standard": {
"id": "11111111-1111-1111-1111-111111111111",
"requirement_id": "STD-GDPR-017",
"title": "Right to erasure",
"framework": "GDPR",
"mandatory": true
},
"repo": {
"id": "22222222-2222-2222-2222-222222222222",
"slug": "customer-app",
"name": "Customer App"
},
"status": "uncovered"
}
]
})))
.mount(&server)
.await;
let client = Client::for_test(server.uri(), "tok");
let err = strict_standards_with_client(&client, "fangorn")
.await
.expect_err("expected uncovered → Err");
let msg = err.to_string();
assert!(msg.contains("1 uncovered"), "got: {msg}");
assert!(msg.contains("mandatory standard"), "got: {msg}");
}
#[tokio::test]
async fn strict_standards_propagates_non_422_errors() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/no-such/standards/strict"))
.respond_with(ResponseTemplate::new(404).set_body_string(r#"{"error":"not_found"}"#))
.mount(&server)
.await;
let client = Client::for_test(server.uri(), "tok");
let err = strict_standards_with_client(&client, "no-such")
.await
.expect_err("expected 404 → Err");
// The CLI's underlying ApiError stringifies as "API error (404): ..."
let msg = err.to_string();
assert!(msg.contains("404"), "got: {msg}");
}
}