Skip to content

Instantly share code, notes, and snippets.

@rolandkakonyi
Last active August 6, 2025 22:01
Show Gist options
  • Save rolandkakonyi/dd822ec2833f40b0f8adbecc90777e30 to your computer and use it in GitHub Desktop.
Save rolandkakonyi/dd822ec2833f40b0f8adbecc90777e30 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
# aclix - "ACLI eXtra": a thin wrapper around Atlassian CLI (acli)
# - Proxies all commands to `acli` unchanged
# - Intercepts: `jira workitem link` to create issue links via Jira REST API
# - Intercepts: `jira workitem link-types` to fetch available link types via Jira REST API
# - Provides custom completions for bash and zsh that extend acli's completions
#
# Requirements:
# - bash 4+, curl
# - Atlassian CLI (acli) - install via: brew tap atlassian/homebrew-acli && brew install acli
# - Environment variables for credentials: JIRA_EMAIL, JIRA_API_TOKEN
# - Optional: JIRA_SITE (base URL, e.g., https://your-site.atlassian.net) if --site is not provided
# - jq (recommended) for safe JSON construction (auto-detected). If missing, script falls back to minimal construction.
#
# For guidance on how to use this, see: https://gist.githubusercontent.com/rolandkakonyi/41df9c47f5e9ef797990c5a59f510c8d/raw/aclix.md (download it directly using wget or similar)
#
# Shell safety
set -euo pipefail
IFS=$'\n\t'
# Print to stderr
err() { printf '%s\n' "$*" >&2; }
# Usage help (only for the intercepted subcommand)
usage_link() {
cat <<'USAGE'
Usage: aclix jira workitem link --key ISSUE-1 --to ISSUE-2 --type TYPE [--site URL] [--comment TEXT] [--direction outward|inward] [--dry-run] [--verbose]
Create a link between Jira issues using the REST API. Credentials are read from env:
JIRA_EMAIL Atlassian account email (required)
JIRA_API_TOKEN Atlassian API token (required)
JIRA_SITE Base URL like https://your-site.atlassian.net (optional if --site is provided)
Flags:
--key KEY Source issue key (e.g., PROJ-123) (required)
--to KEY Target issue key (e.g., PROJ-456) (required)
--type TYPE Link type name or description (required)
Examples: "Blocks", "blocks", "is blocked by",
"Relates", "relates to", "Duplicates", "is duplicated by"
--site URL Jira site base URL (overrides $JIRA_SITE)
--comment TEXT Optional comment body to attach to the link
--direction D Force link direction: outward|inward
(default is derived from --type; if ambiguous, outward)
--dry-run Print the HTTP request body and exit
--verbose Verbose logging
Examples:
aclix jira workitem link --key AIC-101 --to AIC-99 --type "is blocked by" --site https://example.atlassian.net
aclix jira workitem link --key AIC-101 --to AIC-99 --type Blocks --comment "created by aclix"
USAGE
}
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
err "Missing required command: $1"; exit 127
fi
}
json_escape() {
# Fallback JSON string escaper (when jq is unavailable). Handles common cases.
# shellcheck disable=SC2001
printf '%s' "$1" \
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' -e 's/\r/\\r/g' -e 's/\n/\\n/g'
}
build_link_payload() {
# Args: name dir key to [comment]
# name = canonical link type name (e.g., "Blocks")
# dir = "outward" | "inward"
# key = outward issue key (if dir=outward) or inward otherwise
# to = the counterpart key
# comment = optional text
local name="$1" dir="$2" key="$3" to="$4" comment="${5-}"
if command -v jq >/dev/null 2>&1; then
jq -n --arg name "$name" --arg dir "$dir" --arg key "$key" --arg to "$to" --arg cmt "${comment-}" '
def a: if $dir == "outward" then "outwardIssue" else "inwardIssue" end;
def b: if $dir == "outward" then "inwardIssue" else "outwardIssue" end;
{
type: { name: $name }
} + { (a): { key: $key } }
+ { (b): { key: $to } }
+ ( ( $cmt | length ) > 0
| if . then { comment: { body: $cmt } } else {} end
)
'
else
# Minimal construction without jq
local a b
if [[ "$dir" == "outward" ]]; then a="outwardIssue"; b="inwardIssue"; else a="inwardIssue"; b="outwardIssue"; fi
local name_ key_ to_ cmt_
name_=$(json_escape "$name")
key_=$(json_escape "$key")
to_=$(json_escape "$to")
cmt_=$(json_escape "${comment-}")
if [[ -n "${comment-}" ]]; then
printf '{ "type": {"name":"%s"}, "%s":{"key":"%s"}, "%s":{"key":"%s"}, "comment":{"body":"%s"} }\n' \
"$name_" "$a" "$key_" "$b" "$to_" "$cmt_"
else
printf '{ "type": {"name":"%s"}, "%s":{"key":"%s"}, "%s":{"key":"%s"} }\n' \
"$name_" "$a" "$key_" "$b" "$to_"
fi
fi
}
# Resolve link type by name or by inward/outward description and infer direction
# Outputs: two lines: canonical_name and direction ("outward" or "inward")
resolve_link_type() {
local site="$1" type_arg="$2" email="$3" token="$4"
need_cmd curl
local resp types name inward outward dir lowered
resp="$(curl --silent --show-error -u "$email:$token" -H "Accept: application/json" \
"$site/rest/api/3/issueLinkType")"
if command -v jq >/dev/null 2>&1; then
types="$(printf '%s' "$resp" | jq -c '.issueLinkTypes[]?')"
if [[ -z "${types:-}" ]]; then
err "Could not read link types from Jira site. Response may indicate an auth or permission issue."
printf '\n%s\n' "$resp" >&2
return 1
fi
lowered="$(printf '%s' "$type_arg" | tr '[:upper:]' '[:lower:]')"
name="$(printf '%s\n' "$types" | jq -r --arg t "$lowered" '
select((.name|ascii_downcase)==$t or (.inward|ascii_downcase)==$t or (.outward|ascii_downcase)==$t) | .name' | head -n1)"
inward="$(printf '%s\n' "$types" | jq -r --arg t "$lowered" '
select((.name|ascii_downcase)==$t or (.inward|ascii_downcase)==$t or (.outward|ascii_downcase)==$t) | .inward' | head -n1)"
outward="$(printf '%s\n' "$types" | jq -r --arg t "$lowered" '
select((.name|ascii_downcase)==$t or (.inward|ascii_downcase)==$t or (.outward|ascii_downcase)==$t) | .outward' | head -n1)"
if [[ -z "${name:-}" ]]; then
err "No link type matches: $type_arg"
return 1
fi
if [[ "$lowered" == "$(printf '%s' "$inward" | tr '[:upper:]' '[:lower:]')" ]]; then
dir="inward"
else
dir="outward"
fi
printf '%s\n%s\n' "$name" "$dir"
return 0
fi
# Fallback without jq: very basic heuristic (first type only)
name="$(printf '%s' "$resp" | grep -Eo '"name":"[^"]+"' | head -n1 | sed -E 's/^"name":"(.*)"/\1/')"
inward="$(printf '%s' "$resp" | grep -Eo '"inward":"[^"]+"' | head -n1 | sed -E 's/^"inward":"(.*)"/\1/')"
outward="$(printf '%s' "$resp" | grep -Eo '"outward":"[^"]+"' | head -n1 | sed -E 's/^"outward":"(.*)"/\1/')"
if [[ -z "${name:-}" ]]; then
err "Failed to parse issueLinkType response (install jq for robust parsing)."
return 1
fi
dir="outward"
printf '%s\n%s\n' "$name" "$dir"
}
do_link_types() {
# Parse flags after: jira workitem link-types
local site="${JIRA_SITE-}" verbose=0 json=0
while (($#)); do
case "$1" in
--site) site="${2-}"; shift 2;;
--verbose) verbose=1; shift;;
--json) json=1; shift;;
--help|-h)
cat <<'USAGE'
Usage: aclix jira workitem link-types [--site URL] [--json] [--verbose]
Fetch available link types from Jira. Credentials are read from env:
JIRA_EMAIL Atlassian account email (required)
JIRA_API_TOKEN Atlassian API token (required)
JIRA_SITE Base URL like https://your-site.atlassian.net (optional if --site is provided)
Flags:
--site URL Jira site base URL (overrides $JIRA_SITE)
--json Output raw JSON response
--verbose Verbose logging
Examples:
aclix jira workitem link-types
aclix jira workitem link-types --site https://example.atlassian.net --json
USAGE
exit 0;;
--) shift; break;;
-*) err "Unknown flag: $1"; exit 2;;
*) err "Unexpected argument: $1"; exit 2;;
esac
done
# Validate
: "${JIRA_EMAIL:?Set JIRA_EMAIL in the environment}"
: "${JIRA_API_TOKEN:?Set JIRA_API_TOKEN in the environment}"
if [[ -z "$site" ]]; then
err "Missing --site and JIRA_SITE is not set."; exit 2
fi
if [[ "$verbose" -eq 1 ]]; then
err "Using site: $site"
fi
# Fetch link types
local resp
resp="$(curl -s -u "$JIRA_EMAIL:$JIRA_API_TOKEN" \
-H "Accept: application/json" \
"$site/rest/api/3/issueLinkType")"
if [[ "$json" -eq 1 ]]; then
printf '%s\n' "$resp"
exit 0
fi
# Pretty print the link types
if command -v jq >/dev/null 2>&1; then
printf '%s\n' "$resp" | jq -r '
.issueLinkTypes[]? |
"Name: \(.name)\n Outward: \(.outward)\n Inward: \(.inward)\n"'
else
# Fallback without jq
printf 'Available Link Types (install jq for better formatting):\n'
printf '%s\n' "$resp"
fi
}
do_link() {
# Parse flags after: jira workitem link
local key="" to="" type_arg="" site="${JIRA_SITE-}" comment="" direction="" dry_run=0 verbose=0
while (($#)); do
case "$1" in
--key) key="${2-}"; shift 2;;
--to) to="${2-}"; shift 2;;
--type) type_arg="${2-}"; shift 2;;
--site) site="${2-}"; shift 2;;
--comment) comment="${2-}"; shift 2;;
--direction) direction="${2-}"; shift 2;;
--dry-run) dry_run=1; shift;;
--verbose) verbose=1; shift;;
--help|-h) usage_link; exit 0;;
--) shift; break;;
-*)
err "Unknown flag: $1"; usage_link; exit 2;;
*)
err "Unexpected argument: $1"; usage_link; exit 2;;
esac
done
# Validate
: "${JIRA_EMAIL:?Set JIRA_EMAIL in the environment}"
: "${JIRA_API_TOKEN:?Set JIRA_API_TOKEN in the environment}"
if [[ -z "$site" ]]; then
err "Missing --site and JIRA_SITE is not set."; usage_link; exit 2
fi
if [[ -z "$key" || -z "$to" || -z "$type_arg" ]]; then
err "Missing required flags."; usage_link; exit 2
fi
if [[ "$verbose" -eq 1 ]]; then
err "Using site: $site"
fi
# Resolve link type + direction
local name dir
read -r name dir < <(resolve_link_type "$site" "$type_arg" "$JIRA_EMAIL" "$JIRA_API_TOKEN")
# Respect explicit direction override if provided
if [[ -n "${direction:-}" ]]; then
case "$direction" in
outward|inward) dir="$direction";;
*) err "Invalid --direction: must be outward|inward"; exit 2;;
esac
fi
# If inward, swap arguments
local out_key="$key" in_key="$to"
if [[ "$dir" == "inward" ]]; then
out_key="$to"; in_key="$key"
fi
# Build payload
local payload
payload="$(build_link_payload "$name" "$dir" "$out_key" "$in_key" "${comment-}")"
if [[ "$dry_run" -eq 1 ]]; then
printf '%s\n' "$payload"
exit 0
fi
# Execute
local resp_file="" http_code=""
resp_file="$(mktemp -t aclix.XXXXXX)"
# Ensure cleanup happens
cleanup_temp() {
[[ -n "$resp_file" && -f "$resp_file" ]] && rm -f "$resp_file"
}
trap cleanup_temp EXIT
http_code="$(curl --silent --show-error -w '%{http_code}' -o "$resp_file" \
-u "$JIRA_EMAIL:$JIRA_API_TOKEN" \
-H 'Content-Type: application/json' \
-X POST "$site/rest/api/3/issueLink" \
--data-binary "$payload")"
if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then
printf 'Created link: %s [%s] %s\n' "$key" "$name" "$to"
cleanup_temp
trap - EXIT # Remove trap before function returns
else
err "Jira API error ($http_code):"
cat "$resp_file" >&2 || true
cleanup_temp
trap - EXIT # Remove trap before function returns
exit 1
fi
}
emit_completion_bash() {
# Generate a bash completion script for 'aclix' that wraps acli's completion
cat <<'BASHCOMP'
# bash completion for aclix - wrapper around acli
# shellcheck shell=bash
# Source acli's own completion (if available)
# shellcheck disable=SC1090
source <(acli completion bash) 2>/dev/null || true
_aclix_complete() {
local cur prev
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
# If the user is completing after: jira workitem link (but not link-types)
local joined="${COMP_WORDS[*]}"
if [[ "$joined" == *" jira "* && "$joined" == *" workitem "* && "$joined" == *" link "* && "$joined" != *" link-types "* ]]; then
# Offer our custom flags
local opts="--key --to --type --comment --site --direction --dry-run --verbose --help -h"
case "$prev" in
--direction)
COMPREPLY=( $(compgen -W "outward inward" -- "$cur") )
return 0
;;
--type)
# Common link types
COMPREPLY=( $(compgen -W "Blocks 'is blocked by' Relates 'relates to' Duplicates 'is duplicated by' Cloners 'is cloned by'" -- "$cur") )
return 0
;;
*)
COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
return 0
;;
esac
fi
# If the user is completing after: jira workitem link-types
if [[ "$joined" == *" jira "* && "$joined" == *" workitem "* && "$joined" == *" link-types "* ]]; then
# Offer link-types flags
local opts="--site --json --verbose --help -h"
COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
return 0
fi
# Otherwise, reuse acli's completion by swapping the command name
if declare -F _acli >/dev/null 2>&1; then
local i
for i in "${!COMP_WORDS[@]}"; do
if [[ "${COMP_WORDS[$i]}" == "aclix" ]]; then
COMP_WORDS[$i]="acli"
break
fi
done
_acli
return 0
fi
return 0
}
complete -F _aclix_complete aclix
BASHCOMP
}
emit_completion_zsh() {
# Generate a zsh completion script for 'aclix' that wraps acli's completion
cat <<'ZSHCOMP'
#compdef aclix
# zsh completion for aclix - wrapper around acli
# Source acli's own completion (if available)
if command -v acli >/dev/null 2>&1; then
source <(acli completion zsh) 2>/dev/null || true
fi
_aclix() {
local context state line
typeset -A opt_args
# Check if we're completing after: jira workitem link
local words_joined="${words[*]}"
if [[ "$words_joined" == *" jira "* && "$words_joined" == *" workitem "* && "$words_joined" == *" link "* && "$words_joined" != *" link-types "* ]]; then
_arguments \
'--key[Source issue key (e.g., PROJ-123)]:issue key:' \
'--to[Target issue key (e.g., PROJ-456)]:issue key:' \
'--type[Link type name or description]:link type:(Blocks "is blocked by" Relates "relates to" Duplicates "is duplicated by" Cloners "is cloned by")' \
'--site[Jira site base URL]:site url:_urls' \
'--comment[Optional comment body]:comment text:' \
'--direction[Force link direction]:direction:(outward inward)' \
'--dry-run[Print the HTTP request body and exit]' \
'--verbose[Verbose logging]' \
'(--help -h)'{--help,-h}'[Show help]'
return 0
fi
# Check if we're completing after: jira workitem link-types
if [[ "$words_joined" == *" jira "* && "$words_joined" == *" workitem "* && "$words_joined" == *" link-types "* ]]; then
_arguments \
'--site[Jira site base URL]:site url:_urls' \
'--json[Output raw JSON response]' \
'--verbose[Verbose logging]' \
'(--help -h)'{--help,-h}'[Show help]'
return 0
fi
# Otherwise, delegate to acli's completion by modifying the service name
if (( $+functions[_acli] )); then
# Temporarily replace aclix with acli in words array
local original_words=("${words[@]}")
local i
for i in {1..$#words}; do
if [[ "${words[$i]}" == "aclix" ]]; then
words[$i]="acli"
break
fi
done
_acli "$@"
# Restore original words
words=("${original_words[@]}")
return 0
fi
return 0
}
_aclix "$@"
ZSHCOMP
}
# --- entrypoint ---
main() {
# Verify acli is available
if ! command -v acli >/dev/null 2>&1; then
err "acli is not installed or not in PATH"
err "Install via: brew tap atlassian/homebrew-acli && brew install acli"
exit 127
fi
if (( $# == 0 )); then
exec acli
fi
# Intercept: aclix completion bash|zsh
if [[ "${1-}" == "completion" ]]; then
case "${2-}" in
bash)
emit_completion_bash
exit 0
;;
zsh)
emit_completion_zsh
exit 0
;;
*)
# Defer to acli for other shells
exec acli "$@"
;;
esac
fi
# Intercept jira workitem help commands to add our custom 'link' subcommand
if (( $# >= 2 )) && [[ "${1-}" == "jira" && "${2-}" == "workitem" ]]; then
# Check if it's a help request (no additional args, -h, or --help)
if (( $# == 2 )) || [[ "${3-}" == "-h" || "${3-}" == "--help" ]]; then
# Get the original help output from acli and inject our custom commands
acli jira workitem --help | sed '/^Available Commands:/{
a\
link Create a link between Jira issues using the REST API.
a\
link-types Fetch available link types from Jira.
}'
exit 0
fi
# Handle the link subcommand
if [[ "${3-}" == "link" ]]; then
shift 3
do_link "$@"
exit $?
fi
# Handle the link-types subcommand
if [[ "${3-}" == "link-types" ]]; then
shift 3
do_link_types "$@"
exit $?
fi
fi
# Default: proxy to acli
exec acli "$@"
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment