diff --git a/CLAUDE.md b/CLAUDE.md index 3f5c960..f33b947 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,7 @@ skills/zammad-cli/SKILL.md Claude Code skill (workflow wrapper) - `priority.name:"3 high"` - `owner.email:agent@example.com` - Ticket states: `new`, `open`, `closed`, `pending reminder`, `pending close` + - `pending close` / `pending reminder` Zammad'da `pending_time` (ISO 8601) zorunlu kılar → `ticket update --pending-time `. CLI eksikse erken hata verir (HTTP'ye gitmeden). - Ticket priorities: `1 low`, `2 normal`, `3 high` - Article `internal: true` (default) = dahili not, `false` = halka açık yanıt - `ticket overview` 5 state için paralel `try_join_all` ile fetch yapar (per_page=100, .len() ile sayar — büyük instance'larda undercount riski var) diff --git a/skills/zammad-cli/SKILL.md b/skills/zammad-cli/SKILL.md index b811051..0dad331 100644 --- a/skills/zammad-cli/SKILL.md +++ b/skills/zammad-cli/SKILL.md @@ -57,6 +57,8 @@ zammad-cli --json ticket create \ # Update (DESTRUCTIVE — onay al) zammad-cli --json ticket update #61234 --state closed zammad-cli --json ticket update #61234 --priority "3 high" --owner agent@example.com +# Pending state → --pending-time (ISO 8601) ZORUNLU +zammad-cli --json ticket update #61234 --state "pending close" --pending-time 2026-06-18T17:00:00Z # Article add (DESTRUCTIVE — onay al, public yorum müşteriye gider) zammad-cli --json ticket article add #61234 --body "..." # internal default diff --git a/src/commands/ticket.rs b/src/commands/ticket.rs index 055620a..d7c18ce 100644 --- a/src/commands/ticket.rs +++ b/src/commands/ticket.rs @@ -7,7 +7,9 @@ use crate::client::ZammadClient; use crate::commands::tags::{tag_op, TICKET_OBJECT}; use crate::output; use crate::types::{Article, Ticket}; -use crate::util::{build_attachments_opt, build_search_query, insert_opt_str, split_csv}; +use crate::util::{ + build_attachments_opt, build_search_query, insert_opt_str, is_iso8601_datetime, split_csv, +}; #[derive(Subcommand)] pub enum TicketCmd { @@ -72,6 +74,10 @@ pub enum TicketCmd { id: String, #[arg(long)] state: Option, + /// ISO 8601 timestamp for pending states (e.g. 2026-06-18T17:00:00Z). + /// Required when --state is "pending close" or "pending reminder". + #[arg(long = "pending-time")] + pending_time: Option, #[arg(long)] priority: Option, #[arg(long)] @@ -203,6 +209,7 @@ pub async fn run(cmd: TicketCmd, client: &ZammadClient, json: bool) -> Result<() TicketCmd::Update { id, state, + pending_time, priority, owner, title, @@ -215,6 +222,7 @@ pub async fn run(cmd: TicketCmd, client: &ZammadClient, json: bool) -> Result<() client, &id, state, + pending_time, priority, owner, title, @@ -413,10 +421,10 @@ async fn attachment_download( return Err(anyhow!("Ticket {resolved} has no attachments")); } } else { - let article_id = article - .ok_or_else(|| anyhow!("Provide --article and --attachment, or --all"))?; - let att_id = attachment - .ok_or_else(|| anyhow!("Provide --article and --attachment, or --all"))?; + let article_id = + article.ok_or_else(|| anyhow!("Provide --article and --attachment, or --all"))?; + let att_id = + attachment.ok_or_else(|| anyhow!("Provide --article and --attachment, or --all"))?; // Resolve the filename from the article metadata when available. let filename = fetch_articles(client, resolved) .await @@ -434,9 +442,7 @@ async fn attachment_download( let mut saved: Vec = Vec::new(); for (article_id, att_id, filename) in targets { - let path = format!( - "/api/v1/ticket_attachment/{resolved}/{article_id}/{att_id}" - ); + let path = format!("/api/v1/ticket_attachment/{resolved}/{article_id}/{att_id}"); let (bytes, _ct) = client.get_bytes(&path).await?; let dest = dedupe_path(out_dir, &filename); tokio::fs::write(&dest, &bytes) @@ -549,6 +555,7 @@ async fn update( client: &ZammadClient, id_str: &str, state: Option, + pending_time: Option, priority: Option, owner: Option, title: Option, @@ -558,8 +565,46 @@ async fn update( tags_remove: Option, json: bool, ) -> Result<()> { + // Pending states require a `pending_time`; fail early with a clear message + // instead of surfacing Zammad's opaque 422 "Missing required value" error. + // Comparison is case-insensitive so "Pending Close" is treated the same as + // the canonical "pending close" and does not slip past validation. + if let Some(s) = state.as_deref() { + let is_pending = matches!( + s.to_lowercase().as_str(), + "pending close" | "pending reminder" + ); + if is_pending && pending_time.is_none() { + anyhow::bail!( + "state \"{s}\" requires --pending-time (e.g. 2026-06-18T17:00:00Z)" + ); + } + if !is_pending && pending_time.is_some() { + anyhow::bail!( + "--pending-time only applies to \"pending close\" or \"pending reminder\" states" + ); + } + } + // When --state is omitted, --pending-time is still allowed: it reschedules a + // ticket already in a pending state on the server (Zammad accepts a lone + // `pending_time` PUT). We cannot know the server-side state here, so let + // Zammad reject it if the ticket is not pending. + + // Validate the timestamp shape before sending, so an obvious typo surfaces a + // clear error instead of Zammad's opaque 422. Kept minimal (no chrono dep): + // require an ISO 8601 date+time prefix `YYYY-MM-DDTHH:MM`. + if let Some(pt) = pending_time.as_deref() { + if !is_iso8601_datetime(pt) { + anyhow::bail!( + "--pending-time \"{pt}\" is not a valid ISO 8601 timestamp \ + (e.g. 2026-06-18T17:00:00Z)" + ); + } + } + let mut body: Map = Map::new(); insert_opt_str(&mut body, "state", state); + insert_opt_str(&mut body, "pending_time", pending_time); insert_opt_str(&mut body, "priority", priority); insert_opt_str(&mut body, "owner", owner); insert_opt_str(&mut body, "title", title); diff --git a/src/util.rs b/src/util.rs index 6f44a04..f22bfe3 100644 --- a/src/util.rs +++ b/src/util.rs @@ -65,6 +65,72 @@ pub fn insert_opt_str(body: &mut Map, key: &str, value: Option bool { + let bytes = s.as_bytes(); + // Need at least "YYYY-MM-DDTHH:MM". + if bytes.len() < 16 { + return false; + } + // ASCII-only positions are required for the fixed-offset slicing below. + if !s.is_ascii() { + return false; + } + let two = |a: usize, b: usize| s[a..b].parse::().ok(); + let in_range = |v: Option, lo: u32, hi: u32| v.is_some_and(|n| n >= lo && n <= hi); + + // Fixed separators. + if &s[4..5] != "-" || &s[7..8] != "-" { + return false; + } + let date_time_sep = &s[10..11]; + if date_time_sep != "T" && date_time_sep != " " { + return false; + } + if &s[13..14] != ":" { + return false; + } + + if s[0..4].parse::().is_err() { + return false; // year (any 4 digits) + } + if !in_range(two(5, 7), 1, 12) { + return false; // month + } + if !in_range(two(8, 10), 1, 31) { + return false; // day + } + if !in_range(two(11, 13), 0, 23) { + return false; // hour + } + if !in_range(two(14, 16), 0, 59) { + return false; // minute + } + + // Optional `:SS` and everything after (fraction / zone) is left loose — + // it covers `Z`, `+03:00`, `.000Z`, etc. — Zammad does the strict parse. + let rest = &s[16..]; + if rest.is_empty() { + return true; + } + if let Some(sec) = rest.strip_prefix(':') { + // Seconds must be two digits 00-59; the remainder is the zone/fraction. + if sec.len() < 2 { + return false; + } + return in_range(sec[0..2].parse::().ok(), 0, 59); + } + // Directly a zone/fraction after HH:MM (no seconds) — accept. + true +} + /// Build Zammad search query from named filter parts (`field`, `value`). /// Auto-quotes values containing whitespace. pub fn build_search_query>(parts: &[(&str, S)]) -> String { @@ -84,3 +150,35 @@ pub fn build_search_query>(parts: &[(&str, S)]) -> String { .collect::>() .join(" AND ") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn iso8601_accepts_valid_forms() { + assert!(is_iso8601_datetime("2026-06-18T17:00:00.000Z")); + assert!(is_iso8601_datetime("2026-06-18T17:00:00Z")); + assert!(is_iso8601_datetime("2026-06-18T17:00:00")); + assert!(is_iso8601_datetime("2026-06-18T17:00")); + assert!(is_iso8601_datetime("2026-06-18T17:00:00+03:00")); + assert!(is_iso8601_datetime("2026-06-18 17:00:00Z")); // space separator + assert!(is_iso8601_datetime("2026-12-31T23:59:59Z")); + } + + #[test] + fn iso8601_rejects_invalid_forms() { + assert!(!is_iso8601_datetime("tomorrow")); + assert!(!is_iso8601_datetime("2026-13-40")); // bad month/day, no time + assert!(!is_iso8601_datetime("2026-13-01T10:00")); // month 13 + assert!(!is_iso8601_datetime("2026-06-32T10:00")); // day 32 + assert!(!is_iso8601_datetime("2026-06-18T24:00")); // hour 24 + assert!(!is_iso8601_datetime("2026-06-18T10:60")); // minute 60 + assert!(!is_iso8601_datetime("2026/06/18T10:00")); // wrong separators + assert!(!is_iso8601_datetime("2026-06-18X10:00")); // wrong date/time sep + assert!(!is_iso8601_datetime("2026-06-18T10:00:6")); // 1-digit seconds + assert!(!is_iso8601_datetime("2026-06-18T10:00:99")); // seconds 99 + assert!(!is_iso8601_datetime("")); // empty + assert!(!is_iso8601_datetime("2026-06-18")); // date only + } +}