Skip to content

Instantly share code, notes, and snippets.

@RizkiHerdaID
Created October 31, 2025 07:02
Show Gist options
  • Select an option

  • Save RizkiHerdaID/9b4b5c1f9f6c8b91bc084b68033cc5e3 to your computer and use it in GitHub Desktop.

Select an option

Save RizkiHerdaID/9b4b5c1f9f6c8b91bc084b68033cc5e3 to your computer and use it in GitHub Desktop.
JIRA Helper Script for GitHub Copilot Integration
# Jira Helper Configuration
# Your Jira instance URL (e.g., https://yourcompany.atlassian.net)
JIRA_BASE_URL=
# Your Jira email address
JIRA_EMAIL=
JIRA_USER_EMAIL=
# Your Jira API token (generate from: https://id.atlassian.com/manage-profile/security/api-tokens)
JIRA_API_TOKEN=
# Optional: Project key (default will search all projects)
JIRA_PROJECT_KEY=
# Optional: Maximum results to fetch from Jira API (default: 10)
JIRA_MAX_RESULTS=10
#!/usr/bin/env bash
# JIRA Helper Script for GitHub Copilot Integration
# Provides convenient retrieval of JIRA issue details in text or JSON.
# Enhanced help output, version flag, and graceful help display without env file present.
set -euo pipefail
IFS=$'\n\t'
VERSION="1.4.0"
# Detect script directory for relative .env
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Pre-scan arguments to allow help / version without having a valid env file
HELP_ONLY=0
for _raw_arg in "$@"; do
case "$_raw_arg" in
help|--help|-h|--version) HELP_ONLY=1; break ;;
esac
done
# Load environment variables (allow override via JIRA_ENV_FILE)
ENV_FILE=${JIRA_ENV_FILE:-"${SCRIPT_DIR}/.env.jira"}
if [[ -f "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
source "$ENV_FILE"
else
if [[ "$HELP_ONLY" -ne 1 ]]; then
echo "❌ Error: JIRA env file not found at $ENV_FILE" >&2
exit 2
fi
fi
# Basic dependency validation
for dep in curl jq; do
if ! command -v "$dep" >/dev/null 2>&1; then
echo "❌ Missing dependency: $dep (please install before continuing)" >&2
exit 3
fi
done
# Validate required env vars
missing_env=()
for v in JIRA_BASE_URL JIRA_EMAIL JIRA_API_TOKEN; do
if [[ -z "${!v:-}" ]]; then
missing_env+=("$v")
fi
done
if (( ${#missing_env[@]} )); then
echo "❌ Missing required environment variables: ${missing_env[*]}" >&2
exit 4
fi
# Flag to control whether we print full description (no truncation)
FULL_DESCRIPTION=${FULL_DESCRIPTION:-0}
FAIL_ON_MISSING=${FAIL_ON_MISSING:-0}
# Output format (text or json) for AI-friendly structured consumption.
# Can be set via env OUTPUT_FORMAT=json or flag --json
OUTPUT_FORMAT=${OUTPUT_FORMAT:-text}
# Output file (if set, write final output there) (still supported for get)
OUT_FILE=${OUT_FILE:-""}
# Caching configuration
JIRA_CACHE_DIR=${JIRA_CACHE_DIR:-"${SCRIPT_DIR}/.jira_cache"}
CACHE_TTL_SECONDS=${CACHE_TTL_SECONDS:-300} # default 5m
mkdir -p "$JIRA_CACHE_DIR" 2>/dev/null || true
# Search configuration
SEARCH_MAX_RESULTS=${SEARCH_MAX_RESULTS:-50}
SEARCH_START_AT=${SEARCH_START_AT:-0}
# Bulk operations configuration
BULK_MAX_ISSUES=${BULK_MAX_ISSUES:-20} # Max issues for get-many
DEFAULT_FIELDS=${DEFAULT_FIELDS:-"key,summary,status,assignee,reporter,issuetype,priority,created,updated,project,labels,components,fixVersions"}
# Output format options: text, json, compact, detailed, ai-summary
OUTPUT_FORMAT_MODE=${OUTPUT_FORMAT_MODE:-"detailed"}
# Smart caching configuration - different TTLs for different data types
CACHE_TTL_METADATA=${CACHE_TTL_METADATA:-86400} # 24 hours for projects, statuses, etc.
CACHE_TTL_ISSUES=${CACHE_TTL_ISSUES:-300} # 5 minutes for issues (default)
CACHE_TTL_SEARCH=${CACHE_TTL_SEARCH:-600} # 10 minutes for search results
# Rate limiting configuration
RATE_LIMIT_REQUESTS=${RATE_LIMIT_REQUESTS:-30} # Max requests per minute
RATE_LIMIT_WINDOW=${RATE_LIMIT_WINDOW:-60} # Time window in seconds
RATE_LIMIT_FILE=${RATE_LIMIT_FILE:-"${SCRIPT_DIR}/.jira_rate_limit"}
cache_path() { # key
local key="$1"
echo "$JIRA_CACHE_DIR/${key//\//_}.json"
}
cache_get() { # key
local key="$1"; local path
path="$(cache_path "$key")"
[[ -f "$path" ]] || return 1
local now epoch
now=$(date +%s)
epoch=$(stat -c %Y "$path" 2>/dev/null || echo 0)
if (( now - epoch > CACHE_TTL_SECONDS )); then
return 1
fi
cat "$path"
}
cache_set() { # key json
local key="$1"; local data="$2"; local path
path="$(cache_path "$key")"
printf '%s' "$data" > "$path"
}
# Smart cache functions with different TTLs based on data type
cache_get_smart() { # key data_type
local key="$1"; local data_type="${2:-issue}"; local path
path="$(cache_path "$key")"
[[ -f "$path" ]] || return 1
local now epoch ttl
now=$(date +%s)
epoch=$(stat -c %Y "$path" 2>/dev/null || echo 0)
case "$data_type" in
metadata) ttl=$CACHE_TTL_METADATA ;;
search) ttl=$CACHE_TTL_SEARCH ;;
*) ttl=$CACHE_TTL_ISSUES ;;
esac
if (( now - epoch > ttl )); then
return 1
fi
cat "$path"
}
cache_set_smart() { # key json data_type
local key="$1"; local data="$2"; local data_type="${3:-issue}"; local path
path="$(cache_path "$key")"
printf '%s' "$data" > "$path"
}
# Rate limiting functions
check_rate_limit() {
local now=$(date +%s)
local rate_file="$RATE_LIMIT_FILE"
# Create rate limit file if it doesn't exist
if [[ ! -f "$rate_file" ]]; then
echo "0 $now" > "$rate_file"
return 0
fi
# Read current count and window start
local count window_start
read -r count window_start < "$rate_file" 2>/dev/null || { echo "0 $now" > "$rate_file"; return 0; }
# Reset if window expired
if (( now - window_start > RATE_LIMIT_WINDOW )); then
echo "1 $now" > "$rate_file"
return 0
fi
# Check if limit exceeded
if (( count >= RATE_LIMIT_REQUESTS )); then
local wait_time=$((RATE_LIMIT_WINDOW - (now - window_start)))
echo "⏳ Rate limit reached ($RATE_LIMIT_REQUESTS requests/$RATE_LIMIT_WINDOW seconds). Wait ${wait_time}s or use cache." >&2
return 1
fi
# Increment counter
echo "$((count + 1)) $window_start" > "$rate_file"
return 0
}
# Unified curl helper with error & smart caching (GET only when no body)
http_get_json() { # key path (relative) queryString(optional) data_type(optional)
local key="$1"; shift
local endpoint="$1"; shift || true
local qs="${1:-}"; shift || true # optional query string already encoded
local data_type="${1:-issue}" # optional data type for smart caching
local cache_key
cache_key="GET_${endpoint//\//_}_${qs}"
# Try smart cache first
if [[ "$CACHE_TTL_SECONDS" -gt 0 ]]; then
if cached=$(cache_get_smart "$cache_key" "$data_type" 2>/dev/null); then
echo "$cached"
return 0
fi
fi
# Check rate limit before making request
if ! check_rate_limit; then
return 6 # Rate limit exceeded
fi
local url="$JIRA_BASE_URL$endpoint";
[[ -n "$qs" ]] && url+="?$qs"
local resp
resp=$(curl -s -w '\n%{http_code}' -u "${JIRA_EMAIL}:${JIRA_API_TOKEN}" -H 'Accept: application/json' "$url")
local body status
status="${resp##*$'\n'}"
body="${resp%$'\n'$status}"
if [[ "$status" != 2* ]]; then
echo "{""error"": ""HTTP $status"", ""cache_key"": ""$cache_key""}" >&2
echo "$body" | jq '.errorMessages? // .' >&2 || true
return 5
fi
if (( CACHE_TTL_SECONDS > 0 )); then
cache_set_smart "$cache_key" "$body" "$data_type"
fi
echo "$body"
}
# Unified curl helper for POST with JSON body (optionally cached)
http_post_json() { # key endpoint json_body
local key="$1"; shift
local endpoint="$1"; shift
local body="$1"
local cache_key="POST_${endpoint//\//_}_${key}"
# Only cache if TTL > 0
if [[ "$CACHE_TTL_SECONDS" -gt 0 ]]; then
if cached=$(cache_get "$cache_key" 2>/dev/null); then
echo "$cached"
return 0
fi
fi
local url="$JIRA_BASE_URL$endpoint"
local resp
resp=$(curl -s -w '\n%{http_code}' -u "${JIRA_EMAIL}:${JIRA_API_TOKEN}" -H 'Accept: application/json' -H 'Content-Type: application/json' -X POST -d "$body" "$url")
local body_out status
status="${resp##*$'\n'}"
body_out="${resp%$'\n'$status}"
if [[ "$status" != 2* ]]; then
echo "{\"error\": \"HTTP $status\", \"cache_key\": \"$cache_key\"}" >&2
echo "$body_out" | jq '.errorMessages? // .' >&2 || true
return 5
fi
if (( CACHE_TTL_SECONDS > 0 )); then
cache_set "$cache_key" "$body_out"
fi
echo "$body_out"
}
# Function to display help
show_help() {
echo "🎫 JIRA Helper Script (version ${VERSION})"
echo "========================================="
echo "Usage: $0 [global-flags] <command> [args]"
echo ""
echo "Commands:"
echo " get <ISSUE> Fetch detailed issue (summary, description, comments, worklog, history)"
echo " get-full <ISSUE> Same as 'get' but forces full (untruncated) description"
echo " get-many ISSUE1 ISSUE2 ... Fetch multiple issues efficiently (max: $BULK_MAX_ISSUES)"
echo " get-batch --file <file> Read issue keys from file and fetch them"
echo " search '<JQL>' Search issues using JQL (JIRA Query Language)"
echo " my-issues [MAX] Get your assigned unresolved issues (default: 50)"
echo " project <KEY> [STATUS] [MAX] Get issues for project, optionally filtered by status"
echo " recent [DAYS] [MAX] Get recently updated issues (default: 7 days, 50 results)"
echo ""
echo "Discovery & Metadata:"
echo " projects List all accessible projects"
echo " project-info <KEY> Get detailed project information"
echo " statuses List all available issue statuses"
echo " priorities List all available priorities"
echo " issue-types List all available issue types"
echo ""
echo " help Show this help (alias: --help, -h)"
echo ""
echo "Global Flags:"
echo " --json Output machine-readable JSON (same as env OUTPUT_FORMAT=json)"
echo " --full Force full description output (or set FULL_DESCRIPTION=1)"
echo " --fields <list> Comma-separated list of fields to fetch (reduces API load)"
echo " --format <mode> Output format: detailed|compact|ai-summary (default: detailed)"
echo " --out <file> Tee final output to <file>"
echo " --cache-ttl <sec> Override cache TTL seconds (CACHE_TTL_SECONDS). 0 disables cache"
echo " --no-cache Shortcut for --cache-ttl 0"
echo " --fail-on-missing Exit non-zero (7) if issue not found / inaccessible"
echo " --version Print version and exit"
echo " --help | -h Show this help and exit"
echo ""
echo "Environment Variables (override defaults without editing script):"
printf '%-20s %s\n' \
'JIRA_BASE_URL' '(required) e.g. https://your-domain.atlassian.net' \
'JIRA_EMAIL' '(required) Atlassian account email' \
'JIRA_API_TOKEN' '(required) API token (never printed)' \
'JIRA_ENV_FILE' "Env file path (default: ${SCRIPT_DIR}/.env.jira-mcp)" \
'OUTPUT_FORMAT' 'text | json (default: text)' \
'FULL_DESCRIPTION' '0 | 1 (default: 0)' \
'CACHE_TTL_SECONDS' 'Cache lifetime (default: 300)' \
'JIRA_CACHE_DIR' "Cache dir (default: ${SCRIPT_DIR}/.jira_cache)" \
'FAIL_ON_MISSING' '0 | 1 (default: 0)' \
'SEARCH_MAX_RESULTS' 'Default max results for search (default: 50)' \
'SEARCH_START_AT' 'Default pagination start (default: 0)' \
'BULK_MAX_ISSUES' 'Max issues for bulk operations (default: 20)' \
'DEFAULT_FIELDS' 'Default field list for API calls' \
'OUTPUT_FORMAT_MODE' 'detailed|compact|ai-summary (default: detailed)' \
'CACHE_TTL_METADATA' 'Cache lifetime for metadata (default: 86400s/24h)' \
'CACHE_TTL_SEARCH' 'Cache lifetime for searches (default: 600s/10m)' \
'RATE_LIMIT_REQUESTS' 'Max API requests per minute (default: 30)' \
'RATE_LIMIT_WINDOW' 'Rate limit time window (default: 60s)'
echo ""
echo "Runtime (current / effective):"
echo " Env file: ${ENV_FILE:-'(unset)'}"
echo " Output format: ${OUTPUT_FORMAT:-text}"
echo " Full desc: ${FULL_DESCRIPTION:-0}"
echo " Cache TTL: ${CACHE_TTL_SECONDS:-300}s"
echo " Cache dir: ${JIRA_CACHE_DIR:-'(unset)'}"
echo " Fail on missing: ${FAIL_ON_MISSING:-0}"
echo " Search max: ${SEARCH_MAX_RESULTS:-50}"
echo " Search start: ${SEARCH_START_AT:-0}"
echo " Bulk max: ${BULK_MAX_ISSUES:-20}"
echo " Format mode: ${OUTPUT_FORMAT_MODE:-detailed}"
echo " Selected fields: ${SELECTED_FIELDS:-$DEFAULT_FIELDS}"
echo " Metadata cache: ${CACHE_TTL_METADATA:-86400}s"
echo " Search cache: ${CACHE_TTL_SEARCH:-600}s"
echo " Rate limit: ${RATE_LIMIT_REQUESTS:-30} req/${RATE_LIMIT_WINDOW:-60}s"
echo ""
echo "Exit Codes:"
printf ' %-3s %s\n' \
0 'Success' \
1 'Generic usage / unknown command' \
2 'Env file missing (unless help/version)' \
3 'Missing dependency (curl / jq)' \
4 'Missing required env vars' \
5 'HTTP error from JIRA API' \
6 'Rate limit exceeded' \
7 'Issue not found (with --fail-on-missing)' \
11 'Missing file for --out' \
12 'Missing seconds for --cache-ttl' \
13 'Missing fields for --fields' \
14 'Missing format for --format' \
90 'Temporary file or jq processing failure'
echo ""
echo "JSON Output Contract:"
echo " get/get-full: { key, summary, status, assignee, reporter, issueType, priority, created, updated,"
echo " project:{id,key,name}, labels[], components[], fixVersions[], description:{plain,adf},"
echo " comments[], worklog[], history[], url }"
echo " search/*: { returned, isLast, nextPageToken, issues:[{key, summary, status, assignee,"
echo " reporter, issueType, priority, created, updated, project:{id,key,name}, labels[],"
echo " components[], fixVersions[], url}] }"
echo ""
echo "Examples:"
echo " $0 get BRAVO-581"
echo " $0 get-many BRAVO-581 BRAVO-582 BRAVO-583"
echo " $0 get-batch --file issues.txt"
echo " $0 search 'project = BRAVO AND status = \"In Progress\"'"
echo " $0 --fields key,summary,status --format compact my-issues"
echo " $0 --json --format ai-summary recent 1"
echo " $0 projects"
echo " $0 --format compact project-info BRAVO"
echo " $0 --json statuses | jq 'map(.name)'"
echo " $0 priorities"
echo " $0 issue-types"
echo " CACHE_TTL_METADATA=3600 $0 projects # Cache for 1 hour"
echo ""
echo "Tips:"
echo " β€’ Use caching for repeated queries; disable for fresh data during triage."
echo " β€’ JSON mode is stable for automated toolingβ€”field names are fixed."
echo " β€’ Set FULL_DESCRIPTION=1 to avoid truncation without changing scripts using 'get'."
}
show_version() { echo "jira_helper.sh version ${VERSION}"; }
# Function to format issue details with comprehensive information
format_issue() {
local response="$1"
local issue_key=$(echo "$response" | jq -r '.key')
echo "🎫 Key: $issue_key"
echo "πŸ“ Summary: $(echo "$response" | jq -r '.fields.summary')"
echo "πŸ“‹ Status: $(echo "$response" | jq -r '.fields.status.name')"
echo "πŸ‘€ Assignee: $(echo "$response" | jq -r '.fields.assignee.displayName // "Unassigned"')"
echo "πŸ‘€ Reporter: $(echo "$response" | jq -r '.fields.reporter.displayName')"
echo "🏷️ Issue Type: $(echo "$response" | jq -r '.fields.issuetype.name')"
echo "⚑ Priority: $(echo "$response" | jq -r '.fields.priority.name')"
echo "πŸ“… Created: $(echo "$response" | jq -r '.fields.created')"
echo "πŸ“… Updated: $(echo "$response" | jq -r '.fields.updated')"
echo "πŸ“ Project: $(echo "$response" | jq -r '.fields.project.name') ($(echo "$response" | jq -r '.fields.project.key'))"
echo "πŸ†” Project ID: $(echo "$response" | jq -r '.fields.project.id')"
# Show labels if any
local labels=$(echo "$response" | jq -r '.fields.labels[]?' | tr '\n' ', ' | sed 's/,$//')
if [ ! -z "$labels" ]; then
echo "🏷️ Labels: $labels"
fi
# Show components if any
local components=$(echo "$response" | jq -r '.fields.components[]?.name?' | tr '\n' ', ' | sed 's/,$//')
if [ ! -z "$components" ]; then
echo "🧩 Components: $components"
fi
# Show fix versions if any
local fix_versions=$(echo "$response" | jq -r '.fields.fixVersions[]?.name?' | tr '\n' ', ' | sed 's/,$//')
if [ ! -z "$fix_versions" ]; then
echo "πŸ”§ Fix Versions: $fix_versions"
fi
echo ""
echo "πŸ“„ Description:"
if [ "$FULL_DESCRIPTION" = "1" ]; then
# Collect all text nodes from the Atlassian Document Format description
local full_desc=$(echo "$response" | jq -r '.fields.description // empty | .content[]? | .content[]? | select(.text != null) | .text')
if [ -z "$full_desc" ]; then
echo "No description available"
else
echo "$full_desc" | sed 's/^[[:space:]]*$//'
fi
else
# Default (backward compatible) truncated output
echo "$(echo "$response" | jq -r '.fields.description.content[]?.content[]?.text // "No description available"' | head -5)"
fi
echo ""
# Get and display comments
echo "πŸ’¬ Comments:"
get_issue_comments "$issue_key"
echo ""
# Get and display worklog
echo "⏰ Work Log:"
get_issue_worklog "$issue_key"
echo ""
# Get and display recent history
echo "πŸ“œ Recent History:"
get_issue_history "$issue_key"
echo ""
echo "πŸ”— Link: ${JIRA_BASE_URL}/browse/$issue_key"
echo "----------------------------------------"
}
# Fetch-only helpers (return raw JSON without printing) for JSON aggregation
fetch_issue_comments() {
local issue_key="$1"
http_get_json "comments_$issue_key" "/rest/api/3/issue/$issue_key/comment" "orderBy=created" "issue"
}
fetch_issue_worklog() {
local issue_key="$1"
http_get_json "worklog_$issue_key" "/rest/api/3/issue/$issue_key/worklog" "" "issue"
}
fetch_issue_history() {
local issue_key="$1"
http_get_json "history_$issue_key" "/rest/api/3/issue/$issue_key/changelog" "" "issue"
}
# Produce combined JSON for a single issue (issue + comments + worklog + history)
output_json_issue() {
local issue_json="$1"
local comments_json="$2"
local worklog_json="$3"
local history_json="$4"
# Use temporary files + slurpfile to avoid hitting ARG_MAX with very large JSON payloads
# (Observed: /usr/bin/jq: Argument list too long). This keeps invocation args small.
local tmp_issue tmp_comments tmp_worklog tmp_history
tmp_issue=$(mktemp) || { echo "Failed to create temp file" >&2; return 90; }
tmp_comments=$(mktemp) || { echo "Failed to create temp file" >&2; return 90; }
tmp_worklog=$(mktemp) || { echo "Failed to create temp file" >&2; return 90; }
tmp_history=$(mktemp) || { echo "Failed to create temp file" >&2; return 90; }
printf '%s' "$issue_json" > "$tmp_issue"
printf '%s' "$comments_json" > "$tmp_comments"
printf '%s' "$worklog_json" > "$tmp_worklog"
printf '%s' "$history_json" > "$tmp_history"
jq -n --arg baseUrl "${JIRA_BASE_URL}" \
--slurpfile issue "$tmp_issue" \
--slurpfile comments "$tmp_comments" \
--slurpfile worklog "$tmp_worklog" \
--slurpfile history "$tmp_history" '
{
key: $issue[0].key,
summary: $issue[0].fields.summary,
status: $issue[0].fields.status.name,
assignee: ($issue[0].fields.assignee.displayName // "Unassigned"),
reporter: ($issue[0].fields.reporter.displayName // null),
issueType: $issue[0].fields.issuetype.name,
priority: $issue[0].fields.priority.name,
created: $issue[0].fields.created,
updated: $issue[0].fields.updated,
project: {
id: $issue[0].fields.project.id,
key: $issue[0].fields.project.key,
name: $issue[0].fields.project.name
},
labels: ($issue[0].fields.labels // []),
components: ($issue[0].fields.components // [] | map(.name)),
fixVersions: ($issue[0].fields.fixVersions // [] | map(.name)),
description: {
plain: (($issue[0].fields.description // {content: []}) | [.content[]? | .content[]? | select(.text != null) | .text] | join("\n")),
adf: ($issue[0].fields.description // null)
},
comments: ( ($comments[0].comments // []) | map({
id, created, updated, author: (.author.displayName // null),
bodyPlain: ([.body.content[]? | .content[]? | select(.text!=null) | .text] | join("\n"))
}) ),
worklog: ( ($worklog[0].worklogs // []) | map({
id, started, timeSpent, timeSpentSeconds, author: (.author.displayName // null),
comment: ([.comment.content[]? | .content[]? | select(.text!=null) | .text] | join("\n"))
}) ),
history: ( ($history[0].values // []) | map({
id, created, author: (.author.displayName // null),
items: ( .items // [] | map({ field, from, fromString, to, toString }))
}) ),
url: ($baseUrl + "/browse/" + $issue[0].key)
}
' || { echo "jq processing failed" >&2; }
# Cleanup temp files
rm -f "$tmp_issue" "$tmp_comments" "$tmp_worklog" "$tmp_history" 2>/dev/null || true
}
# Function to get issue comments
get_issue_comments() {
local issue_key="$1"
local comments_response
comments_response=$(fetch_issue_comments "$issue_key" || echo '{}')
if echo "$comments_response" | jq -e '.errorMessages' > /dev/null 2>&1; then
echo " ❌ Error retrieving comments"
return
fi
local comment_count=$(echo "$comments_response" | jq -r '.total // 0')
if [ "$comment_count" -eq 0 ]; then
echo " πŸ“­ No comments"
return
fi
echo " πŸ“Š Total Comments: $comment_count"
echo "$comments_response" | jq -r '.comments[]? | " πŸ‘€ \(.author.displayName) (\(.created | split("T")[0])): \(.body.content[]?.content[]?.text // "No content")"' | head -10
}
# Function to get issue worklog
get_issue_worklog() {
local issue_key="$1"
local worklog_response
worklog_response=$(fetch_issue_worklog "$issue_key" || echo '{}')
if echo "$worklog_response" | jq -e '.errorMessages' > /dev/null 2>&1; then
echo " ❌ Error retrieving worklog"
return
fi
local worklog_count=$(echo "$worklog_response" | jq -r '.total // 0')
if [ "$worklog_count" -eq 0 ]; then
echo " πŸ“­ No work logged"
return
fi
echo " πŸ“Š Total Work Entries: $worklog_count"
echo "$worklog_response" | jq -r '.worklogs[]? | " ⏱️ \(.author.displayName) - \(.timeSpent) on \(.started | split("T")[0]): \(.comment.content[]?.content[]?.text // "No comment")"' | head -10
}
# Function to get issue history
get_issue_history() {
local issue_key="$1"
local changelog_response
changelog_response=$(fetch_issue_history "$issue_key" || echo '{}')
if echo "$changelog_response" | jq -e '.errorMessages' > /dev/null 2>&1; then
echo " ❌ Error retrieving history"
return
fi
local history_count=$(echo "$changelog_response" | jq -r '.total // 0')
if [ "$history_count" -eq 0 ]; then
echo " πŸ“­ No history available"
return
fi
echo " πŸ“Š Total History Entries: $history_count (showing latest 100)"
echo "$changelog_response" | jq -r '.values[]? | " πŸ“ \(.author.displayName) (\(.created | split("T")[0])): \(.items[]? | "\(.field): \(.fromString // "null") β†’ \(.toString // "null")")"' | head -15
}
# Function to format issue summary (for search results - less verbose)
format_issue_summary() {
local response="$1"
local issue_key=$(echo "$response" | jq -r '.key')
case "$OUTPUT_FORMAT_MODE" in
compact)
echo "$issue_key: $(echo "$response" | jq -r '.fields.summary // "No summary"') [$(echo "$response" | jq -r '.fields.status.name // "Unknown"')]"
;;
ai-summary)
# Minimal format optimized for AI processing - one line per issue
local summary=$(echo "$response" | jq -r '.fields.summary // "No summary"')
local status=$(echo "$response" | jq -r '.fields.status.name // "Unknown"')
local assignee=$(echo "$response" | jq -r '.fields.assignee.displayName // "Unassigned"')
local priority=$(echo "$response" | jq -r '.fields.priority.name // "Unknown"')
echo "$issue_key|$summary|$status|$assignee|$priority"
;;
*)
# Default detailed format
echo "🎫 $issue_key: $(echo "$response" | jq -r '.fields.summary // "No summary"')"
if echo "$response" | jq -e '.fields.status' > /dev/null 2>&1; then
echo " πŸ“‹ Status: $(echo "$response" | jq -r '.fields.status.name') | πŸ‘€ Assignee: $(echo "$response" | jq -r '.fields.assignee.displayName // "Unassigned"')"
fi
if echo "$response" | jq -e '.fields.priority' > /dev/null 2>&1; then
echo " ⚑ Priority: $(echo "$response" | jq -r '.fields.priority.name') | πŸ“… Updated: $(echo "$response" | jq -r '.fields.updated | split("T")[0] // "Unknown"')"
fi
echo " πŸ”— ${JIRA_BASE_URL}/browse/$issue_key"
echo ""
;;
esac
}
# Function to format JSON output based on available fields and format mode
format_json_issue() {
local response="$1"
local base_url="$2"
case "$OUTPUT_FORMAT_MODE" in
compact)
echo "$response" | jq --arg baseUrl "$base_url" '{
key,
summary: (.fields.summary // null),
status: (.fields.status.name // null),
url: ($baseUrl + "/browse/" + .key)
}'
;;
ai-summary)
echo "$response" | jq --arg baseUrl "$base_url" '{
key,
summary: (.fields.summary // null),
status: (.fields.status.name // null),
assignee: (.fields.assignee.displayName // null),
priority: (.fields.priority.name // null),
updated: (.fields.updated // null)
}'
;;
*)
# Detailed format - include all available fields
echo "$response" | jq --arg baseUrl "$base_url" '{
key,
summary: (.fields.summary // null),
status: (.fields.status.name // null),
assignee: (.fields.assignee.displayName // null),
reporter: (.fields.reporter.displayName // null),
issueType: (.fields.issuetype.name // null),
priority: (.fields.priority.name // null),
created: (.fields.created // null),
updated: (.fields.updated // null),
project: (if .fields.project then {
id: .fields.project.id,
key: .fields.project.key,
name: .fields.project.name
} else null end),
labels: (.fields.labels // []),
components: (.fields.components // [] | map(.name)),
fixVersions: (.fields.fixVersions // [] | map(.name)),
url: ($baseUrl + "/browse/" + .key)
}'
;;
esac
}
# Function to perform JQL search
search_issues() {
local jql="$1"
local max_results="${2:-$SEARCH_MAX_RESULTS}"
local start_at="${3:-$SEARCH_START_AT}"
if [ -z "$jql" ]; then
echo "❌ Error: Please provide a JQL query"
echo "Usage: $0 search '<JQL_QUERY>'"
echo "Example: $0 search 'project = BRAVO AND status = \"In Progress\"'"
exit 1
fi
if [ "$OUTPUT_FORMAT" = "text" ]; then
echo "πŸ” Searching JIRA with JQL: $jql"
echo "========================================="
fi
# URL encode the JQL query
local encoded_jql=$(printf '%s' "$jql" | jq -sRr @uri)
local query_string="jql=${encoded_jql}&maxResults=${max_results}&startAt=${start_at}&fields=${SELECTED_FIELDS}"
# Create a shorter cache key to avoid filesystem limits
local cache_key_hash=$(echo "$jql$max_results$start_at" | sha256sum | cut -d' ' -f1 | head -c 16)
local search_response
if ! search_response=$(http_get_json "search_$cache_key_hash" "/rest/api/3/search/jql" "$query_string" "search"); then
echo "❌ Search failed" >&2
return 5
fi
if echo "$search_response" | jq -e '.errorMessages' > /dev/null 2>&1; then
echo "❌ Search error: $(echo "$search_response" | jq -r '.errorMessages[]?' | head -1)" >&2
return 5
fi
local returned=$(echo "$search_response" | jq -r '.issues | length')
local is_last=$(echo "$search_response" | jq -r '.isLast // true')
if [ "$OUTPUT_FORMAT" = "json" ]; then
# Output structured JSON for AI consumption with format-specific optimization
local formatted_issues=""
if [ "$OUTPUT_FORMAT_MODE" = "ai-summary" ] || [ "$OUTPUT_FORMAT_MODE" = "compact" ]; then
# Use optimized formatting for AI consumption
formatted_issues=$(echo "$search_response" | jq -c '.issues[]' | while IFS= read -r issue; do
format_json_issue "$issue" "${JIRA_BASE_URL}"
done | jq -s '.')
else
# Use detailed formatting
formatted_issues=$(echo "$search_response" | jq -c '.issues[]' | while IFS= read -r issue; do
format_json_issue "$issue" "${JIRA_BASE_URL}"
done | jq -s '.')
fi
echo "$search_response" | jq --argjson issues "$formatted_issues" '{
returned: (.issues | length),
isLast: .isLast,
nextPageToken: .nextPageToken,
issues: $issues
}'
else
if [ "$returned" -eq 0 ]; then
echo "πŸ“­ No issues found matching the query"
return 0
fi
echo "βœ… Found $returned issue(s):"
echo ""
echo "$search_response" | jq -c '.issues[]' | while IFS= read -r issue; do
format_issue_summary "$issue"
done
if [ "$is_last" != "true" ]; then
echo "πŸ“„ Showing $returned results (more pages available)"
fi
fi
}
# Function to get current user's assigned issues
get_my_issues() {
local jql="assignee = currentUser() AND resolution = Unresolved ORDER BY updated DESC"
if [ "$OUTPUT_FORMAT" = "text" ]; then
echo "πŸ” Getting your assigned issues..."
echo "========================================="
fi
search_issues "$jql" "${1:-$SEARCH_MAX_RESULTS}"
}
# Function to get issues for a specific project
get_project_issues() {
local project_key="$1"
local status_filter="${2:-}"
if [ -z "$project_key" ]; then
echo "❌ Error: Please provide a project key"
echo "Usage: $0 project PROJECT_KEY [STATUS]"
echo "Example: $0 project BRAVO"
echo "Example: $0 project BRAVO \"In Progress\""
exit 1
fi
local jql="project = \"$project_key\""
if [ -n "$status_filter" ]; then
jql="$jql AND status = \"$status_filter\""
fi
jql="$jql ORDER BY updated DESC"
if [ "$OUTPUT_FORMAT" = "text" ]; then
echo "πŸ” Getting issues for project $project_key..."
if [ -n "$status_filter" ]; then
echo " Status filter: $status_filter"
fi
echo "========================================="
fi
search_issues "$jql" "${3:-$SEARCH_MAX_RESULTS}"
}
# Function to get recently updated issues
get_recent_issues() {
local days="${1:-7}"
local jql="updated >= -${days}d ORDER BY updated DESC"
if [ "$OUTPUT_FORMAT" = "text" ]; then
echo "πŸ” Getting issues updated in the last $days days..."
echo "========================================="
fi
search_issues "$jql" "${2:-$SEARCH_MAX_RESULTS}"
}
# Function to get multiple issues efficiently using JQL IN clause
get_many_issues() {
local issue_keys=("$@")
if [ ${#issue_keys[@]} -eq 0 ]; then
echo "❌ Error: Please provide at least one issue key"
echo "Usage: $0 get-many ISSUE1 [ISSUE2] [ISSUE3] ..."
echo "Example: $0 get-many BRAVO-581 BRAVO-582 BRAVO-583"
exit 1
fi
if [ ${#issue_keys[@]} -gt $BULK_MAX_ISSUES ]; then
echo "❌ Error: Too many issues (${#issue_keys[@]}). Maximum allowed: $BULK_MAX_ISSUES"
echo "Use get-batch for larger lists or increase BULK_MAX_ISSUES"
exit 1
fi
if [ "$OUTPUT_FORMAT" = "text" ]; then
echo "πŸ” Fetching ${#issue_keys[@]} issues: ${issue_keys[*]}"
echo "========================================="
fi
# Build JQL query using IN clause for efficiency
local jql_keys=""
for key in "${issue_keys[@]}"; do
if [ -n "$jql_keys" ]; then
jql_keys="$jql_keys,$key"
else
jql_keys="$key"
fi
done
local jql="key IN ($jql_keys) ORDER BY key"
search_issues "$jql" "${#issue_keys[@]}"
}
# Function to get issues from a batch file
get_batch_issues() {
local batch_file=""
local max_results="$BULK_MAX_ISSUES"
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--file|-f)
batch_file="$2"
shift 2
;;
--max|-m)
max_results="$2"
shift 2
;;
*)
echo "❌ Unknown argument: $1"
echo "Usage: $0 get-batch --file <file> [--max <count>]"
exit 1
;;
esac
done
if [ -z "$batch_file" ]; then
echo "❌ Error: Please provide a batch file"
echo "Usage: $0 get-batch --file <file> [--max <count>]"
echo "Example: $0 get-batch --file issues.txt"
exit 1
fi
if [ ! -f "$batch_file" ]; then
echo "❌ Error: Batch file not found: $batch_file"
exit 1
fi
# Read issue keys from file (one per line, ignore empty lines and comments)
local issue_keys=()
while IFS= read -r line || [ -n "$line" ]; do
# Skip empty lines and comments
if [[ -n "$line" && ! "$line" =~ ^[[:space:]]*# ]]; then
# Trim whitespace
line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [[ -n "$line" ]]; then
issue_keys+=("$line")
fi
fi
done < "$batch_file"
if [ ${#issue_keys[@]} -eq 0 ]; then
echo "❌ Error: No valid issue keys found in $batch_file"
exit 1
fi
if [ ${#issue_keys[@]} -gt "$max_results" ]; then
echo "⚠️ Warning: Found ${#issue_keys[@]} issues, limiting to first $max_results"
issue_keys=("${issue_keys[@]:0:$max_results}")
fi
if [ "$OUTPUT_FORMAT" = "text" ]; then
echo "πŸ” Processing batch file: $batch_file"
echo "πŸ” Fetching ${#issue_keys[@]} issues..."
echo "========================================="
fi
get_many_issues "${issue_keys[@]}"
}
# Function to list all accessible projects
list_projects() {
if [ "$OUTPUT_FORMAT" = "text" ]; then
echo "πŸ” Listing accessible projects..."
echo "========================================="
fi
local projects_response
if ! projects_response=$(http_get_json "projects_list" "/rest/api/3/project" "" "metadata"); then
echo "❌ Failed to fetch projects" >&2
return 5
fi
if echo "$projects_response" | jq -e '.errorMessages' > /dev/null 2>&1; then
echo "❌ Error fetching projects: $(echo "$projects_response" | jq -r '.errorMessages[]?' | head -1)" >&2
return 5
fi
local project_count=$(echo "$projects_response" | jq -r 'length')
if [ "$OUTPUT_FORMAT" = "json" ]; then
case "$OUTPUT_FORMAT_MODE" in
compact)
echo "$projects_response" | jq 'map({key, name, projectTypeKey})'
;;
ai-summary)
echo "$projects_response" | jq 'map({key, name})'
;;
*)
echo "$projects_response" | jq 'map({
id, key, name, description,
projectTypeKey, style, lead: .lead.displayName,
url: .self
})'
;;
esac
else
if [ "$project_count" -eq 0 ]; then
echo "πŸ“­ No accessible projects found"
return 0
fi
echo "βœ… Found $project_count accessible project(s):"
echo ""
case "$OUTPUT_FORMAT_MODE" in
compact)
echo "$projects_response" | jq -r '.[] | "\(.key): \(.name)"'
;;
ai-summary)
echo "$projects_response" | jq -r '.[] | "\(.key)|\(.name)"'
;;
*)
echo "$projects_response" | jq -r '.[] | "πŸ—οΈ \(.key): \(.name)\n πŸ“ Type: \(.projectTypeKey) | πŸ‘€ Lead: \(.lead.displayName // "Unknown")\n πŸ”— \(.self)\n"'
;;
esac
fi
}
# Function to get detailed project information
get_project_info() {
local project_key="$1"
if [ -z "$project_key" ]; then
echo "❌ Error: Please provide a project key"
echo "Usage: $0 project-info PROJECT_KEY"
echo "Example: $0 project-info BRAVO"
exit 1
fi
if [ "$OUTPUT_FORMAT" = "text" ]; then
echo "πŸ” Getting project information for $project_key..."
echo "========================================="
fi
local project_response
if ! project_response=$(http_get_json "project_info_$project_key" "/rest/api/3/project/$project_key" "" "metadata"); then
echo "❌ Failed to fetch project info for $project_key" >&2
return 5
fi
if echo "$project_response" | jq -e '.errorMessages' > /dev/null 2>&1; then
echo "❌ Project not found or inaccessible: $project_key" >&2
return 5
fi
if [ "$OUTPUT_FORMAT" = "json" ]; then
case "$OUTPUT_FORMAT_MODE" in
compact)
echo "$project_response" | jq '{key, name, projectTypeKey, issueTypes: [.issueTypes[].name]}'
;;
ai-summary)
echo "$project_response" | jq '{key, name, lead: .lead.displayName}'
;;
*)
echo "$project_response" | jq '{
id, key, name, description,
projectTypeKey, style,
lead: .lead.displayName,
components: [.components[]?.name],
versions: [.versions[]?.name],
issueTypes: [.issueTypes[]?.name],
url: .self
}'
;;
esac
else
echo "βœ… Project found!"
echo ""
echo "πŸ—οΈ Key: $(echo "$project_response" | jq -r '.key')"
echo "πŸ“ Name: $(echo "$project_response" | jq -r '.name')"
echo "πŸ“‹ Description: $(echo "$project_response" | jq -r '.description // "No description"')"
echo "🏷️ Type: $(echo "$project_response" | jq -r '.projectTypeKey')"
echo "πŸ‘€ Lead: $(echo "$project_response" | jq -r '.lead.displayName // "Unknown"')"
local components=$(echo "$project_response" | jq -r '.components[]?.name?' | tr '\n' ', ' | sed 's/,$//')
if [ ! -z "$components" ]; then
echo "🧩 Components: $components"
fi
local versions=$(echo "$project_response" | jq -r '.versions[]?.name?' | tr '\n' ', ' | sed 's/,$//')
if [ ! -z "$versions" ]; then
echo "πŸ”– Versions: $versions"
fi
local issue_types=$(echo "$project_response" | jq -r '.issueTypes[]?.name?' | tr '\n' ', ' | sed 's/,$//')
if [ ! -z "$issue_types" ]; then
echo "🎭 Issue Types: $issue_types"
fi
echo ""
echo "πŸ”— Link: $(echo "$project_response" | jq -r '.self')"
echo "----------------------------------------"
fi
}
# Function to get system statuses
get_statuses() {
if [ "$OUTPUT_FORMAT" = "text" ]; then
echo "πŸ” Getting issue statuses..."
echo "========================================="
fi
local statuses_response
if ! statuses_response=$(http_get_json "statuses_list" "/rest/api/3/status" "" "metadata"); then
echo "❌ Failed to fetch statuses" >&2
return 5
fi
local status_count=$(echo "$statuses_response" | jq -r 'length')
if [ "$OUTPUT_FORMAT" = "json" ]; then
case "$OUTPUT_FORMAT_MODE" in
compact|ai-summary)
echo "$statuses_response" | jq 'map({id, name})'
;;
*)
echo "$statuses_response" | jq 'map({id, name, description, statusCategory: .statusCategory.name})'
;;
esac
else
echo "βœ… Found $status_count statuses:"
echo ""
case "$OUTPUT_FORMAT_MODE" in
compact|ai-summary)
echo "$statuses_response" | jq -r '.[] | "\(.name)"'
;;
*)
echo "$statuses_response" | jq -r '.[] | "πŸ“‹ \(.name) (\(.statusCategory.name // "Unknown"))\n πŸ“ \(.description // "No description")\n"'
;;
esac
fi
}
# Function to get system priorities
get_priorities() {
if [ "$OUTPUT_FORMAT" = "text" ]; then
echo "πŸ” Getting issue priorities..."
echo "========================================="
fi
local priorities_response
if ! priorities_response=$(http_get_json "priorities_list" "/rest/api/3/priority" "" "metadata"); then
echo "❌ Failed to fetch priorities" >&2
return 5
fi
local priority_count=$(echo "$priorities_response" | jq -r 'length')
if [ "$OUTPUT_FORMAT" = "json" ]; then
case "$OUTPUT_FORMAT_MODE" in
compact|ai-summary)
echo "$priorities_response" | jq 'map({id, name})'
;;
*)
echo "$priorities_response" | jq 'map({id, name, description})'
;;
esac
else
echo "βœ… Found $priority_count priorities:"
echo ""
case "$OUTPUT_FORMAT_MODE" in
compact|ai-summary)
echo "$priorities_response" | jq -r '.[] | "\(.name)"'
;;
*)
echo "$priorities_response" | jq -r '.[] | "⚑ \(.name)\n πŸ“ \(.description // "No description")\n"'
;;
esac
fi
}
# Function to get issue types
get_issue_types() {
if [ "$OUTPUT_FORMAT" = "text" ]; then
echo "πŸ” Getting issue types..."
echo "========================================="
fi
local types_response
if ! types_response=$(http_get_json "issue_types_list" "/rest/api/3/issuetype" "" "metadata"); then
echo "❌ Failed to fetch issue types" >&2
return 5
fi
local type_count=$(echo "$types_response" | jq -r 'length')
if [ "$OUTPUT_FORMAT" = "json" ]; then
case "$OUTPUT_FORMAT_MODE" in
compact|ai-summary)
echo "$types_response" | jq 'map({id, name})'
;;
*)
echo "$types_response" | jq 'map({id, name, description, subtask})'
;;
esac
else
echo "βœ… Found $type_count issue types:"
echo ""
case "$OUTPUT_FORMAT_MODE" in
compact|ai-summary)
echo "$types_response" | jq -r '.[] | "\(.name)"'
;;
*)
echo "$types_response" | jq -r '.[] | "🎭 \(.name) \(if .subtask then "(Subtask)" else "" end)\n πŸ“ \(.description // "No description")\n"'
;;
esac
fi
}
# Function to get a specific ticket
get_ticket() {
local ticket_id="$1"
if [ -z "$ticket_id" ]; then
echo "❌ Error: Please provide a ticket ID"
echo "Usage: $0 get TICKET-ID"
exit 1
fi
if [ "$OUTPUT_FORMAT" = "text" ]; then
echo "πŸ” Querying JIRA ticket $ticket_id..."
echo "========================================="
fi
local raw
if ! raw=$(http_get_json "issue_$ticket_id" "/rest/api/3/issue/$ticket_id" "expand=changelog" "issue"); then
if [[ "$FAIL_ON_MISSING" == 1 ]]; then
echo "❌ Ticket not found or inaccessible: $ticket_id" >&2
exit 7
fi
raw='{}'
fi
response="$raw"
if echo "$response" | jq -e '.errorMessages' > /dev/null 2>&1 || [[ $(echo "$response" | jq -r '.key // empty') == "" ]]; then
if [[ "$FAIL_ON_MISSING" == 1 ]]; then
echo "❌ Ticket not found or inaccessible: $ticket_id" >&2
exit 7
fi
echo "⚠️ Ticket not found or inaccessible (empty response used)" >&2
return 0
fi
if [ "$OUTPUT_FORMAT" = "json" ]; then
local comments_json worklog_json history_json
comments_json=$(fetch_issue_comments "$ticket_id" || echo '{}')
worklog_json=$(fetch_issue_worklog "$ticket_id" || echo '{}')
history_json=$(fetch_issue_history "$ticket_id" || echo '{}')
output_json_issue "$response" "$comments_json" "$worklog_json" "$history_json"
else
echo "βœ… Ticket found!"
echo ""
format_issue "$response"
fi
}
# (Search functionality removed: search_tickets, search_tickets_migrated, get_my_issues)
# Parse global flags (restored)
SELECTED_FIELDS="$DEFAULT_FIELDS"
new_args=()
while (( "$#" )); do
case "$1" in
--json) OUTPUT_FORMAT=json; shift ;;
--full) FULL_DESCRIPTION=1; shift ;;
--out) OUT_FILE="${2:-}"; shift 2 || { echo "Missing file for --out" >&2; exit 11; } ;;
--cache-ttl) CACHE_TTL_SECONDS="${2:-}"; shift 2 || { echo "Missing seconds for --cache-ttl" >&2; exit 12; } ;;
--no-cache) CACHE_TTL_SECONDS=0; shift ;;
--fail-on-missing) FAIL_ON_MISSING=1; shift ;;
--fields) SELECTED_FIELDS="${2:-}"; shift 2 || { echo "Missing fields for --fields" >&2; exit 13; } ;;
--format) OUTPUT_FORMAT_MODE="${2:-}"; shift 2 || { echo "Missing format for --format" >&2; exit 14; } ;;
--help|-h) show_help; exit 0 ;;
--version) show_version; exit 0 ;;
--) shift; break ;;
*) new_args+=("$1"); shift ;;
esac
done
set -- "${new_args[@]}" "$@"
# Main script logic
run_command() {
local cmd="${1:-}"; shift || true
case "$cmd" in
get) get_ticket "${1:-}" ;;
get-full) FULL_DESCRIPTION=1 get_ticket "${1:-}" ;;
get-many) get_many_issues "$@" ;;
get-batch) get_batch_issues "$@" ;;
search) search_issues "${1:-}" "${2:-}" "${3:-}" ;;
my-issues) get_my_issues "${1:-}" ;;
project) get_project_issues "${1:-}" "${2:-}" "${3:-}" ;;
recent) get_recent_issues "${1:-}" "${2:-}" ;;
projects) list_projects ;;
project-info) get_project_info "${1:-}" ;;
statuses) get_statuses ;;
priorities) get_priorities ;;
issue-types) get_issue_types ;;
help|""|-h|--help) show_help ;;
*)
echo "❌ Unknown command: $cmd" >&2
show_help
exit 1 ;;
esac
}
execute_main() {
if [[ -n "$OUT_FILE" ]]; then
run_command "$@" | tee "$OUT_FILE"
else
run_command "$@"
fi
}
execute_main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment