Last active
August 6, 2025 22:01
-
-
Save rolandkakonyi/dd822ec2833f40b0f8adbecc90777e30 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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