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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ISO8601>`. 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)
Expand Down
2 changes: 2 additions & 0 deletions skills/zammad-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 53 additions & 8 deletions src/commands/ticket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -72,6 +74,10 @@ pub enum TicketCmd {
id: String,
#[arg(long)]
state: Option<String>,
/// 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<String>,
#[arg(long)]
priority: Option<String>,
#[arg(long)]
Expand Down Expand Up @@ -203,6 +209,7 @@ pub async fn run(cmd: TicketCmd, client: &ZammadClient, json: bool) -> Result<()
TicketCmd::Update {
id,
state,
pending_time,
priority,
owner,
title,
Expand All @@ -215,6 +222,7 @@ pub async fn run(cmd: TicketCmd, client: &ZammadClient, json: bool) -> Result<()
client,
&id,
state,
pending_time,
priority,
owner,
title,
Expand Down Expand Up @@ -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
Expand All @@ -434,9 +442,7 @@ async fn attachment_download(

let mut saved: Vec<Value> = 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)
Expand Down Expand Up @@ -549,6 +555,7 @@ async fn update(
client: &ZammadClient,
id_str: &str,
state: Option<String>,
pending_time: Option<String>,
priority: Option<String>,
owner: Option<String>,
title: Option<String>,
Expand All @@ -558,8 +565,46 @@ async fn update(
tags_remove: Option<String>,
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 <ISO8601> (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<String, Value> = 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);
Expand Down
98 changes: 98 additions & 0 deletions src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,72 @@ pub fn insert_opt_str(body: &mut Map<String, Value>, key: &str, value: Option<St
}
}

/// Minimal ISO 8601 date-time validation without pulling in a date crate.
///
/// Accepts `YYYY-MM-DDTHH:MM` with optional `:SS`, optional fractional seconds
/// (`.sss`), and an optional zone suffix (`Z` or `±HH:MM`). Field ranges are
/// sanity-checked (month 1-12, day 1-31, hour 0-23, minute/second 0-59) so an
/// obvious typo (`2026-13-40`, `tomorrow`) fails locally instead of surfacing
/// Zammad's opaque 422. It is a shape check, not a full calendar validator
/// (e.g. it does not reject Feb 30) — Zammad makes the final ruling.
pub fn is_iso8601_datetime(s: &str) -> 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::<u32>().ok();
let in_range = |v: Option<u32>, 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::<u32>().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::<u32>().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<S: AsRef<str>>(parts: &[(&str, S)]) -> String {
Expand All @@ -84,3 +150,35 @@ pub fn build_search_query<S: AsRef<str>>(parts: &[(&str, S)]) -> String {
.collect::<Vec<_>>()
.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
}
}
Loading