From 736f067af2b756da26081a12205466beeb73e431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20Faruk=20Y=C4=B1=C4=9F=C4=B1n?= <42890397+omert11@users.noreply.github.com> Date: Wed, 20 May 2026 11:52:45 +0300 Subject: [PATCH] feat(ticket): add tags, owner, organization, attachments + bug fixes - ticket create: add --owner, --organization, --tags, --attachments - ticket update: add --customer, --organization, --tags-add, --tags-remove - ticket article add: add --attachments (base64 inline) - new `tags` command group: list/add/remove on any object (default Ticket) - ticket overview: warn when state hits per_page=100 cap (undercount risk) - ticket search/list: visible aliases for --limit/--per-page consistency - client: explicit User-Agent header (Cloudflare WAF 1010 bypass) - client: raw response body in error output for easier debugging - client: add DELETE method - tags remove: use HTTP DELETE (was POST, Zammad returns 404 on POST) - article response: parse + render attachments list (filename + size) - create payload: priority/state as plain string (wrapping in {"name":...} triggered ActiveSupport::HashWithIndifferentAccess cast error) - tag operations: fire concurrently via try_join_all - build_attachments: async + tokio::fs::read to avoid blocking the runtime Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 24 +++++ Cargo.toml | 4 +- src/client.rs | 31 ++++-- src/commands/mod.rs | 1 + src/commands/tags.rs | 113 +++++++++++++++++++++ src/commands/ticket.rs | 217 ++++++++++++++++++++++++++++++++++++----- src/main.rs | 8 +- src/output.rs | 7 ++ src/types.rs | 14 +++ src/util.rs | 50 ++++++++++ 10 files changed, 434 insertions(+), 35 deletions(-) create mode 100644 src/commands/tags.rs diff --git a/Cargo.lock b/Cargo.lock index 830bcb3..e0ce716 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -787,6 +787,22 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.2.0" @@ -1496,6 +1512,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1970,10 +1992,12 @@ name = "zammad-cli" version = "0.1.0" dependencies = [ "anyhow", + "base64", "clap", "colored", "comfy-table", "futures", + "mime_guess", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 5b788b7..75fe425 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,13 +13,15 @@ path = "src/main.rs" [dependencies] clap = { version = "4.6", features = ["derive"] } reqwest = { version = "0.13", default-features = false, features = ["json", "query", "rustls", "webpki-roots"] } -tokio = { version = "1.52", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.52", features = ["macros", "rt-multi-thread", "fs"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" anyhow = "1.0" colored = "3.1" comfy-table = "7.2" futures = "0.3" +base64 = "0.22" +mime_guess = "2.0" [profile.release] strip = true diff --git a/src/client.rs b/src/client.rs index 1b7dd80..8b6bef1 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,11 +1,13 @@ use anyhow::{anyhow, Context, Result}; -use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE, USER_AGENT}; use reqwest::{Client, Method, Response, StatusCode}; use serde::Serialize; use serde_json::Value; use crate::config::Config; +const UA: &str = concat!("zammad-cli/", env!("CARGO_PKG_VERSION")); + pub struct ZammadClient { http: Client, base_url: String, @@ -20,6 +22,8 @@ impl ZammadClient { HeaderValue::from_str(&auth).context("Invalid token characters")?, ); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + // Explicit UA — bypasses Cloudflare WAF default-UA blocks (error 1010) + headers.insert(USER_AGENT, HeaderValue::from_static(UA)); let http = Client::builder() .default_headers(headers) .timeout(std::time::Duration::from_secs(30)) @@ -61,6 +65,15 @@ impl ZammadClient { pub async fn put(&self, path: &str, body: Option<&B>) -> Result { self.request::<(), B>(Method::PUT, path, None, body).await } + + pub async fn delete( + &self, + path: &str, + body: Option<&B>, + ) -> Result { + self.request::<(), B>(Method::DELETE, path, None, body) + .await + } } async fn handle_response(resp: Response) -> Result { @@ -77,15 +90,17 @@ async fn handle_response(resp: Response) -> Result { } let body = resp.text().await.unwrap_or_default(); - let msg = serde_json::from_str::(&body) - .ok() + let parsed = serde_json::from_str::(&body).ok(); + let msg = parsed + .as_ref() .and_then(|v| { - v.get("error") + v.get("error_human") + .or_else(|| v.get("error")) .or_else(|| v.get("message")) .and_then(|m| m.as_str()) .map(|s| s.to_string()) }) - .unwrap_or(body); + .unwrap_or_else(|| body.clone()); let prefix = match status { StatusCode::NOT_FOUND => "Not found (404)", @@ -95,5 +110,9 @@ async fn handle_response(resp: Response) -> Result { StatusCode::UNPROCESSABLE_ENTITY => "Unprocessable (422)", _ => "Zammad API error", }; - Err(anyhow!("{prefix}: {msg}")) + if !body.is_empty() && body.trim() != msg.trim() { + Err(anyhow!("{prefix}: {msg}\n--- raw: {body}")) + } else { + Err(anyhow!("{prefix}: {msg}")) + } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 68122d0..4085516 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod org; pub mod system; +pub mod tags; pub mod ticket; pub mod user; diff --git a/src/commands/tags.rs b/src/commands/tags.rs new file mode 100644 index 0000000..6246d53 --- /dev/null +++ b/src/commands/tags.rs @@ -0,0 +1,113 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::client::ZammadClient; +use crate::output; + +pub const TICKET_OBJECT: &str = "Ticket"; + +#[derive(Subcommand)] +pub enum TagsCmd { + /// List tags attached to an object (e.g. `tags list --object Ticket --id 42`) + List { + #[arg(long, default_value = TICKET_OBJECT)] + object: String, + #[arg(long)] + id: i64, + }, + /// Add a tag to an object + Add { + #[arg(long, default_value = TICKET_OBJECT)] + object: String, + #[arg(long)] + id: i64, + #[arg(long)] + name: String, + }, + /// Remove a tag from an object + Remove { + #[arg(long, default_value = TICKET_OBJECT)] + object: String, + #[arg(long)] + id: i64, + #[arg(long)] + name: String, + }, +} + +pub async fn run(cmd: TagsCmd, client: &ZammadClient, json: bool) -> Result<()> { + match cmd { + TagsCmd::List { object, id } => list(client, &object, id, json).await, + TagsCmd::Add { object, id, name } => { + tag_op(client, "add", &object, id, &name).await?; + if json { + output::emit_value(&serde_json::json!({"ok": true, "op": "add", "tag": name})) + } else { + output::print_message(&format!("Tag '{name}' added on {object} #{id}")); + Ok(()) + } + } + TagsCmd::Remove { object, id, name } => { + tag_op(client, "remove", &object, id, &name).await?; + if json { + output::emit_value(&serde_json::json!({"ok": true, "op": "remove", "tag": name})) + } else { + output::print_message(&format!("Tag '{name}' removed on {object} #{id}")); + Ok(()) + } + } + } +} + +/// Single tag add/remove call. Zammad takes one `item` per request and +/// requires DELETE (not POST) for `/tags/remove`. +pub async fn tag_op( + client: &ZammadClient, + op: &str, + object: &str, + id: i64, + name: &str, +) -> Result<()> { + let path = format!("/api/v1/tags/{op}"); + let payload = serde_json::json!({ + "object": object, + "o_id": id, + "item": name, + }); + match op { + "add" => { + client.post(&path, Some(&payload)).await?; + } + "remove" => { + client.delete(&path, Some(&payload)).await?; + } + _ => anyhow::bail!("Unknown tag op: {op}"), + } + Ok(()) +} + +async fn list(client: &ZammadClient, object: &str, id: i64, json: bool) -> Result<()> { + let params = vec![("object", object.to_string()), ("o_id", id.to_string())]; + let value = client.get("/api/v1/tags", Some(¶ms)).await?; + if json { + return output::emit_value(&value); + } + let tags = value + .get("tags") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|t| t.as_str().map(|s| s.to_string())) + .collect::>() + }) + .unwrap_or_default(); + if tags.is_empty() { + output::print_message(&format!("No tags on {object} #{id}")); + } else { + for t in &tags { + println!(" {t}"); + } + output::print_message(&format!("{} tags", tags.len())); + } + Ok(()) +} diff --git a/src/commands/ticket.rs b/src/commands/ticket.rs index 30ec554..d1656ab 100644 --- a/src/commands/ticket.rs +++ b/src/commands/ticket.rs @@ -4,16 +4,17 @@ use futures::future::try_join_all; use serde_json::{Map, Value}; use crate::client::ZammadClient; +use crate::commands::tags::{tag_op, TICKET_OBJECT}; use crate::output; use crate::types::{Article, Ticket}; -use crate::util::{build_search_query, insert_opt_str}; +use crate::util::{build_attachments_opt, build_search_query, insert_opt_str, split_csv}; #[derive(Subcommand)] pub enum TicketCmd { /// Search tickets by text query (Zammad search syntax accepted) Search { query: String, - #[arg(long, default_value_t = 20)] + #[arg(long, visible_alias = "per-page", default_value_t = 20)] limit: u32, }, /// List tickets with filters @@ -32,7 +33,7 @@ pub enum TicketCmd { priority: Option, #[arg(long, default_value_t = 1)] page: u32, - #[arg(long, default_value_t = 20)] + #[arg(long, visible_alias = "limit", default_value_t = 20)] per_page: u32, }, /// Get ticket details by `#NUMBER` (ticket number) or plain integer (internal ID) @@ -53,6 +54,18 @@ pub enum TicketCmd { priority: String, #[arg(long, default_value = "new")] state: String, + /// Assign owner (email or login) + #[arg(long)] + owner: Option, + /// Attach to organization (by name) + #[arg(long)] + organization: Option, + /// Comma-separated tags to add after creation (e.g. "billing,urgent") + #[arg(long)] + tags: Option, + /// Comma-separated file paths to attach to the initial article + #[arg(long)] + attachments: Option, }, /// Update ticket (`#NUMBER` or internal ID) Update { @@ -65,6 +78,16 @@ pub enum TicketCmd { owner: Option, #[arg(long)] title: Option, + #[arg(long)] + customer: Option, + #[arg(long)] + organization: Option, + /// Comma-separated tags to add (e.g. "billing,urgent") + #[arg(long = "tags-add")] + tags_add: Option, + /// Comma-separated tags to remove + #[arg(long = "tags-remove")] + tags_remove: Option, }, /// Article subcommands Article { @@ -87,6 +110,9 @@ pub enum ArticleCmd { /// Mark as public reply (default: internal note) #[arg(long)] public: bool, + /// Comma-separated file paths to attach + #[arg(long)] + attachments: Option, }, } @@ -126,21 +152,61 @@ pub async fn run(cmd: TicketCmd, client: &ZammadClient, json: bool) -> Result<() customer, priority, state, - } => create(client, title, body, group, customer, priority, state, json).await, + owner, + organization, + tags, + attachments, + } => { + create( + client, + title, + body, + group, + customer, + priority, + state, + owner, + organization, + tags, + attachments, + json, + ) + .await + } TicketCmd::Update { id, state, priority, owner, title, - } => update(client, &id, state, priority, owner, title, json).await, + customer, + organization, + tags_add, + tags_remove, + } => { + update( + client, + &id, + state, + priority, + owner, + title, + customer, + organization, + tags_add, + tags_remove, + json, + ) + .await + } TicketCmd::Article { cmd } => match cmd { ArticleCmd::Add { id, body, subject, public, - } => article_add(client, &id, body, subject, !public, json).await, + attachments, + } => article_add(client, &id, body, subject, !public, attachments, json).await, }, TicketCmd::Overview => overview(client, json).await, } @@ -243,27 +309,48 @@ async fn create( customer: Option, priority: String, state: String, + owner: Option, + organization: Option, + tags: Option, + attachments: Option, json: bool, ) -> Result<()> { + let attachment_objs = build_attachments_opt(attachments.as_deref()).await?; + + let mut article = serde_json::json!({ + "subject": title, + "body": body_text, + "type": "note", + "internal": false, + }); + if !attachment_objs.is_empty() { + article["attachments"] = Value::Array(attachment_objs); + } + let mut payload: Map = Map::new(); payload.insert("title".into(), Value::String(title.clone())); payload.insert("group".into(), Value::String(group)); - payload.insert("priority".into(), serde_json::json!({ "name": priority })); - payload.insert("state".into(), serde_json::json!({ "name": state })); - payload.insert( - "article".into(), - serde_json::json!({ - "subject": title, - "body": body_text, - "type": "note", - "internal": false, - }), - ); + // Plain name string. Wrapping in `{"name": ...}` triggers Zammad + // ActiveSupport::HashWithIndifferentAccess cast error on POST. + payload.insert("priority".into(), Value::String(priority)); + payload.insert("state".into(), Value::String(state)); + payload.insert("article".into(), article); insert_opt_str(&mut payload, "customer", customer); + insert_opt_str(&mut payload, "owner", owner); + insert_opt_str(&mut payload, "organization", organization); let value = client .post("/api/v1/tickets", Some(&Value::Object(payload))) .await?; + + // Tags require a separate call per item — Zammad does not accept tags in the create payload + let tag_list = tags.as_deref().map(split_csv).unwrap_or_default(); + if !tag_list.is_empty() { + if let Some(ticket_id) = value.get("id").and_then(|v| v.as_i64()) { + apply_tags(client, ticket_id, &tag_list, true).await?; + } + } + if json { return output::emit_value(&value); } @@ -272,6 +359,7 @@ async fn create( Ok(()) } +#[allow(clippy::too_many_arguments)] async fn update( client: &ZammadClient, id_str: &str, @@ -279,6 +367,10 @@ async fn update( priority: Option, owner: Option, title: Option, + customer: Option, + organization: Option, + tags_add: Option, + tags_remove: Option, json: bool, ) -> Result<()> { let mut body: Map = Map::new(); @@ -286,17 +378,42 @@ async fn update( insert_opt_str(&mut body, "priority", priority); insert_opt_str(&mut body, "owner", owner); insert_opt_str(&mut body, "title", title); - if body.is_empty() { + insert_opt_str(&mut body, "customer", customer); + insert_opt_str(&mut body, "organization", organization); + + let add_list = tags_add.as_deref().map(split_csv).unwrap_or_default(); + let remove_list = tags_remove.as_deref().map(split_csv).unwrap_or_default(); + + if body.is_empty() && add_list.is_empty() && remove_list.is_empty() { anyhow::bail!("No fields provided to update"); } let resolved = resolve_ticket_id(client, id_str).await?; - let value = client - .put( - &format!("/api/v1/tickets/{resolved}"), - Some(&Value::Object(body)), - ) - .await?; + + let value = if body.is_empty() { + // No PUT payload — just fetch current state for output after tag changes + client + .get( + &format!("/api/v1/tickets/{resolved}"), + Some(&[("expand", "true")]), + ) + .await? + } else { + client + .put( + &format!("/api/v1/tickets/{resolved}"), + Some(&Value::Object(body)), + ) + .await? + }; + + if !add_list.is_empty() { + apply_tags(client, resolved, &add_list, true).await?; + } + if !remove_list.is_empty() { + apply_tags(client, resolved, &remove_list, false).await?; + } + if json { return output::emit_value(&value); } @@ -305,21 +422,43 @@ async fn update( Ok(()) } +/// Apply tag add/remove for a ticket. Zammad takes one item per request, +/// so calls are fired concurrently to amortize latency. +async fn apply_tags( + client: &ZammadClient, + ticket_id: i64, + tags: &[String], + add: bool, +) -> Result<()> { + let op = if add { "add" } else { "remove" }; + let futs = tags + .iter() + .map(|t| tag_op(client, op, TICKET_OBJECT, ticket_id, t)); + try_join_all(futs).await?; + Ok(()) +} + async fn article_add( client: &ZammadClient, id_str: &str, body_text: String, subject: Option, internal: bool, + attachments: Option, json: bool, ) -> Result<()> { let resolved = resolve_ticket_id(client, id_str).await?; + let attachment_objs = build_attachments_opt(attachments.as_deref()).await?; + let mut body: Map = Map::new(); body.insert("ticket_id".into(), Value::Number(resolved.into())); body.insert("body".into(), Value::String(body_text)); body.insert("type".into(), Value::String("note".into())); body.insert("internal".into(), Value::Bool(internal)); insert_opt_str(&mut body, "subject", subject); + if !attachment_objs.is_empty() { + body.insert("attachments".into(), Value::Array(attachment_objs)); + } let value = client .post("/api/v1/ticket_articles", Some(&Value::Object(body))) @@ -334,31 +473,55 @@ async fn article_add( } async fn overview(client: &ZammadClient, json: bool) -> Result<()> { + const PAGE_CAP: usize = 100; let states = ["new", "open", "pending reminder", "pending close", "closed"]; let futs = states.iter().map(|s| async move { let params = vec![ ("query", format!("state.name:{s}")), - ("per_page", "100".to_string()), + ("per_page", PAGE_CAP.to_string()), ]; let value = client.get("/api/v1/tickets/search", Some(¶ms)).await?; let count = value.as_array().map(|a| a.len()).unwrap_or(0); Ok::<_, anyhow::Error>((s.to_string(), count)) }); let summary = try_join_all(futs).await?; + let capped: Vec<&str> = summary + .iter() + .filter(|(_, n)| *n >= PAGE_CAP) + .map(|(s, _)| s.as_str()) + .collect(); if json { - let map: Map = summary + let mut map: Map = summary .iter() .map(|(s, n)| (s.clone(), Value::Number((*n as i64).into()))) .collect(); + if !capped.is_empty() { + map.insert( + "_warning".into(), + Value::String(format!( + "states at per_page cap ({PAGE_CAP}) — true count may be higher: {}", + capped.join(", ") + )), + ); + } return output::emit_value(&Value::Object(map)); } use colored::Colorize; println!("{}", "Ticket Overview".bold().underline()); for (s, n) in &summary { - println!(" {:<20} {}", s, n.to_string().bold()); + let suffix = if *n >= PAGE_CAP { "+" } else { "" }; + println!(" {:<20} {}{}", s, n.to_string().bold(), suffix.yellow()); + } + if !capped.is_empty() { + eprintln!( + "{} states hit per_page cap ({}): {} — counts may undercount", + "warning:".yellow().bold(), + PAGE_CAP, + capped.join(", ") + ); } Ok(()) } diff --git a/src/main.rs b/src/main.rs index 4f82da5..dd573c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ mod output; mod types; mod util; -use commands::{org, system, ticket, user}; +use commands::{org, system, tags, ticket, user}; #[derive(Parser)] #[command(name = "zammad-cli")] @@ -45,6 +45,11 @@ enum Commands { #[command(subcommand)] cmd: system::SystemCmd, }, + /// Tag operations (list/add/remove on any object) + Tags { + #[command(subcommand)] + cmd: tags::TagsCmd, + }, } #[tokio::main] @@ -58,5 +63,6 @@ async fn main() -> Result<()> { Commands::Org { cmd } => org::run(cmd, &client, cli.json).await, Commands::User { cmd } => user::run(cmd, &client, cli.json).await, Commands::System { cmd } => system::run(cmd, &client, cli.json).await, + Commands::Tags { cmd } => tags::run(cmd, &client, cli.json).await, } } diff --git a/src/output.rs b/src/output.rs index a55ba06..91d987d 100644 --- a/src/output.rs +++ b/src/output.rs @@ -136,6 +136,13 @@ pub fn print_articles(articles: &[Article]) { for line in a.body.lines() { println!(" {line}"); } + if !a.attachments.is_empty() { + println!(" {}", "Attachments:".bold()); + for att in &a.attachments { + let size = att.size.as_deref().unwrap_or("?"); + println!(" - {} ({} bytes)", att.filename, size); + } + } println!("{}", "---".dimmed()); } println!( diff --git a/src/types.rs b/src/types.rs index 178fe9c..3b62c47 100644 --- a/src/types.rs +++ b/src/types.rs @@ -42,6 +42,20 @@ pub struct Article { pub body: String, #[serde(default)] pub internal: bool, + #[serde(default)] + pub attachments: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArticleAttachment { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub filename: String, + #[serde(default)] + pub size: Option, + #[serde(default, rename = "preferences")] + pub preferences: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/util.rs b/src/util.rs index 0011fe2..6f44a04 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,4 +1,54 @@ +use anyhow::{Context, Result}; +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; use serde_json::{Map, Value}; +use std::path::Path; + +/// Split a comma-separated CSV string into trimmed, non-empty tokens. +pub fn split_csv(s: &str) -> Vec { + s.split(',') + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect() +} + +/// Read each file path and build Zammad article `attachments[]` entries +/// (`filename`, `data` (base64), `mime-type`). Async to avoid blocking the tokio worker. +pub async fn build_attachments(paths: &[String]) -> Result> { + let mut out = Vec::with_capacity(paths.len()); + for p in paths { + let bytes = tokio::fs::read(p) + .await + .with_context(|| format!("Failed to read attachment: {p}"))?; + let path = Path::new(p); + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("attachment") + .to_string(); + let mime = mime_guess::from_path(path) + .first_or_octet_stream() + .essence_str() + .to_string(); + out.push(serde_json::json!({ + "filename": filename, + "data": B64.encode(&bytes), + "mime-type": mime, + })); + } + Ok(out) +} + +/// Parse a comma-separated attachment-path string and read them into JSON entries. +/// Returns an empty vec when `csv` is `None` or empty. +pub async fn build_attachments_opt(csv: Option<&str>) -> Result> { + let paths = csv.map(split_csv).unwrap_or_default(); + if paths.is_empty() { + Ok(Vec::new()) + } else { + build_attachments(&paths).await + } +} pub fn truncate(s: &str, max: usize) -> String { if s.chars().count() <= max {