#!/usr/bin/env bash # bms-tickets.sh — Kaseya BMS ticket CRUD operations set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BMS_API_BASE="${BMS_API_BASE:-https://api.bms.kaseya.com}" # ─── Helpers ───────────────────────────────────────────────────────────────── die() { echo "ERROR: $*" >&2; exit 1; } get_token() { bash "${SCRIPT_DIR}/bms-auth.sh" get-token } bms_curl() { local method="$1"; shift local path="$1"; shift local token token=$(get_token) curl -sf -X "$method" \ "${BMS_API_BASE}${path}" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ "$@" } # Pretty-print a ticket list format_ticket_list() { jq -r ' (.result // .) | if type == "array" then .[] else empty end | "\(.ticketNumber // .Id)\t[\(.statusName // "?")] \(.title // "?")\t| \(.accountName // "?")\t| Assignee: \(.assigneeName // "unassigned")\t| Priority: \(.priorityName // "?")" ' | sed 's/\t/ /g' } format_ticket_detail() { jq -r ' .Data // . | "Ticket: \(.TicketNumber) (ID: \(.Id)) Title: \(.Title) Status: \(.StatusName) (ID: \(.StatusId)) Priority: \(.PriorityName) (ID: \(.PriorityId)) Account: \(.AccountName) (ID: \(.AccountId)) Location: \(.LocationName // "N/A") Contact: \(.ContactName // "N/A") Queue: \(.QueueName // "N/A") Assignee: \(.AssigneeName // "unassigned") Type: \(.TypeName // "N/A") Created: \(.CreatedOn) Modified: \(.ModifiedOn) Due: \(.DueDate // "none") --- \(.Details // "(no details)")" ' } # ─── Commands ──────────────────────────────────────────────────────────────── cmd_list() { local status="" assignee="" from="" to="" priority="" queue="" account="" local page=1 page_size=25 format="table" while [[ $# -gt 0 ]]; do case "$1" in --status) status="$2"; shift 2 ;; --assignee) assignee="$2"; shift 2 ;; --from) from="$2"; shift 2 ;; --to) to="$2"; shift 2 ;; --priority) priority="$2"; shift 2 ;; --queue) queue="$2"; shift 2 ;; --account) account="$2"; shift 2 ;; --page) page="$2"; shift 2 ;; --page-size) page_size="$2"; shift 2 ;; --format) format="$2"; shift 2 ;; *) die "Unknown option: $1" ;; esac done # Build filter JSON local filter="{" local sep="" if [[ "$status" == "open" ]]; then filter+="\"StatusNames\":\"Escalated,Open,Waiting for Customer,Waiting for Product(s),Waiting for Vendor\"" sep="," status="" fi [[ -n "$status" ]] && { filter+="${sep}\"StatusNames\":\"${status}\""; sep=","; } [[ -n "$assignee" ]] && { filter+="${sep}\"AssigneeName\":\"${assignee}\""; sep=","; } [[ -n "$from" ]] && { filter+="${sep}\"CreatedOnFrom\":\"${from}T00:00:00\""; sep=","; } [[ -n "$to" ]] && { filter+="${sep}\"CreatedOnTo\":\"${to}T23:59:59\""; sep=","; } [[ -n "$priority" ]] && { filter+="${sep}\"PriorityNames\":\"${priority}\""; sep=","; } [[ -n "$queue" ]] && { filter+="${sep}\"QueueNames\":\"${queue}\""; sep=","; } [[ -n "$account" ]] && { filter+="${sep}\"Account\":\"${account}\""; sep=","; } filter+="}" local body body=$(jq -n \ --argjson filter "$filter" \ --argjson page "$page" \ --argjson page_size "$page_size" \ '{Filter: $filter, PageNumber: $page, PageSize: $page_size}') local response response=$(bms_curl POST "/v2/servicedesk/tickets/search" -d "$body") if [[ "$format" == "json" ]]; then echo "$response" | jq . else local total total=$(echo "$response" | jq -r '.TotalCount // .Total // "?"') echo "Tickets (page ${page}, ${page_size} per page, total: ${total}):" >&2 echo "$response" | format_ticket_list fi } cmd_get() { local ticket_id="${1:-}" local format="${2:-table}" [[ -n "$ticket_id" ]] || die "Usage: bms tickets get " local response response=$(bms_curl GET "/v2/servicedesk/tickets/${ticket_id}") if [[ "$format" == "json" ]] || [[ "${2:-}" == "--json" ]]; then echo "$response" | jq . else echo "$response" | format_ticket_detail fi } # Prompt for a value if empty; first arg is field label, second is current value (by nameref) # Usage: prompt_if_empty "Label" varname prompt_if_empty() { local label="$1" local -n _ref="$2" if [[ -z "${_ref:-}" ]]; then read -r -p "${label}: " _ref fi } cmd_create() { local title="" details="" account_id="" location_id="" contact_id="" local status_id="" priority_id="" type_id="" source_id="" local assignee_id="" queue_id="" due_date="" open_date="" local template_id="" local interactive=false local response="" ticket_id="" ticket_number="" success="" while [[ $# -gt 0 ]]; do case "$1" in --template-id) template_id="$2"; shift 2 ;; --title) title="$2"; shift 2 ;; --details|--description) details="$2"; shift 2 ;; --account-id) account_id="$2"; shift 2 ;; --location-id) location_id="$2"; shift 2 ;; --contact-id) contact_id="$2"; shift 2 ;; --status-id) status_id="$2"; shift 2 ;; --priority-id) priority_id="$2"; shift 2 ;; --type-id) type_id="$2"; shift 2 ;; --source-id) source_id="$2"; shift 2 ;; --assignee-id) assignee_id="$2"; shift 2 ;; --queue-id) queue_id="$2"; shift 2 ;; --due-date) due_date="$2"; shift 2 ;; --interactive) interactive=true; shift ;; *) die "Unknown option: $1" ;; esac done # ── Template pre-fill ──────────────────────────────────────────────────── if [[ -n "$template_id" ]]; then echo "Fetching template ${template_id}..." >&2 local tmpl tmpl=$(bms_curl GET "/v2/servicedesk/templates/tickets/${template_id}" | jq '.Data // .') # Fill only fields not already set by CLI flags [[ -z "$title" ]] && title=$(echo "$tmpl" | jq -r '.Title // empty') [[ -z "$details" ]] && details=$(echo "$tmpl" | jq -r '.Details // empty') [[ -z "$status_id" ]] && status_id=$(echo "$tmpl" | jq -r '.StatusId // empty') [[ -z "$priority_id" ]] && priority_id=$(echo "$tmpl" | jq -r '.PriorityId // empty') [[ -z "$type_id" ]] && type_id=$(echo "$tmpl" | jq -r '(.TypeId // .IssueTypeId) // empty') [[ -z "$source_id" ]] && source_id=$(echo "$tmpl" | jq -r '.SourceId // empty') [[ -z "$queue_id" ]] && queue_id=$(echo "$tmpl" | jq -r '.QueueId // empty') [[ -z "$assignee_id" ]] && assignee_id=$(echo "$tmpl" | jq -r '.AssigneeId // empty') [[ -z "$account_id" ]] && account_id=$(echo "$tmpl" | jq -r '.AccountId // empty') [[ -z "$location_id" ]] && location_id=$(echo "$tmpl" | jq -r '.LocationId // empty') [[ -z "$contact_id" ]] && contact_id=$(echo "$tmpl" | jq -r '.ContactId // empty') fi # ── Interactive prompts ─────────────────────────────────────────────────── if $interactive; then prompt_if_empty "Title" title prompt_if_empty "Details" details prompt_if_empty "Account ID" account_id prompt_if_empty "Location ID" location_id prompt_if_empty "Status ID" status_id prompt_if_empty "Priority ID" priority_id prompt_if_empty "Type ID" type_id prompt_if_empty "Source ID" source_id prompt_if_empty "Queue ID (optional if Assignee ID provided)" queue_id if [[ -z "$queue_id" ]]; then prompt_if_empty "Assignee ID (optional if Queue ID provided)" assignee_id fi elif [[ -n "$template_id" ]]; then # When using a template, prompt only for fields still missing that are required [[ -n "$title" ]] || { read -r -p "Title: " title; } [[ -n "$details" ]] || { read -r -p "Details: " details; } [[ -n "$account_id" ]] || { read -r -p "Account ID: " account_id; } [[ -n "$location_id" ]] || { read -r -p "Location ID: " location_id; } [[ -n "$status_id" ]] || { read -r -p "Status ID: " status_id; } [[ -n "$priority_id" ]] || { read -r -p "Priority ID: " priority_id; } [[ -n "$type_id" ]] || { read -r -p "Type ID: " type_id; } [[ -n "$source_id" ]] || { read -r -p "Source ID: " source_id; } if [[ -z "$queue_id" && -z "$assignee_id" ]]; then read -r -p "Queue ID (or leave blank to provide Assignee ID): " queue_id if [[ -z "$queue_id" ]]; then read -r -p "Assignee ID: " assignee_id fi fi fi [[ -n "$title" ]] || die "Missing required field: --title" [[ -n "$details" ]] || die "Missing required field: --details" [[ -n "$account_id" ]] || die "Missing required field: --account-id" [[ -n "$location_id" ]] || die "Missing required field: --location-id" [[ -n "$status_id" ]] || die "Missing required field: --status-id" [[ -n "$priority_id" ]] || die "Missing required field: --priority-id" [[ -n "$type_id" ]] || die "Missing required field: --type-id" [[ -n "$source_id" ]] || die "Missing required field: --source-id" [[ -n "$queue_id" || -n "$assignee_id" ]] || die "Missing required routing: provide either --queue-id or --assignee-id" open_date="${open_date:-$(date -u +%Y-%m-%dT%H:%M:%S)}" local body body=$(jq -n \ --arg title "$title" \ --arg details "$details" \ --argjson account_id "$account_id" \ --argjson location_id "$location_id" \ --argjson status_id "$status_id" \ --argjson priority_id "$priority_id" \ --argjson type_id "$type_id" \ --argjson source_id "$source_id" \ --arg open_date "$open_date" \ '{ Title: $title, Details: $details, AccountId: $account_id, LocationId: $location_id, StatusId: $status_id, PriorityId: $priority_id, TypeId: $type_id, SourceId: $source_id, OpenDate: $open_date }') # Optionally add non-required fields [[ -n "$contact_id" ]] && body=$(echo "$body" | jq --argjson v "$contact_id" '. + {ContactId: $v}') [[ -n "$assignee_id" ]] && body=$(echo "$body" | jq --argjson v "$assignee_id" '. + {AssigneeId: $v}') [[ -n "$queue_id" ]] && body=$(echo "$body" | jq --argjson v "$queue_id" '. + {QueueId: $v}') [[ -n "$due_date" ]] && body=$(echo "$body" | jq --arg v "$due_date" '. + {DueDate: $v}') # Single create call only. No retries here. response=$(bms_curl POST "/v2/servicedesk/tickets" -d "$body") success=$(echo "$response" | jq -r '.success // .Success // empty') ticket_id=$(echo "$response" | jq -r '.Data.Id // .Id // empty') ticket_number=$(echo "$response" | jq -r '.Data.TicketNumber // .TicketNumber // empty') if [[ "$success" != "true" ]] || [[ -z "$ticket_id" ]] || [[ "$ticket_id" == "null" ]]; then echo "Create ticket failed or returned ambiguous response:" >&2 echo "$response" | jq . >&2 exit 1 fi echo "Created ticket ID: ${ticket_id} — ${ticket_number:-N/A}" } cmd_update() { local ticket_id="${1:-}" [[ -n "$ticket_id" ]] || die "Usage: bms tickets update [options]" shift # Fetch current ticket first so we can do a full PUT with changes merged local current current=$(bms_curl GET "/v2/servicedesk/tickets/${ticket_id}" | jq '.Data // .') local patch="{}" while [[ $# -gt 0 ]]; do case "$1" in --title) patch=$(echo "$patch" | jq --arg v "$2" '. + {Title: $v}'); shift 2 ;; --details) patch=$(echo "$patch" | jq --arg v "$2" '. + {Details: $v}'); shift 2 ;; --status-id) patch=$(echo "$patch" | jq --argjson v "$2" '. + {StatusId: $v}'); shift 2 ;; --priority-id) patch=$(echo "$patch" | jq --argjson v "$2" '. + {PriorityId: $v}'); shift 2 ;; --assignee-id) patch=$(echo "$patch" | jq --argjson v "$2" '. + {AssigneeId: $v}'); shift 2 ;; --queue-id) patch=$(echo "$patch" | jq --argjson v "$2" '. + {QueueId: $v}'); shift 2 ;; --due-date) patch=$(echo "$patch" | jq --arg v "$2" '. + {DueDate: $v}'); shift 2 ;; *) die "Unknown option: $1" ;; esac done # Merge patch onto current (keep required fields from current ticket) local body body=$(echo "$current" | jq \ --argjson patch "$patch" \ '{ Title: .Title, Details: .Details, AccountId: .AccountId, LocationId: .LocationId, StatusId: .StatusId, PriorityId: .PriorityId, TypeId: .TypeId, SourceId: .SourceId, OpenDate: .OpenDate, AssigneeId: .AssigneeId, QueueId: .QueueId, ContactId: .ContactId } * $patch') local response response=$(bms_curl PUT "/v2/servicedesk/tickets/${ticket_id}" -d "$body") echo "$response" | jq -r '"Updated ticket \(.Data.Id // .Id // "'"$ticket_id"'")"' } cmd_note() { local ticket_id="${1:-}" [[ -n "$ticket_id" ]] || die "Usage: bms tickets note --message [options]" shift local message="" is_internal=false type_id=1 status_id="" note_date="" while [[ $# -gt 0 ]]; do case "$1" in --message|-m) message="$2"; shift 2 ;; --internal) is_internal=true; shift ;; --type-id) type_id="$2"; shift 2 ;; --status-id) status_id="$2"; shift 2 ;; *) die "Unknown option: $1" ;; esac done [[ -n "$message" ]] || die "--message is required" note_date=$(date -u +%Y-%m-%dT%H:%M:%S) local body body=$(jq -n \ --arg details "$message" \ --argjson is_internal "$is_internal" \ --argjson type_id "$type_id" \ --arg note_date "$note_date" \ '{ Details: $details, IsInternal: $is_internal, TypeId: $type_id, NoteDate: $note_date }') [[ -n "$status_id" ]] && body=$(echo "$body" | jq --argjson v "$status_id" '. + {StatusId: $v}') local response response=$(bms_curl POST "/v2/servicedesk/tickets/${ticket_id}/notes" -d "$body") echo "$response" | jq -r '"Note added (ID: \(.Data.Id // .Id // "ok"))"' } cmd_assign() { local ticket_id="${1:-}" [[ -n "$ticket_id" ]] || die "Usage: bms tickets assign [options]" shift local assignee_id="" queue_id="" note="" is_internal=false type_id=1 status_id="" while [[ $# -gt 0 ]]; do case "$1" in --assignee-id) assignee_id="$2"; shift 2 ;; --queue-id) queue_id="$2"; shift 2 ;; --note|-n) note="$2"; shift 2 ;; --internal) is_internal=true; shift ;; --type-id) type_id="$2"; shift 2 ;; --status-id) status_id="$2"; shift 2 ;; *) die "Unknown option: $1" ;; esac done [[ -n "$note" ]] || note="Ticket reassigned." local note_date note_date=$(date -u +%Y-%m-%dT%H:%M:%S) local body body=$(jq -n \ --arg details "$note" \ --argjson is_internal "$is_internal" \ --argjson type_id "$type_id" \ --arg note_date "$note_date" \ '{ Details: $details, IsInternal: $is_internal, TypeId: $type_id, NoteDate: $note_date }') [[ -n "$status_id" ]] && body=$(echo "$body" | jq --argjson v "$status_id" '. + {StatusId: $v}') [[ -n "$assignee_id" ]] && body=$(echo "$body" | jq --argjson v "$assignee_id" '. + {AssigneeId: $v}') [[ -n "$queue_id" ]] && body=$(echo "$body" | jq --argjson v "$queue_id" '. + {QueueId: $v}') local response response=$(bms_curl POST "/v2/servicedesk/tickets/${ticket_id}/assignticket" -d "$body") echo "$response" | jq -r '"Ticket \("'"$ticket_id"'") assigned."' } cmd_resolve() { local ticket_id="${1:-}" [[ -n "$ticket_id" ]] || die "Usage: bms tickets resolve --comment [options]" shift local comment="" status_id="" publish_kb=false is_internal=false while [[ $# -gt 0 ]]; do case "$1" in --comment|-c) comment="$2"; shift 2 ;; --status-id) status_id="$2"; shift 2 ;; --publish-kb) publish_kb=true; shift ;; --internal) is_internal=true; shift ;; *) die "Unknown option: $1" ;; esac done [[ -n "$comment" ]] || die "--comment is required" local body body=$(jq -n \ --arg comment "$comment" \ --argjson publish_kb "$publish_kb" \ --argjson is_internal "$is_internal" \ '{Comment: $comment, IsPublishToKnowledgeBase: $publish_kb, IsInternal: $is_internal}') [[ -n "$status_id" ]] && body=$(echo "$body" | jq --argjson v "$status_id" '. + {StatusId: $v}') local response response=$(bms_curl POST "/v2/servicedesk/tickets/${ticket_id}/resolve" -d "$body") echo "$response" | jq -r '"Ticket \("'"$ticket_id"'") resolved."' } cmd_delete() { local ids=("$@") [[ ${#ids[@]} -gt 0 ]] || die "Usage: bms tickets delete [id2 ...]" if [[ ${#ids[@]} -eq 1 ]]; then bms_curl DELETE "/v2/servicedesk/tickets/${ids[0]}" >/dev/null echo "Deleted ticket ${ids[0]}" else local body body=$(printf '%s\n' "${ids[@]}" | jq -Rs 'split("\n") | map(select(. != "")) | map(tonumber) | {Ids: .}') bms_curl DELETE "/v2/servicedesk/tickets" -d "$body" >/dev/null echo "Deleted tickets: ${ids[*]}" fi } # ─── Dispatch ──────────────────────────────────────────────────────────────── subcmd="${1:-list}" [[ $# -gt 0 ]] && shift case "$subcmd" in list) cmd_list "$@" ;; get) cmd_get "$@" ;; create) cmd_create "$@" ;; update) cmd_update "$@" ;; note) cmd_note "$@" ;; assign) cmd_assign "$@" ;; resolve) cmd_resolve "$@" ;; delete) cmd_delete "$@" ;; *) echo "Usage: bms tickets " >&2 exit 1 ;; esac