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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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" => {
Expand Down Expand Up @@ -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 <SECONDS> Poll every N seconds; default is 5 in --monitor
--fail-on <LEVEL> Exit non-zero on threshold: never, warning, danger
--warning <PCT> Warning threshold percentage [default: 75]
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/cli/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
102 changes: 102 additions & 0 deletions src/cli/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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<String> {
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,
Expand Down Expand Up @@ -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");
}
}
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading