#!/usr/bin/env bash # migration-doctor-snapshot (bash edition) # # Read-only snapshot of Jira Data Center configuration for Migration Doctor. # Sends only HTTP GET requests to the Jira REST API. Never writes to Jira. # Collects configuration only: no issues, no comments, no attachments. # # Requirements: bash 3.2+, curl, jq 1.6+. # Authentication (environment variables only, never arguments): # JIRA_PAT personal access token (sent as a Bearer token), or # JIRA_USER and JIRA_PASSWORD basic authentication # # Copyright (c) 2026 Nox Development. MIT License. set -euo pipefail GENERATOR_NAME="migration-doctor-snapshot" GENERATOR_VERSION="1.0.0" BASE_URL="" OUT="" PROJECTS="" SQL_DIR="" FILTER_SCAN_GAP=100 DELAY_MS=100 MAX_RETRIES=5 NO_BOARDS=0 usage() { cat <<'EOF' Usage: snapshot.sh --base-url URL [options] Read-only Jira Data Center configuration snapshot for Migration Doctor. Options: --base-url URL Jira base URL, e.g. https://jira.example.com or https://example.com/jira --out FILE Output file (default: migration-doctor-snapshot-.json) --projects KEY1,KEY2 Limit projects, their permission schemes, workflows and boards to these keys --sql-export-dir DIR Directory with the results of the read-only SQL queries in sql// (users, groups, filters, share-permissions, dashboards, dashboard-gadgets, workflows, plugins as .jsonl files); merged into the snapshot --filter-scan-gap N Without SQL exports, probe filter IDs from 10000 upwards and stop after N consecutive IDs that are missing or not visible (default 100, 0 disables) --delay-ms N Pause between requests in milliseconds (default 100) --max-retries N Retries for HTTP 429, 502, 503, 504 and network errors (default 5) --no-boards Skip Jira Software boards -h, --help Show this help --version Show the version Authentication comes only from environment variables: JIRA_PAT personal access token (Bearer), or JIRA_USER and JIRA_PASSWORD basic authentication EOF } # ---------------------------------------------------------------- output helpers START_TS=$(date +%s) WARN_COUNT=0 REQUEST_COUNT=0 TMP="" log() { printf '%s\n' "$*" >&2; } step() { log ""; log "==> $*"; } info() { log " $*"; } warn() { WARN_COUNT=$((WARN_COUNT + 1)) log " WARNING: $*" if [ -n "$TMP" ] && [ -d "$TMP" ]; then printf '%s\n' "$*" >> "$TMP/warnings.txt"; fi } die() { log ""; log "ERROR: $*"; exit 1; } usage_error() { log "ERROR: $*"; log "Run with --help for usage."; exit 2; } cleanup() { if [ -n "$TMP" ] && [ -d "$TMP" ]; then rm -rf "$TMP"; fi; } trap cleanup EXIT # ---------------------------------------------------------------- arguments while [ $# -gt 0 ]; do case "$1" in --base-url) [ $# -ge 2 ] || usage_error "--base-url needs a value"; BASE_URL="$2"; shift 2 ;; --base-url=*) BASE_URL="${1#*=}"; shift ;; --out) [ $# -ge 2 ] || usage_error "--out needs a value"; OUT="$2"; shift 2 ;; --out=*) OUT="${1#*=}"; shift ;; --projects) [ $# -ge 2 ] || usage_error "--projects needs a value"; PROJECTS="$2"; shift 2 ;; --projects=*) PROJECTS="${1#*=}"; shift ;; --sql-export-dir) [ $# -ge 2 ] || usage_error "--sql-export-dir needs a value"; SQL_DIR="$2"; shift 2 ;; --sql-export-dir=*) SQL_DIR="${1#*=}"; shift ;; --filter-scan-gap) [ $# -ge 2 ] || usage_error "--filter-scan-gap needs a value"; FILTER_SCAN_GAP="$2"; shift 2 ;; --filter-scan-gap=*) FILTER_SCAN_GAP="${1#*=}"; shift ;; --delay-ms) [ $# -ge 2 ] || usage_error "--delay-ms needs a value"; DELAY_MS="$2"; shift 2 ;; --delay-ms=*) DELAY_MS="${1#*=}"; shift ;; --max-retries) [ $# -ge 2 ] || usage_error "--max-retries needs a value"; MAX_RETRIES="$2"; shift 2 ;; --max-retries=*) MAX_RETRIES="${1#*=}"; shift ;; --no-boards) NO_BOARDS=1; shift ;; -h|--help) usage; exit 0 ;; --version) printf '%s %s\n' "$GENERATOR_NAME" "$GENERATOR_VERSION"; exit 0 ;; --token*|--pat*|--password*|--user*|--secret*|--api-key*) usage_error "secrets are never accepted as arguments. Set JIRA_PAT, or JIRA_USER and JIRA_PASSWORD, in the environment." ;; *) usage_error "unknown argument: $1" ;; esac done [ -n "$BASE_URL" ] || usage_error "--base-url is required" case "$BASE_URL" in http://*|https://*) ;; *) usage_error "--base-url must start with https:// or http://" ;; esac case "$BASE_URL" in *://*@*) usage_error "--base-url must not contain credentials; use JIRA_PAT or JIRA_USER/JIRA_PASSWORD" ;; esac BASE_URL="${BASE_URL%/}" for n in "$FILTER_SCAN_GAP" "$DELAY_MS" "$MAX_RETRIES"; do case "$n" in ''|*[!0-9]*) usage_error "numeric options take a non-negative integer, got '$n'" ;; esac done if [ -z "$OUT" ]; then OUT="migration-doctor-snapshot-$(date +%Y-%m-%d).json"; fi PROJECT_KEYS="" if [ -n "$PROJECTS" ]; then PROJECT_KEYS=$(printf '%s' "$PROJECTS" | tr ',' '\n' | tr -d ' \r' | tr '[:lower:]' '[:upper:]' | sed '/^$/d' | sort -u) while IFS= read -r k; do case "$k" in [A-Z]*) case "$k" in *[!A-Z0-9_]*) usage_error "invalid project key: $k" ;; esac ;; *) usage_error "invalid project key: $k" ;; esac done </dev/null 2>&1 || die "curl is required" command -v jq >/dev/null 2>&1 || die "jq 1.6 or newer is required (https://jqlang.org/download/)" # jq.exe on Windows (Git Bash, MSYS2, Cygwin) writes CRLF line endings; strip them. case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) jq() { command jq "$@" | tr -d '\r'; } ;; esac JQ_VERSION=$(jq --version 2>/dev/null | sed 's/^jq-//') case "$JQ_VERSION" in 1.[0-5]|1.[0-5].*) die "jq 1.6 or newer is required, found $JQ_VERSION" ;; esac # ---------------------------------------------------------------- authentication curl_quote() { local v="$1"; v="${v//\\/\\\\}"; v="${v//\"/\\\"}"; printf '"%s"' "$v"; } if [ -n "${JIRA_PAT:-}" ]; then AUTH_MODE="personal access token" CURL_AUTH="header = $(curl_quote "Authorization: Bearer ${JIRA_PAT}")" elif [ -n "${JIRA_USER:-}" ] && [ -n "${JIRA_PASSWORD:-}" ]; then AUTH_MODE="basic authentication as ${JIRA_USER}" CURL_AUTH="user = $(curl_quote "${JIRA_USER}:${JIRA_PASSWORD}")" else usage_error "no credentials. Set JIRA_PAT (recommended), or JIRA_USER and JIRA_PASSWORD, in the environment." fi TMP=$(mktemp -d 2>/dev/null || mktemp -d -t mdsnap) chmod 700 "$TMP" : > "$TMP/warnings.txt" DELAY_S=$(printf '%d.%03d' $((DELAY_MS / 1000)) $((DELAY_MS % 1000))) # ---------------------------------------------------------------- HTTP (GET only) HTTP_STATUS="" # http_get # Returns 0 on 2xx. Retries 429/502/503/504 and network errors with backoff, # honouring Retry-After. Sets HTTP_STATUS. The auth config is passed to curl on # stdin, so the secret never appears in the process list. http_get() { local url="${BASE_URL}$1" out="$2" attempt=0 status wait rc while :; do if [ "$DELAY_MS" -gt 0 ]; then sleep "$DELAY_S"; fi REQUEST_COUNT=$((REQUEST_COUNT + 1)) rc=0 status=$(printf '%s\n' "$CURL_AUTH" | curl -sS -K - --get \ -H 'Accept: application/json' -H 'X-Atlassian-Token: no-check' \ --connect-timeout 30 --max-time 300 \ -o "$out" -D "$TMP/headers.txt" -w '%{http_code}' "$url" 2>"$TMP/curl-error.txt") || rc=$? if [ "$rc" -ne 0 ]; then status="000" case "$rc" in 5|6|7|18|28|35|52|55|56) ;; # resolve, connect, timeout, TLS handshake, empty reply, send/recv errors *) HTTP_STATUS="000"; return 1 ;; esac fi case "$status" in 2??) HTTP_STATUS="$status"; return 0 ;; 429|502|503|504|000) attempt=$((attempt + 1)) if [ "$attempt" -gt "$MAX_RETRIES" ]; then HTTP_STATUS="$status"; return 1; fi wait=$(grep -i '^retry-after:' "$TMP/headers.txt" 2>/dev/null | tail -n 1 | tr -dc '0-9' || true) if [ -z "$wait" ]; then wait=$((1 << attempt)); fi if [ "$wait" -gt 60 ]; then wait=60; fi if [ "$status" = "000" ]; then info "network error ($(head -c 200 "$TMP/curl-error.txt" | tr -d '\r\n')), retry $attempt/$MAX_RETRIES in ${wait}s" else info "HTTP $status from server, retry $attempt/$MAX_RETRIES in ${wait}s" fi sleep "$wait" ;; *) HTTP_STATUS="$status"; return 1 ;; esac done } error_detail() { local f="$1" if [ -s "$f" ]; then jq -r '[(.errorMessages // [])[], ((.errors // {}) | to_entries[] | "\(.key): \(.value)"), (.message // empty)] | join("; ")' "$f" 2>/dev/null | head -c 300 || true fi } # get_json : fails the run on any non-2xx response. get_json() { local path="$1" out="$2" detail hint="" if http_get "$path" "$out"; then jq empty "$out" 2>/dev/null || die "GET $path returned a response that is not JSON. Check --base-url (is a proxy or SSO page answering?)." return 0 fi detail=$(error_detail "$out") case "$HTTP_STATUS" in 000) hint=" Could not connect: $(head -c 200 "$TMP/curl-error.txt" | tr -d '\r\n')" ;; 3??) hint=" The server redirected the request; use the final URL as --base-url (redirects are not followed so credentials are never sent elsewhere)." ;; 401) hint=" Authentication failed: check JIRA_PAT or JIRA_USER/JIRA_PASSWORD." ;; 403) hint=" Forbidden: the snapshot needs a Jira System Administrator account. If CAPTCHA was triggered for basic auth, log in once in the browser or use a personal access token." ;; 404) hint=" Not found: check --base-url (include the context path, e.g. /jira)." ;; esac die "GET $path failed with HTTP $HTTP_STATUS.${detail:+ Server said: $detail.}$hint" } project_selected() { [ -z "$PROJECT_KEYS" ] && return 0 printf '%s\n' "$PROJECT_KEYS" | grep -qx "$1" } sql_file() { # sql_file : prints the path when the export exists [ -n "$SQL_DIR" ] || return 1 if [ -f "$SQL_DIR/$1.jsonl" ]; then printf '%s' "$SQL_DIR/$1.jsonl"; return 0; fi if [ -f "$SQL_DIR/$1.json" ]; then printf '%s' "$SQL_DIR/$1.json"; return 0; fi return 1 } # read_jsonl : JSON Lines from a database client into a JSON array. # Lines that do not start with "{" (headers, row counts, blank lines) are ignored. read_jsonl() { local f="$1" out="$2" jq -R -s -c --arg f "$f" ' ltrimstr("\ufeff") | split("\n") | map(sub("^\\s+"; "") | sub("\\s+$"; "") | select(startswith("{"))) | map(. as $line | try fromjson catch error("invalid JSON line in " + $f + ": " + ($line | .[0:120]))) ' "$f" > "$out" || die "could not read $f (each line must be one JSON object; see README, SQL exports)" } # ---------------------------------------------------------------- shared jq definitions JQ_LIB=' def tostr: if . == null then null else tostring end; def tobool: if . == true or . == 1 or . == "1" or . == "true" or . == "Y" or . == "y" then true else false end; def nonempty: if . == null or . == "" then null else . end; def ascii_lower: explode | map(if . >= 65 and . <= 90 then . + 32 else . end) | implode; def numid: (tostring | tonumber? // 0); def compact: with_entries(select(.value != null)); def share_known: ["global", "loggedin", "authenticated", "group", "project", "projectRole", "user"]; def rest_share: if .type == "group" then {type: "group", group: .group.name} elif .type == "project" and .role != null then {type: "projectRole", projectKey: .project.key, role: .role.name} elif .type == "project" then {type: "project", projectKey: .project.key} elif .type == "projectRole" then {type: "projectRole", projectKey: .project.key, role: .role.name} elif .type == "user" then {type: "user", user: .user.name} elif .type == "global" or .type == "loggedin" or .type == "authenticated" then {type: .type} else empty end | compact; def sql_share: if .shareType == "group" then {type: "group", group: .group} elif .shareType == "project" and .role != null then {type: "projectRole", projectKey: .projectKey, role: .role} elif .shareType == "project" then {type: "project", projectKey: .projectKey} elif .shareType == "user" then {type: "user", user: .user} elif .shareType == "global" or .shareType == "loggedin" or .shareType == "authenticated" then {type: .shareType} else empty end | compact; def rest_filter: {id: (.id | tostr), name: (.name // ""), description: (.description | nonempty), jql: (.jql // ""), owner: (.owner.name // ""), sharePermissions: [(.sharePermissions // [])[] | rest_share]}; ' # ---------------------------------------------------------------- workflow descriptor parser (jq) JQ_WORKFLOW=' def trim: sub("^\\s+"; "") | sub("\\s+$"; ""); def hexval: ascii_downcase | explode | reduce .[] as $c (0; . * 16 + (if $c >= 97 then $c - 87 else $c - 48 end)); def xml_unescape: if test("&") then gsub("&#[xX](?[0-9A-Fa-f]+);"; [.h | hexval] | implode) | gsub("&#(?[0-9]+);"; [.d | tonumber] | implode) | gsub("<"; "<") | gsub(">"; ">") | gsub("""; "\"") | gsub("'"; "'"'"'") | gsub("&"; "&") else . end; def xml_attrs: [match("([^\\s=]+)\\s*=\\s*(?:\"([^\"]*)\"|'"'"'([^'"'"']*)'"'"')"; "g") | .captures | {key: .[0].string, value: ((.[1].string // .[2].string // "") | xml_unescape)}] | from_entries; def xml_tokens: match("|[\\s\\S]*?)\\]\\]>|<[?!][^>]*>|[^\\s>]+)\\s*>|<(?[^\\s/>]+)(?(?:\\s+[^\\s=/>]+\\s*=\\s*(?:\"[^\"]*\"|'"'"'[^'"'"']*'"'"'))*)\\s*(?/?)>|(?[^<]+)"; "g") | [.captures[] | select(.name != null) | {key: .name, value: .string}] | from_entries; def xml_parse: reduce xml_tokens as $t ([{n: "#document", a: {}, c: [], t: ""}]; (length - 1) as $i | if $t.start != null then {n: $t.start, a: (($t.attrs // "") | xml_attrs), c: [], t: ""} as $node | if $t.self == "/" then .[$i].c += [$node] else . + [$node] end elif $t.end != null then if $i < 1 then error("unexpected ") elif .[$i].n != $t.end then error(" closes <" + .[$i].n + ">") else .[$i] as $node | .[0:$i] | .[$i - 1].c += [$node] end elif $t.cdata != null then .[$i].t += $t.cdata elif $t.text != null then .[$i].t += ($t.text | xml_unescape) else . end) | if length != 1 then error("unclosed <" + .[length - 1].n + ">") elif (.[0].c | length) == 0 then error("no root element") else .[0].c[0] end; def kids($n): (.c // [])[] | select(.n == $n); def walknodes: ., ((.c // [])[] | walknodes); def descendants($n): (.c // [])[] | walknodes | select(.n == $n); def text: .t | trim; def arg($name): first(kids("arg") | select(.a.name == $name) | text) // null; def uniq_keep: reduce .[] as $x ([]; if any(.[]; . == $x) then . else . + [$x] end); def plugin_key($cls; $mk; $plugins): ( if $mk != null and $mk != "" then first($plugins[] | select(. as $p | ($mk | startswith($p)) and (($mk | length) > ($p | length)))) // (if ($mk | contains(":")) then ($mk | split(":")[0]) else null end) // (if ($mk | startswith("com.atlassian.jira.plugin.system.")) then "com.atlassian.jira" else null end) else null end ) // (if $cls != null and ($cls | test("^com\\.atlassian\\.jira\\.|^com\\.opensymphony\\.")) then "com.atlassian.jira" else null end) | if . != null and startswith("com.atlassian.jira.plugin.system.") then "com.atlassian.jira" else . end; def rule($kind; $plugins): arg("class.name") as $cls | arg("full.module.key") as $mk | {kind: $kind, type: ($cls // $mk // .a.type // "unknown")} + (plugin_key($cls; $mk; $plugins) as $pk | if $pk != null then {pluginKey: $pk} else {} end) + (if $mk != null and $mk != "" then {moduleKey: $mk} else {} end); def rules_of($plugins): [ (kids("restrict-to") | descendants("condition") | rule("condition"; $plugins)), (kids("validators") | kids("validator") | rule("validator"; $plugins)), (descendants("post-functions") | kids("function") | rule("postFunction"; $plugins)) ]; def transitions($statusName; $plugins): . as $wf | [ $wf | kids("steps") | kids("step") ] as $steps | ( [ $steps[] | { key: (.a.id // ""), value: ( (first(kids("meta") | select(.a.name == "jira.status.id") | text) // null) as $sid | if $sid != null and $statusName[$sid] != null then $statusName[$sid] else (.a.name // "") end ) } ] | from_entries ) as $stepStatus | def tr($from): (first(kids("results") | kids("unconditional-result") | .a.step) // null) as $to | { id: (.a.id // ""), name: (.a.name // ""), from: $from, to: (if $to == null or $to == "-1" then "" else ($stepStatus[$to] // "") end), rules: rules_of($plugins) } + (if $to == "-1" then {looped: true} else {} end); [ ( $wf | kids("initial-actions") | kids("action") | tr([]) ), ( $wf | kids("global-actions") | kids("action") | tr([]) ), ( $wf | kids("common-actions") | kids("action") | . as $a | tr([ $steps[] | select(any(kids("actions") | kids("common-action"); .a.id == $a.a.id)) | $stepStatus[.a.id // ""] ] | uniq_keep) ), ( $steps[] | . as $s | kids("actions") | kids("action") | tr([ $stepStatus[$s.a.id // ""] ]) ) ] | sort_by(.id | tonumber? // 0); ' # ---------------------------------------------------------------- run TOTAL_STEPS=11 [ "$NO_BOARDS" -eq 1 ] && TOTAL_STEPS=10 STEP_NO=0 next_step() { STEP_NO=$((STEP_NO + 1)); step "[$STEP_NO/$TOTAL_STEPS] $*"; } log "Migration Doctor snapshot $GENERATOR_VERSION (bash, jq $JQ_VERSION)" log "Jira: $BASE_URL" log "Auth: $AUTH_MODE (from the environment)" log "Output: $OUT" [ -n "$PROJECT_KEYS" ] && log "Projects: $(printf '%s' "$PROJECT_KEYS" | tr '\n' ' ')" [ -n "$SQL_DIR" ] && log "SQL exports: $SQL_DIR" log "Only HTTP GET requests are sent. Nothing in Jira is changed." # SQL exports: validate the contract early SQL_USERS=""; SQL_GROUPS=""; SQL_FILTERS=""; SQL_SHARES=""; SQL_DASHBOARDS=""; SQL_GADGETS=""; SQL_WORKFLOWS=""; SQL_PLUGINS="" if [ -n "$SQL_DIR" ]; then SQL_USERS=$(sql_file users || true) SQL_GROUPS=$(sql_file groups || true) SQL_FILTERS=$(sql_file filters || true) SQL_SHARES=$(sql_file share-permissions || true) SQL_DASHBOARDS=$(sql_file dashboards || true) SQL_GADGETS=$(sql_file dashboard-gadgets || true) SQL_WORKFLOWS=$(sql_file workflows || true) SQL_PLUGINS=$(sql_file plugins || true) if [ -n "$SQL_FILTERS$SQL_DASHBOARDS" ] && [ -z "$SQL_SHARES" ]; then die "SQL exports: filters.jsonl and dashboards.jsonl need share-permissions.jsonl from the same run" fi if [ -n "$SQL_DASHBOARDS" ] && [ -z "$SQL_GADGETS" ]; then die "SQL exports: dashboards.jsonl needs dashboard-gadgets.jsonl from the same run" fi found="" for name in users groups filters share-permissions dashboards dashboard-gadgets workflows plugins; do if sql_file "$name" >/dev/null; then found="$found $name"; fi done [ -n "$found" ] || die "SQL exports: no known .jsonl files in $SQL_DIR (expected users, groups, filters, share-permissions, dashboards, dashboard-gadgets, workflows, plugins)" info "SQL exports found:$found" [ -n "$SQL_SHARES" ] && read_jsonl "$SQL_SHARES" "$TMP/sql-shares.json" fi # 1. Server info ------------------------------------------------------------- next_step "Server info" get_json "/rest/api/2/serverInfo" "$TMP/serverInfo.json" DEPLOYMENT_TYPE=$(jq -r '.deploymentType // ""' "$TMP/serverInfo.json") [ "$DEPLOYMENT_TYPE" = "Cloud" ] && die "this is a Jira Cloud site. The snapshot is for Jira Data Center (and Server)." JIRA_VERSION=$(jq -r '.version // ""' "$TMP/serverInfo.json") [ -n "$JIRA_VERSION" ] || die "serverInfo did not return a version. Is $BASE_URL a Jira base URL?" JIRA_MAJOR=${JIRA_VERSION%%.*} info "Jira $JIRA_VERSION, $(jq -r '.serverTitle // "untitled"' "$TMP/serverInfo.json")" case "$JIRA_MAJOR" in 8|9|10|11) ;; *) warn "Jira $JIRA_VERSION is outside the tested range (8.20 to 11.x); continuing" ;; esac # 2. Users ------------------------------------------------------------------- next_step "Users" : > "$TMP/users.jsonl" if http_get "/rest/api/2/user/list?maxResults=1000" "$TMP/page.json"; then info "using /rest/api/2/user/list (cursor paging)" pages=0 while :; do pages=$((pages + 1)) jq -c '(.values // [])[]' "$TMP/page.json" >> "$TMP/users.jsonl" cursor=$(jq -r 'if (.isLast // false) or ((.values // []) | length) == 0 then "" else (.nextCursor // "" | tostring) end' "$TMP/page.json") [ -n "$cursor" ] || break [ "$pages" -lt 100000 ] || die "user/list did not finish after $pages pages" get_json "/rest/api/2/user/list?maxResults=1000&cursor=$cursor" "$TMP/page.json" done elif [ "$HTTP_STATUS" = "404" ]; then info "using /rest/api/2/user/search (startAt paging)" start=0 while :; do get_json "/rest/api/2/user/search?username=.&includeActive=true&includeInactive=true&startAt=$start&maxResults=1000" "$TMP/page.json" n=$(jq 'length' "$TMP/page.json") [ "$n" -gt 0 ] || break jq -c '.[]' "$TMP/page.json" >> "$TMP/users.jsonl" start=$((start + n)) info "$start users so far" done case "$JIRA_VERSION" in 10.*|11.*) [ "$start" -eq 100 ] && warn "exactly 100 users returned; Jira $JIRA_VERSION caps user/search at 100 results. Use the SQL export users.jsonl for a complete list." ;; esac else get_json "/rest/api/2/user/list?maxResults=1000" "$TMP/page.json" fi jq -s -c "$JQ_LIB"' unique_by(.key // .name) | map({name: (.name // ""), active: (if .active == null then true else .active end | tobool)} + (if (.key // "") != "" then {key: .key} else {} end) + {displayName: (.displayName // .name // ""), email: (.emailAddress | nonempty)}) ' "$TMP/users.jsonl" > "$TMP/users.json" COV_USERS="full" if [ -n "$SQL_USERS" ]; then read_jsonl "$SQL_USERS" "$TMP/sql-users.json" jq -c --slurpfile sql "$TMP/sql-users.json" "$JQ_LIB"' ($sql[0] | map({name: (.name // "" | tostring), active: (.active | tobool)} + (if (.key // "") != "" then {key: (.key | tostring)} else {} end) + {displayName: (.displayName // .name // ""), email: (.email | nonempty)}) | map(select(.name != ""))) as $s | ($s | map({key: (.name | ascii_lower), value: .}) | from_entries) as $byName | (map(.name | ascii_lower)) as $restNames | map(. as $u | ($byName[$u.name | ascii_lower]) as $m | if $u.email == null and $m != null and $m.email != null then .email = $m.email else . end) + [$s[] | select((.name | ascii_lower) as $n | $restNames | index($n) | not)] ' "$TMP/users.json" > "$TMP/users2.json" mv "$TMP/users2.json" "$TMP/users.json" info "merged users.jsonl from SQL" fi info "$(jq 'length' "$TMP/users.json") users ($(jq '[.[] | select(.active | not)] | length' "$TMP/users.json") inactive)" # 3. Groups ------------------------------------------------------------------ next_step "Groups" if [ -n "$SQL_GROUPS" ]; then read_jsonl "$SQL_GROUPS" "$TMP/sql-groups.json" jq -c '[.[] | .name // empty | tostring] | unique | map({name: .})' "$TMP/sql-groups.json" > "$TMP/groups.json" COV_GROUPS="full" info "from SQL groups.jsonl" else get_json "/rest/api/2/groups/picker?query=&maxResults=100000" "$TMP/page.json" jq -c '[(.groups // [])[] | .name] | unique | map({name: .})' "$TMP/page.json" > "$TMP/groups.json" total=$(jq '.total // 0' "$TMP/page.json") got=$(jq 'length' "$TMP/groups.json") if [ "$total" -gt "$got" ]; then COV_GROUPS="rest-partial" warn "the group picker returned $got of $total groups (capped by jira.ajax.autocomplete.limit). Export groups.jsonl with the SQL queries for the full list." else COV_GROUPS="full" fi fi info "$(jq 'length' "$TMP/groups.json") groups" # 4. Projects ---------------------------------------------------------------- next_step "Projects" get_json "/rest/api/2/project?includeArchived=true" "$TMP/page.json" jq -c "$JQ_LIB"'map({id: (.id | tostr), key: .key, name: (.name // ""), type: (.projectTypeKey // null)} | compact)' "$TMP/page.json" > "$TMP/projects-all.json" if [ -n "$PROJECT_KEYS" ]; then KEYS_JSON=$(printf '%s\n' "$PROJECT_KEYS" | jq -R -s -c 'split("\n") | map(select(. != ""))') missing=$(jq -r --argjson keys "$KEYS_JSON" '[.[].key] as $have | $keys - $have | join(", ")' "$TMP/projects-all.json") [ -z "$missing" ] || die "unknown project key(s): $missing" jq -c --argjson keys "$KEYS_JSON" 'map(select(.key as $k | $keys | index($k)))' "$TMP/projects-all.json" > "$TMP/projects.json" else cp "$TMP/projects-all.json" "$TMP/projects.json" fi jq -r '.[].key' "$TMP/projects.json" > "$TMP/project-keys.txt" info "$(jq 'length' "$TMP/projects.json") projects" # 5. Fields ------------------------------------------------------------------ next_step "Fields" get_json "/rest/api/2/field" "$TMP/page.json" jq -c 'map({id: .id, name: (.name // .id), custom: (.custom // false), type: (if (.custom // false) then (.schema.custom // null) else null end)})' "$TMP/page.json" > "$TMP/fields.json" info "$(jq 'length' "$TMP/fields.json") fields ($(jq '[.[] | select(.custom)] | length' "$TMP/fields.json") custom)" # 6. Statuses ---------------------------------------------------------------- next_step "Statuses" get_json "/rest/api/2/status" "$TMP/page.json" jq -c "$JQ_LIB"'map({id: (.id | tostr), name: (.name // "")})' "$TMP/page.json" > "$TMP/statuses.json" info "$(jq 'length' "$TMP/statuses.json") statuses" # 7. Permission schemes ------------------------------------------------------ next_step "Permission schemes" get_json "/rest/api/2/permissionscheme?expand=permissions,user,group,projectRole,field" "$TMP/page.json" jq -c "$JQ_LIB"'(.permissionSchemes // []) | map({id: (.id | tostr), name: (.name // ""), permissions: [(.permissions // [])[] | {permission: (.permission // ""), holder: {type: (.holder.type // "unknown"), parameter: (.holder.parameter // null | tostr)}}]})' \ "$TMP/page.json" > "$TMP/permschemes-all.json" : > "$TMP/scheme-projects.tsv" while IFS= read -r key; do [ -n "$key" ] || continue if http_get "/rest/api/2/project/$key/permissionscheme" "$TMP/page.json"; then printf '%s\t%s\n' "$(jq -r '.id | tostring' "$TMP/page.json")" "$key" >> "$TMP/scheme-projects.tsv" else warn "could not read the permission scheme of project $key (HTTP $HTTP_STATUS)" fi done < "$TMP/project-keys.txt" jq -R -s -c 'split("\n") | map(select(. != "") | split("\t")) | group_by(.[0]) | map({key: .[0][0], value: (map(.[1]) | sort)}) | from_entries' \ "$TMP/scheme-projects.tsv" > "$TMP/scheme-projects.json" jq -c --slurpfile map "$TMP/scheme-projects.json" --arg filtered "${PROJECT_KEYS:+1}" ' map(.projectKeys = ($map[0][.id] // [])) | if $filtered == "1" then map(select((.projectKeys | length) > 0)) else . end ' "$TMP/permschemes-all.json" > "$TMP/permschemes.json" info "$(jq 'length' "$TMP/permschemes.json") permission schemes" # 8. Workflows --------------------------------------------------------------- next_step "Workflows" get_json "/rest/api/2/workflow" "$TMP/page.json" jq -c '[.[].name] | unique' "$TMP/page.json" > "$TMP/workflow-names.json" if [ -n "$PROJECT_KEYS" ]; then : > "$TMP/used-workflows.txt" while IFS= read -r key; do [ -n "$key" ] || continue if http_get "/rest/api/2/project/$key/workflowscheme" "$TMP/page.json"; then jq -r '(.defaultWorkflow // "jira"), ((.issueTypeMappings // {}) | .[])' "$TMP/page.json" >> "$TMP/used-workflows.txt" else warn "could not read the workflow scheme of project $key (HTTP $HTTP_STATUS); its workflows may be missing" fi done < "$TMP/project-keys.txt" jq -R -s -c --slurpfile all "$TMP/workflow-names.json" 'split("\n") | map(select(. != "")) | unique as $used | $all[0] | map(select(. as $n | $used | index($n)))' \ "$TMP/used-workflows.txt" > "$TMP/wf.json" mv "$TMP/wf.json" "$TMP/workflow-names.json" fi if [ -n "$SQL_WORKFLOWS" ]; then read_jsonl "$SQL_WORKFLOWS" "$TMP/sql-workflows.json" if [ -n "$SQL_PLUGINS" ]; then read_jsonl "$SQL_PLUGINS" "$TMP/sql-plugins.json" jq -c '[.[] | .key // empty | tostring] | unique | sort_by(-length)' "$TMP/sql-plugins.json" > "$TMP/plugin-keys.json" else echo '[]' > "$TMP/plugin-keys.json" info "no plugins.jsonl: plugin keys of app-provided rules are derived from built-in prefixes only" fi jq -c '[.[] | {key: .id, value: .name}] | from_entries' "$TMP/statuses.json" > "$TMP/status-names.json" jq -c --slurpfile names "$TMP/workflow-names.json" --slurpfile st "$TMP/status-names.json" --slurpfile pk "$TMP/plugin-keys.json" \ "$JQ_WORKFLOW"' (map({key: (.name // "" | tostring), value: .descriptor}) | from_entries) as $desc | $names[0] | map(. as $n | if ($desc[$n] // "") == "" then {name: $n, transitions: [], transitionsAvailable: false} else (try {name: $n, transitions: ($desc[$n] | xml_parse | transitions($st[0]; $pk[0]))} catch {name: $n, transitions: [], transitionsAvailable: false, parseError: .}) end) ' "$TMP/sql-workflows.json" > "$TMP/workflows.json" jq -r '.[] | select(.parseError) | "\(.name): \(.parseError)"' "$TMP/workflows.json" | while IFS= read -r line; do printf 'could not parse the workflow descriptor of %s\n' "$line" >> "$TMP/warnings.txt" log " WARNING: could not parse the workflow descriptor of $line" done WARN_COUNT=$((WARN_COUNT + $(jq '[.[] | select(.parseError)] | length' "$TMP/workflows.json"))) jq -c 'map(del(.parseError))' "$TMP/workflows.json" > "$TMP/wf.json" && mv "$TMP/wf.json" "$TMP/workflows.json" missing=$(jq -r '[.[] | select(.transitionsAvailable == false) | .name] | join(", ")' "$TMP/workflows.json") nonsystem_missing=$(jq -r '[.[] | select(.transitionsAvailable == false and .name != "jira")] | length' "$TMP/workflows.json") if [ "$nonsystem_missing" -gt 0 ]; then COV_WORKFLOWS="rest-partial" warn "no usable descriptor in workflows.jsonl for: $missing" else COV_WORKFLOWS="full" [ -n "$missing" ] && info "the read-only system workflow \"jira\" is not stored in the database; its transitions are not exported" fi info "$(jq '[.[].transitions[]] | length' "$TMP/workflows.json") transitions, $(jq '[.[].transitions[].rules[]] | length' "$TMP/workflows.json") rules" else jq -c 'map({name: ., transitions: [], transitionsAvailable: false})' "$TMP/workflow-names.json" > "$TMP/workflows.json" COV_WORKFLOWS="none" warn "Jira DC REST does not expose workflow transitions or rules. Run the SQL query workflows.sql and pass --sql-export-dir to include them." fi info "$(jq 'length' "$TMP/workflows.json") workflows" # 9. Boards ------------------------------------------------------------------ BOARD_FILTER_IDS="[]" if [ "$NO_BOARDS" -eq 0 ]; then next_step "Boards (Jira Software)" : > "$TMP/boards.jsonl" COV_BOARDS="full" agile=1 if [ -n "$PROJECT_KEYS" ]; then scopes=$(cat "$TMP/project-keys.txt"); else scopes="__ALL__"; fi for scope in $scopes; do [ "$agile" -eq 1 ] || break start=0 while :; do if [ "$scope" = "__ALL__" ]; then q="startAt=$start&maxResults=50"; else q="projectKeyOrId=$scope&startAt=$start&maxResults=50"; fi if ! http_get "/rest/agile/1.0/board?$q" "$TMP/page.json"; then if [ "$HTTP_STATUS" = "404" ] && [ "$start" -eq 0 ] && { [ "$scope" = "__ALL__" ] || [ ! -s "$TMP/boards.jsonl" ]; }; then info "the agile REST API is not available (Jira Software not installed?); no boards" agile=0 break fi get_json "/rest/agile/1.0/board?$q" "$TMP/page.json" fi n=$(jq '(.values // []) | length' "$TMP/page.json") jq -c '(.values // [])[]' "$TMP/page.json" >> "$TMP/boards.jsonl" start=$((start + n)) last=$(jq -r 'if (.isLast // false) then "1" else "0" end' "$TMP/page.json") if [ "$last" = "1" ] || [ "$n" -eq 0 ]; then break; fi done done jq -s -c "$JQ_LIB"'unique_by(.id) | map({id: (.id | tostr), name: (.name // ""), type: (.type // null)} | compact)' "$TMP/boards.jsonl" > "$TMP/boards-base.json" : > "$TMP/boards-out.jsonl" denied=0 for id in $(jq -r '.[].id' "$TMP/boards-base.json"); do if http_get "/rest/agile/1.0/board/$id/configuration" "$TMP/page.json"; then fid=$(jq -r '.filter.id // "" | tostring' "$TMP/page.json") elif [ "$HTTP_STATUS" = "403" ] || [ "$HTTP_STATUS" = "404" ] || [ "$HTTP_STATUS" = "400" ]; then fid=""; denied=$((denied + 1)) else get_json "/rest/agile/1.0/board/$id/configuration" "$TMP/page.json" fi jq -c --arg id "$id" --arg fid "$fid" '.[] | select(.id == $id) | .filterId = (if $fid == "" then null else $fid end)' "$TMP/boards-base.json" >> "$TMP/boards-out.jsonl" done jq -s -c '.' "$TMP/boards-out.jsonl" > "$TMP/boards.json" if [ "$denied" -gt 0 ]; then COV_BOARDS="rest-partial" warn "$denied board configuration(s) not readable by this account (board filter not shared with it); their filterId is null" fi BOARD_FILTER_IDS=$(jq -c '[.[].filterId | select(. != null)] | unique' "$TMP/boards.json") info "$(jq 'length' "$TMP/boards.json") boards" fi # 10. Filters ---------------------------------------------------------------- next_step "Filters" if [ -n "$SQL_FILTERS" ]; then read_jsonl "$SQL_FILTERS" "$TMP/sql-filters.json" jq -c --slurpfile sh "$TMP/sql-shares.json" "$JQ_LIB"' ($sh[0] | map(select(.entityType == "SearchRequest")) | sort_by(.id | numid) | group_by(.entityId | tostr) | map({key: (.[0].entityId | tostr), value: map(sql_share)}) | from_entries) as $shares | map({id: (.id | tostr), name: (.name // ""), description: (.description | nonempty), jql: (.jql // ""), owner: (.owner // "" | tostring)} | .sharePermissions = ($shares[.id] // [])) ' "$TMP/sql-filters.json" > "$TMP/filters.json" COV_FILTERS="full" info "from SQL filters.jsonl (all filters, including private ones)" else get_json "/rest/api/2/filter/favourite" "$TMP/page.json" jq -c '.[]' "$TMP/page.json" > "$TMP/filters.jsonl" info "$(jq 'length' "$TMP/page.json") favourite filters of this account" if [ "$FILTER_SCAN_GAP" -gt 0 ]; then known=$(jq -c --argjson b "$BOARD_FILTER_IDS" '[.[].id | tostring] + $b | map(tonumber) | unique' "$TMP/page.json") have=" $(jq -r '[.[].id | tostring] | join(" ")' "$TMP/page.json") " max_known=$(printf '%s' "$known" | jq 'max // 10000') [ "$max_known" -ge 10000 ] || max_known=10000 info "probing filter IDs from 10000 (highest known $max_known, stop after $FILTER_SCAN_GAP consecutive misses)" id=10000; misses=0; hidden=0; probed=0 while [ "$id" -le "$max_known" ] || [ "$misses" -lt "$FILTER_SCAN_GAP" ]; do case "$have" in *" $id "*) misses=0; id=$((id + 1)); continue ;; esac probed=$((probed + 1)) if http_get "/rest/api/2/filter/$id" "$TMP/page.json"; then jq -c '.' "$TMP/page.json" >> "$TMP/filters.jsonl" misses=0 else case "$HTTP_STATUS" in 400|403|404) misses=$((misses + 1)); hidden=$((hidden + 1)) ;; *) get_json "/rest/api/2/filter/$id" "$TMP/page.json" ;; esac fi if [ $((probed % 200)) -eq 0 ]; then info "probed up to filter $id"; fi id=$((id + 1)) done info "probed $probed IDs; $hidden missing or not visible to this account" else have=" $(jq -r '[.[].id | tostring] | join(" ")' "$TMP/page.json") " for id in $(printf '%s' "$BOARD_FILTER_IDS" | jq -r '.[]'); do case "$have" in *" $id "*) continue ;; esac if http_get "/rest/api/2/filter/$id" "$TMP/page.json"; then jq -c '.' "$TMP/page.json" >> "$TMP/filters.jsonl" else case "$HTTP_STATUS" in 400|403|404) info "board filter $id is not visible to this account" ;; *) get_json "/rest/api/2/filter/$id" "$TMP/page.json" ;; esac fi done fi jq -s -c "$JQ_LIB"'unique_by(.id | tostring) | map(rest_filter)' "$TMP/filters.jsonl" > "$TMP/filters.json" COV_FILTERS="rest-partial" warn "Jira DC REST cannot list all filters: only filters visible to this account were exported (other users' private filters are missing). Run filters.sql and share-permissions.sql and pass --sql-export-dir for a complete list." fi share_count=$(jq '[.[] | .sharePermissions | length] | add // 0' "$TMP/filters.json") info "$(jq 'length' "$TMP/filters.json") filters, $share_count share permissions" # 11. Dashboards ------------------------------------------------------------- next_step "Dashboards" if [ -n "$SQL_DASHBOARDS" ]; then read_jsonl "$SQL_DASHBOARDS" "$TMP/sql-dashboards.json" read_jsonl "$SQL_GADGETS" "$TMP/sql-gadgets.json" jq -c --slurpfile sh "$TMP/sql-shares.json" --slurpfile g "$TMP/sql-gadgets.json" "$JQ_LIB"' ($sh[0] | map(select(.entityType == "PortalPage")) | sort_by(.id | numid) | group_by(.entityId | tostr) | map({key: (.[0].entityId | tostr), value: map(sql_share)}) | from_entries) as $shares | ($g[0] | sort_by([(.column | numid), (.row | numid), (.gadgetId | numid)]) | group_by(.dashboardId | tostr) | map({key: (.[0].dashboardId | tostr), value: map((if (.moduleKey | nonempty) != null then {moduleKey: .moduleKey} elif (.gadgetXml | nonempty) != null then {moduleKey: (.gadgetXml | (capture("/g/(?[^/]+)/") | .k) // .)} else {} end) + {filterId: ((.filterPref // "") | tostring | if test("^filter-[0-9]+$") then ltrimstr("filter-") elif test("^[0-9]+$") then . else null end)})}) | from_entries) as $gadgets | map({id: (.id | tostr), name: (.name // ""), owner: (.owner | nonempty)} | .sharePermissions = ($shares[.id] // []) | .gadgets = ($gadgets[.id] // [])) ' "$TMP/sql-dashboards.json" > "$TMP/dashboards.json" COV_DASHBOARDS="full" info "from SQL dashboards.jsonl with owners, shares and gadgets" else : > "$TMP/dashboards.jsonl" start=0; max=50 while :; do get_json "/rest/api/2/dashboard?startAt=$start&maxResults=$max" "$TMP/page.json" jq -c '(.dashboards // [])[]' "$TMP/page.json" >> "$TMP/dashboards.jsonl" n=$(jq '(.dashboards // []) | length' "$TMP/page.json") total=$(jq '.total // 0' "$TMP/page.json") pm=$(jq '.maxResults // 0' "$TMP/page.json") [ "$pm" -gt 0 ] || pm=$n start=$((start + pm)); max=$pm if [ "$n" -eq 0 ] || [ "$start" -ge "$total" ]; then break; fi done jq -s -c "$JQ_LIB"'unique_by(.id | tostring) | map({id: (.id | tostr), name: (.name // "")})' "$TMP/dashboards.jsonl" > "$TMP/dashboards.json" COV_DASHBOARDS="rest-no-owner" warn "Jira DC REST returns dashboards without owner, sharing or gadgets, and only those visible to this account. Run dashboards.sql, dashboard-gadgets.sql and share-permissions.sql and pass --sql-export-dir for full data." fi info "$(jq 'length' "$TMP/dashboards.json") dashboards" # ---------------------------------------------------------------- assemble step "Writing $OUT" CREATED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) [ -f "$TMP/boards.json" ] || echo 'null' > "$TMP/boards.json" jq -n "$JQ_LIB"' { format: "migration-doctor-snapshot", schemaVersion: 1, createdAt: $createdAt, generator: {name: $gname, version: $gversion, runtime: "bash"}, source: ({product: "jira", deployment: "datacenter", version: $info[0].version, baseUrl: ($info[0].baseUrl // $baseUrl), serverTitle: ($info[0].serverTitle // null)} | compact), coverage: ({users: $cu, groups: $cg, projects: "full", fields: "full", statuses: "full", filters: $cf, dashboards: $cd, workflows: $cw, permissionSchemes: "full"} + (if $boards[0] != null then {boards: $cb} else {} end)), users: ($users[0] | sort_by(.name)), groups: ($groups[0] | sort_by(.name)), projects: ($projects[0] | sort_by(.key)), fields: ($fields[0] | sort_by(.id)), statuses: ($statuses[0] | sort_by(.id | numid)), filters: ($filters[0] | sort_by(.id | numid)), dashboards: ($dashboards[0] | sort_by(.id | numid)), workflows: ($workflows[0] | sort_by(.name)), permissionSchemes: ($perms[0] | sort_by(.id | numid)) } + (if $boards[0] != null then {boards: ($boards[0] | sort_by(.id | numid))} else {} end) ' --arg createdAt "$CREATED_AT" --arg gname "$GENERATOR_NAME" --arg gversion "$GENERATOR_VERSION" --arg baseUrl "$BASE_URL" \ --arg cu "$COV_USERS" --arg cg "$COV_GROUPS" --arg cf "$COV_FILTERS" --arg cd "$COV_DASHBOARDS" --arg cw "$COV_WORKFLOWS" --arg cb "${COV_BOARDS:-full}" \ --slurpfile info "$TMP/serverInfo.json" --slurpfile users "$TMP/users.json" --slurpfile groups "$TMP/groups.json" \ --slurpfile projects "$TMP/projects.json" --slurpfile fields "$TMP/fields.json" --slurpfile statuses "$TMP/statuses.json" \ --slurpfile filters "$TMP/filters.json" --slurpfile dashboards "$TMP/dashboards.json" --slurpfile workflows "$TMP/workflows.json" \ --slurpfile perms "$TMP/permschemes.json" --slurpfile boards "$TMP/boards.json" \ > "$TMP/snapshot.json" OUT_DIR=$(dirname -- "$OUT") [ -d "$OUT_DIR" ] || die "output directory does not exist: $OUT_DIR" ( umask 077; cat "$TMP/snapshot.json" > "$OUT" ) || die "could not write $OUT" # ---------------------------------------------------------------- summary ELAPSED=$(( $(date +%s) - START_TS )) log "" log "Snapshot written: $OUT ($(wc -c < "$OUT" | tr -d ' ') bytes, $REQUEST_COUNT GET requests, ${ELAPSED}s)" log "" jq -r ' def row($k; $n): " \($k | . + (" " * (20 - length)))\($n)"; row("users"; .users | length), row("groups"; .groups | length), row("projects"; .projects | length), row("fields"; .fields | length), row("statuses"; .statuses | length), row("filters"; .filters | length), row("dashboards"; .dashboards | length), row("workflows"; "\(.workflows | length) (\([.workflows[].transitions[]] | length) transitions, \([.workflows[].transitions[].rules[]] | length) rules)"), row("permission schemes"; .permissionSchemes | length), (if .boards then row("boards"; .boards | length) else empty end), "", " Coverage: " + (.coverage | to_entries | map("\(.key)=\(.value)") | join(", ")) ' "$OUT" >&2 if [ "$WARN_COUNT" -gt 0 ]; then log "" log "$WARN_COUNT warning(s):" sed 's/^/ - /' "$TMP/warnings.txt" >&2 fi log "" log "Privacy: this file contains user names, display names and e-mail addresses. Keep it on this" log "machine until you upload it into the Migration Doctor app on your own Jira Cloud site." exit 0