use std::io::Write;
use anyhow::{Context, Result};
use clap::{Arg, ArgAction, ArgMatches, Command};
use serde::{Deserialize, Serialize};
use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
use crate::{
CliCommand,
constants::get_observability_api_url,
core::{
command::command,
http_client::{get, post},
validate::{require_integration, require_manifest},
},
};
#[derive(Debug)]
pub(super) struct IssuesCommand {
ack: AckCommand,
resolve: ResolveCommand,
}
impl IssuesCommand {
pub(super) fn new() -> Self {
Self {
ack: AckCommand::new(),
resolve: ResolveCommand::new(),
}
}
}
impl CliCommand for IssuesCommand {
fn command(&self) -> Command {
command(
"issues",
"List or acknowledge active issues for a ForkLaunch application",
)
.subcommand_negates_reqs(true)
.arg(
Arg::new("base_path")
.short('p')
.long("path")
.help("The application path")
.global(true),
)
.arg(
Arg::new("environment")
.short('e')
.long("environment")
.required(true)
.help("Environment to inspect (for example: dev, staging, production)"),
)
.arg(
Arg::new("severity")
.long("severity")
.help("Filter by severity (ERROR, ALERT, INCIDENT)"),
)
.arg(
Arg::new("status")
.long("status")
.help("Filter by status (for example: open, acknowledged)"),
)
.arg(
Arg::new("json")
.long("json")
.help("Output raw JSON instead of formatted terminal output")
.action(ArgAction::SetTrue)
.global(true),
)
.subcommand(self.ack.command())
.subcommand(self.resolve.command())
}
fn handler(&self, matches: &ArgMatches) -> Result<()> {
match matches.subcommand() {
Some(("ack", sub_matches)) => self.ack.handler(sub_matches),
Some(("resolve", sub_matches)) => self.resolve.handler(sub_matches),
_ => list_issues(matches),
}
}
}
#[derive(Debug)]
struct AckCommand;
impl AckCommand {
fn new() -> Self {
Self
}
}
impl CliCommand for AckCommand {
fn command(&self) -> Command {
command("ack", "Acknowledge an active issue")
.arg(
Arg::new("id")
.required(true)
.help("The issue ID to acknowledge"),
)
.arg(Arg::new("acknowledged_by").long("acknowledged-by").help(
"User acknowledging the issue (defaults to FORKLAUNCH_USER env var, then OS user)",
))
}
fn handler(&self, matches: &ArgMatches) -> Result<()> {
acknowledge_issue(matches)
}
}
#[derive(Debug)]
struct ResolveCommand;
impl ResolveCommand {
fn new() -> Self {
Self
}
}
impl CliCommand for ResolveCommand {
fn command(&self) -> Command {
command(
"resolve",
"Resolve an issue (mark as closed/fixed) — distinct from `ack`, which only marks it as seen and leaves it open",
)
.arg(
Arg::new("id")
.required(true)
.help("The issue ID to resolve"),
)
.arg(
Arg::new("resolved_by")
.long("resolved-by")
.help("User resolving the issue (defaults to FORKLAUNCH_USER env var, then OS user)"),
)
}
fn handler(&self, matches: &ArgMatches) -> Result<()> {
resolve_issue(matches)
}
}
fn list_issues(matches: &ArgMatches) -> Result<()> {
let (_app_root, manifest) = require_manifest(matches)?;
let application_id = require_integration(&manifest)?;
let environment = matches
.get_one::<String>("environment")
.context("--environment is required for listing issues")?
.to_string();
let severity = matches.get_one::<String>("severity").cloned();
let status = matches.get_one::<String>("status").cloned();
let json_output = matches.get_flag("json");
let issues = fetch_issues(
&application_id,
&environment,
severity.as_deref(),
status.as_deref(),
)?;
if json_output {
println!("{}", serde_json::to_string_pretty(&issues)?);
} else {
print_issues(&issues)?;
}
Ok(())
}
fn fetch_issues(
application_id: &str,
environment: &str,
severity: Option<&str>,
status: Option<&str>,
) -> Result<Vec<Issue>> {
let api_url = get_observability_api_url();
let mut url = format!(
"{}/issues?appId={}&env={}",
api_url,
urlencoding::encode(application_id),
urlencoding::encode(environment),
);
if let Some(sev) = severity {
url.push_str(&format!("&severity={}", urlencoding::encode(sev)));
}
if let Some(st) = status {
url.push_str(&format!("&status={}", urlencoding::encode(st)));
}
let response = get(&url).with_context(|| "Failed to reach observability API")?;
if !response.status().is_success() {
let http_status = response.status();
let body = response
.text()
.unwrap_or_else(|_| "unknown error".to_string());
anyhow::bail!("Observability API returned {} — {}", http_status, body);
}
response
.json()
.with_context(|| "Failed to parse issues response")
}
fn acknowledge_issue(matches: &ArgMatches) -> Result<()> {
let (_app_root, manifest) = require_manifest(matches)?;
let application_id = require_integration(&manifest)?;
let issue_id = matches
.get_one::<String>("id")
.context("issue id is required")?
.to_string();
let acknowledged_by = matches
.get_one::<String>("acknowledged_by")
.cloned()
.unwrap_or_else(|| resolve_current_user(&application_id));
let json_output = matches.get_flag("json");
let response = post_acknowledge(&issue_id, &acknowledged_by)?;
if json_output {
println!("{}", serde_json::to_string_pretty(&response)?);
} else {
print_ack_result(&issue_id, &acknowledged_by, &response)?;
}
Ok(())
}
fn resolve_issue(matches: &ArgMatches) -> Result<()> {
let (_app_root, manifest) = require_manifest(matches)?;
let application_id = require_integration(&manifest)?;
let issue_id = matches
.get_one::<String>("id")
.context("issue id is required")?
.to_string();
let resolved_by = matches
.get_one::<String>("resolved_by")
.cloned()
.unwrap_or_else(|| resolve_current_user(&application_id));
let json_output = matches.get_flag("json");
let response = post_resolve(&issue_id, &resolved_by)?;
if json_output {
println!("{}", serde_json::to_string_pretty(&response)?);
} else {
print_resolve_result(&issue_id, &resolved_by, &response)?;
}
Ok(())
}
fn resolve_current_user(_application_id: &str) -> String {
if let Ok(user) = std::env::var("FORKLAUNCH_USER") {
if !user.trim().is_empty() {
return user.trim().to_string();
}
}
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".to_string())
}
fn post_acknowledge(issue_id: &str, acknowledged_by: &str) -> Result<AckResponse> {
let api_url = get_observability_api_url();
let url = format!(
"{}/issues/{}/acknowledge",
api_url,
urlencoding::encode(issue_id)
);
let body = serde_json::json!({ "acknowledgedBy": acknowledged_by });
let response = post(&url, body)
.with_context(|| "Failed to reach observability API")?;
if !response.status().is_success() {
let http_status = response.status();
let body = response
.text()
.unwrap_or_else(|_| "unknown error".to_string());
anyhow::bail!("Observability API returned {} — {}", http_status, body);
}
response
.json()
.with_context(|| "Failed to parse acknowledge response")
}
fn post_resolve(issue_id: &str, resolved_by: &str) -> Result<ResolveResponse> {
let api_url = get_observability_api_url();
let url = format!(
"{}/issues/{}/resolve",
api_url,
urlencoding::encode(issue_id)
);
let body = serde_json::json!({ "resolvedBy": resolved_by });
let response = post(&url, body)
.with_context(|| "Failed to reach observability API")?;
if !response.status().is_success() {
let http_status = response.status();
let body = response
.text()
.unwrap_or_else(|_| "unknown error".to_string());
anyhow::bail!("Observability API returned {} — {}", http_status, body);
}
response
.json()
.with_context(|| "Failed to parse resolve response")
}
fn print_issues(issues: &[Issue]) -> Result<()> {
let mut stdout = StandardStream::stdout(ColorChoice::Always);
let mut issues: Vec<&Issue> = issues.iter().collect();
issues.sort_by(|a, b| {
b.score
.unwrap_or(f64::MIN)
.partial_cmp(&a.score.unwrap_or(f64::MIN))
.unwrap_or(std::cmp::Ordering::Equal)
});
if issues.is_empty() {
stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)))?;
writeln!(stdout, "No active issues found.")?;
stdout.reset()?;
return Ok(());
}
writeln!(stdout)?;
stdout.set_color(ColorSpec::new().set_bold(true))?;
writeln!(
stdout,
" {:<10} {:<36} {:<20} {:<30} {}",
"SEVERITY", "ID", "SERVICE", "TITLE", "FIRST SEEN"
)?;
stdout.reset()?;
stdout.set_color(ColorSpec::new().set_fg(Some(Color::White)))?;
writeln!(
stdout,
" {:-<10} {:-<36} {:-<20} {:-<30} {:-<19}",
"", "", "", "", ""
)?;
stdout.reset()?;
for issue in &issues {
let severity = issue.severity.as_deref().unwrap_or("UNKNOWN");
let color = severity_color(severity);
stdout.set_color(ColorSpec::new().set_fg(Some(color)).set_bold(true))?;
write!(stdout, " {:<10}", severity)?;
stdout.reset()?;
let id_display = issue.id.as_str();
let service = issue.service_name.as_deref().unwrap_or("-");
let title = issue.title.as_deref().unwrap_or("-");
let title_truncated = if title.chars().count() > 30 {
format!("{}…", title.chars().take(29).collect::<String>())
} else {
title.to_string()
};
let first_seen = issue
.first_seen
.as_deref()
.and_then(|ts| ts.get(..19))
.map(|ts| ts.replace('T', " "))
.unwrap_or_else(|| "-".to_string());
writeln!(
stdout,
" {:<36} {:<20} {:<30} {}",
id_display, service, title_truncated, first_seen
)?;
}
writeln!(stdout)?;
stdout.set_color(ColorSpec::new().set_fg(Some(Color::White)))?;
writeln!(stdout, " {} issue(s) found.", issues.len())?;
stdout.reset()?;
writeln!(stdout)?;
Ok(())
}
fn print_ack_result(issue_id: &str, acknowledged_by: &str, _response: &AckResponse) -> Result<()> {
let mut stdout = StandardStream::stdout(ColorChoice::Always);
writeln!(stdout)?;
stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)).set_bold(true))?;
write!(stdout, " Acknowledged")?;
stdout.reset()?;
writeln!(stdout, " issue {} by {}", issue_id, acknowledged_by)?;
writeln!(stdout)?;
Ok(())
}
fn print_resolve_result(issue_id: &str, resolved_by: &str, _response: &ResolveResponse) -> Result<()> {
let mut stdout = StandardStream::stdout(ColorChoice::Always);
writeln!(stdout)?;
stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)).set_bold(true))?;
write!(stdout, " Resolved")?;
stdout.reset()?;
writeln!(stdout, " issue {} by {}", issue_id, resolved_by)?;
writeln!(stdout)?;
Ok(())
}
fn severity_color(severity: &str) -> Color {
match severity.to_uppercase().as_str() {
"ERROR" => Color::Red,
"ALERT" => Color::Yellow,
"INCIDENT" => Color::Cyan,
_ => Color::White,
}
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct Issue {
id: String,
#[serde(default)]
app_id: Option<String>,
#[serde(default)]
service_name: Option<String>,
#[serde(default)]
env: Option<String>,
#[serde(default)]
severity: Option<String>,
#[serde(default)]
status: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
score: Option<f64>,
#[serde(default)]
first_seen: Option<String>,
#[serde(default)]
last_seen: Option<String>,
#[serde(default)]
acknowledged_at: Option<String>,
#[serde(default)]
acknowledged_by: Option<String>,
#[serde(default)]
resolved_at: Option<String>,
#[serde(default)]
resolved_by: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct AckResponse {
#[serde(default)]
id: Option<String>,
#[serde(default)]
acknowledged_at: Option<String>,
#[serde(default)]
acknowledged_by: Option<String>,
#[serde(flatten)]
extra: serde_json::Value,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct ResolveResponse {
#[serde(default)]
id: Option<String>,
#[serde(default)]
resolved_at: Option<String>,
#[serde(default)]
resolved_by: Option<String>,
#[serde(flatten)]
extra: serde_json::Value,
}
#[cfg(test)]
mod tests {
use super::*;
fn issues_cmd() -> Command {
IssuesCommand::new().command().version("0.0.0-test")
}
#[test]
fn command_definition_is_valid() {
issues_cmd().debug_assert();
}
#[test]
fn list_requires_environment() {
assert!(issues_cmd().try_get_matches_from(["issues"]).is_err());
assert!(
issues_cmd()
.try_get_matches_from(["issues", "-e", "dev"])
.is_ok()
);
}
#[test]
fn ack_does_not_require_environment() {
assert!(
issues_cmd()
.try_get_matches_from(["issues", "ack", "issue-123"])
.is_ok()
);
}
#[test]
fn resolve_does_not_require_environment() {
assert!(
issues_cmd()
.try_get_matches_from(["issues", "resolve", "issue-123"])
.is_ok()
);
}
#[test]
fn resolve_response_deserializes_partial() {
let json = r#"{"id": "iss-001", "resolvedAt": "2024-01-15T10:05:00Z", "resolvedBy": "alice"}"#;
let response: ResolveResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.id.as_deref(), Some("iss-001"));
assert_eq!(response.resolved_by.as_deref(), Some("alice"));
}
#[test]
fn severity_color_error_is_red() {
assert_eq!(severity_color("ERROR"), Color::Red);
}
#[test]
fn severity_color_alert_is_yellow() {
assert_eq!(severity_color("ALERT"), Color::Yellow);
}
#[test]
fn severity_color_incident_is_cyan() {
assert_eq!(severity_color("INCIDENT"), Color::Cyan);
}
#[test]
fn severity_color_unknown_is_white() {
assert_eq!(severity_color("CRITICAL"), Color::White);
}
#[test]
fn severity_color_case_insensitive() {
assert_eq!(severity_color("error"), Color::Red);
assert_eq!(severity_color("alert"), Color::Yellow);
assert_eq!(severity_color("incident"), Color::Cyan);
}
fn with_forklaunch_user<F: FnOnce()>(value: &str, f: F) {
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("FORKLAUNCH_USER").ok();
unsafe { std::env::set_var("FORKLAUNCH_USER", value) };
f();
unsafe {
match prev {
Some(v) => std::env::set_var("FORKLAUNCH_USER", v),
None => std::env::remove_var("FORKLAUNCH_USER"),
}
}
}
#[test]
fn resolve_current_user_env_var_takes_priority() {
with_forklaunch_user("ci-bot", || {
assert_eq!(resolve_current_user("app-123"), "ci-bot");
});
}
#[test]
fn resolve_current_user_trims_whitespace() {
with_forklaunch_user(" alice ", || {
assert_eq!(resolve_current_user("app-123"), "alice");
});
}
#[test]
fn resolve_current_user_skips_empty_env_var() {
with_forklaunch_user("", || {
assert!(!resolve_current_user("app-123").is_empty());
});
}
#[test]
fn issue_deserializes_all_optional_fields() {
let json = r#"{
"id": "iss-001",
"appId": "app-123",
"serviceName": "payment-service",
"env": "production",
"severity": "ERROR",
"status": "open",
"title": "High error rate",
"summary": "Error rate exceeded threshold",
"score": 0.95,
"firstSeen": "2024-01-15T10:00:00Z",
"lastSeen": "2024-01-15T11:00:00Z",
"acknowledgedAt": null,
"acknowledgedBy": null,
"resolvedAt": null,
"resolvedBy": null
}"#;
let issue: Issue = serde_json::from_str(json).unwrap();
assert_eq!(issue.id, "iss-001");
assert_eq!(issue.service_name.as_deref(), Some("payment-service"));
assert_eq!(issue.severity.as_deref(), Some("ERROR"));
assert_eq!(issue.title.as_deref(), Some("High error rate"));
assert_eq!(issue.score, Some(0.95));
}
#[test]
fn issue_deserializes_with_only_required_id() {
let json = r#"{"id": "iss-002"}"#;
let issue: Issue = serde_json::from_str(json).unwrap();
assert_eq!(issue.id, "iss-002");
assert!(issue.severity.is_none());
assert!(issue.service_name.is_none());
}
#[test]
fn issues_list_deserializes_bare_array() {
let json = r#"[
{"id": "iss-001", "severity": "ALERT"},
{"id": "iss-002", "severity": "INCIDENT"}
]"#;
let issues: Vec<Issue> = serde_json::from_str(json).unwrap();
assert_eq!(issues.len(), 2);
assert_eq!(issues[0].id, "iss-001");
}
#[test]
fn issues_list_deserializes_empty_array() {
let issues: Vec<Issue> = serde_json::from_str("[]").unwrap();
assert!(issues.is_empty());
}
#[test]
fn ack_response_deserializes_partial() {
let json = r#"{"id": "iss-001", "acknowledgedAt": "2024-01-15T10:05:00Z", "acknowledgedBy": "alice"}"#;
let response: AckResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.id.as_deref(), Some("iss-001"));
assert_eq!(response.acknowledged_by.as_deref(), Some("alice"));
}
#[test]
fn title_truncation_logic() {
let long_title = "A".repeat(35);
let truncated = if long_title.chars().count() > 30 {
format!("{}…", long_title.chars().take(29).collect::<String>())
} else {
long_title.clone()
};
assert_eq!(truncated.chars().count(), 30);
}
#[test]
fn title_truncation_handles_multibyte_chars() {
let long_title = "é".repeat(35);
let truncated = if long_title.chars().count() > 30 {
format!("{}…", long_title.chars().take(29).collect::<String>())
} else {
long_title.clone()
};
assert_eq!(truncated.chars().count(), 30);
}
}