From ce868c30d9e1fbaca06b5d6da5438e72407821b5 Mon Sep 17 00:00:00 2001 From: xodapi <4956501+xodapi@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:54:23 +0700 Subject: [PATCH] feat(ci): add threshold breach annotations (closes #152) --- README.md | 5 +++ src/cli/args.rs | 10 +++++ src/cli/mcp.rs | 1 + src/cli/output.rs | 102 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 5 files changed, 119 insertions(+) diff --git a/README.md b/README.md index acca391..48ffca2 100644 --- a/README.md +++ b/README.md @@ -296,8 +296,13 @@ CI/automation threshold: vimit --fail-on warning vimit --fail-on danger --json vimit --warning 80 --danger 95 --fail-on warning +vimit --fail-on warning --ci-annotate ``` +`--ci-annotate` keeps normal stdout output intact and emits GitHub Actions-style +`::warning` / `::error` lines to stderr for breached windows, so it can be used +in CI logs without adding hook orchestration or request mutation. + Compact one-line output for widgets/status bars: ```bash diff --git a/src/cli/args.rs b/src/cli/args.rs index f361372..69dd5c5 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -40,6 +40,7 @@ pub struct Args { pub theme: Theme, pub with_abtop: bool, pub notify: bool, + pub ci_annotate: bool, pub watch: u64, pub fail_on: FailOn, pub warning_threshold: f64, @@ -80,6 +81,7 @@ where theme: Theme::Btop, with_abtop: false, notify: false, + ci_annotate: false, watch: 0, fail_on: FailOn::Never, warning_threshold: constants::DEFAULT_WARNING_THRESHOLD, @@ -164,6 +166,7 @@ where } "--with-abtop" => parsed.with_abtop = true, "--notify" => parsed.notify = true, + "--ci-annotate" => parsed.ci_annotate = true, "--api-base" => parsed.api_base = Some(next_value(&mut iter, "--api-base")?), "--api-key-env" => parsed.api_key_env = next_value(&mut iter, "--api-key-env")?, "--env-file" => { @@ -310,6 +313,7 @@ OPTIONS: tritanopia, solarized, monokai --with-abtop Merge local abtop --status-json output if available --notify Desktop alert when a window enters warning/danger + --ci-annotate Emit CI-friendly warning/error annotations to stderr --watch Poll every N seconds; default is 5 in --monitor --fail-on Exit non-zero on threshold: never, warning, danger --warning Warning threshold percentage [default: 75] @@ -368,6 +372,12 @@ mod tests { assert!(args.auto_failover); } + #[test] + fn ci_annotate_flag_is_parsed() { + let args = parse_args(["--ci-annotate".to_string()]).unwrap(); + assert!(args.ci_annotate); + } + #[test] fn no_failover_disables_auto_endpoint_switching() { let args = parse_args(["--no-failover".to_string()]).unwrap(); diff --git a/src/cli/mcp.rs b/src/cli/mcp.rs index 175af0d..4719ce7 100644 --- a/src/cli/mcp.rs +++ b/src/cli/mcp.rs @@ -99,6 +99,7 @@ impl VimitMcpServer { theme: cli::theme::Theme::Btop, with_abtop: false, notify: false, + ci_annotate: false, watch: 0, fail_on: cli::args::FailOn::Never, warning_threshold: cli::constants::DEFAULT_WARNING_THRESHOLD, diff --git a/src/cli/output.rs b/src/cli/output.rs index 2ed7181..730e893 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -49,6 +49,9 @@ pub fn run_once( } notifier.check_windows(&snapshot.windows); + if args.ci_annotate { + print_ci_annotations(&snapshot.windows); + } Ok(exit_code(&snapshot.windows, args.fail_on)) } @@ -156,6 +159,47 @@ fn format_agent(agent: &Value) -> String { ) } +fn print_ci_annotations(windows: &[ng::WindowState]) { + for annotation in ci_annotations(windows) { + eprintln!("{}", annotation); + } +} + +fn ci_annotations(windows: &[ng::WindowState]) -> Vec { + windows + .iter() + .filter_map(|window| match window.level.as_str() { + "warning" => Some(render_ci_annotation("warning", window)), + "danger" => Some(render_ci_annotation("error", window)), + _ => None, + }) + .collect() +} + +fn render_ci_annotation(kind: &str, window: &ng::WindowState) -> String { + let peak = ng::peak_percent(window.credits.as_ref(), window.requests.as_ref()).unwrap_or(0.0); + let message = format!( + "{} window hit {} at {:.1}% (reset {})", + window.key, + window.level, + peak, + ng::format_duration_opt(window.reset_in_seconds) + ); + format!( + "::{} title={}::{}", + kind, + escape_github_annotation("vimit threshold breach"), + escape_github_annotation(&message) + ) +} + +fn escape_github_annotation(value: &str) -> String { + value + .replace('%', "%25") + .replace('\r', "%0D") + .replace('\n', "%0A") +} + fn exit_code(windows: &[ng::WindowState], fail_on: FailOn) -> i32 { match fail_on { FailOn::Never => 0, @@ -220,4 +264,62 @@ mod tests { assert_eq!(exit_code(&windows, FailOn::Danger), 3); assert_eq!(exit_code(&windows, FailOn::Never), 0); } + + #[test] + fn ci_annotations_emit_warning_and_error_entries() { + let windows = vec![ + ng::WindowState { + key: "5h", + credits: Some(ng::Metric { + used: 39.0, + limit: 50.0, + remaining: 11.0, + percent: 78.0, + }), + requests: None, + reset: "unknown".to_string(), + reset_in_seconds: Some(3600), + level: "warning".to_string(), + percent: 78.0, + }, + ng::WindowState { + key: "7d", + credits: Some(ng::Metric { + used: 95.0, + limit: 100.0, + remaining: 5.0, + percent: 95.0, + }), + requests: None, + reset: "unknown".to_string(), + reset_in_seconds: Some(120), + level: "danger".to_string(), + percent: 95.0, + }, + ng::WindowState { + key: "30d", + credits: None, + requests: None, + reset: "unknown".to_string(), + reset_in_seconds: None, + level: "ok".to_string(), + percent: 0.0, + }, + ]; + + let annotations = ci_annotations(&windows); + + assert_eq!(annotations.len(), 2); + assert!(annotations[0].starts_with("::warning ")); + assert!(annotations[0].contains("5h window hit warning at 78.0%")); + assert!(annotations[1].starts_with("::error ")); + assert!(annotations[1].contains("7d window hit danger at 95.0%")); + } + + #[test] + fn ci_annotation_escaping_matches_github_command_rules() { + let escaped = escape_github_annotation("danger 95%\nnext\rline"); + + assert_eq!(escaped, "danger 95%25%0Anext%0Dline"); + } } diff --git a/src/main.rs b/src/main.rs index 02836ad..0569adb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -274,6 +274,7 @@ fn merge_args_with_config(args: Args, merged: &MergedConfig) -> Args { }, with_abtop: args.with_abtop || merged.with_abtop, notify: args.notify || merged.notify, + ci_annotate: args.ci_annotate, watch: if args.watch == 0 && merged.watch != 0 { merged.watch } else {