<# .SYNOPSIS Read-only Jira Data Center configuration snapshot for Migration Doctor (PowerShell edition). .DESCRIPTION Sends only HTTP GET requests to the Jira REST API and writes one JSON file (format "migration-doctor-snapshot", schemaVersion 1). Never writes to Jira. Collects configuration only: no issues, no comments, no attachments. Runs on Windows PowerShell 5.1 and PowerShell 7+, with built-in cmdlets and .NET only. Authentication comes only from environment variables, never from arguments: JIRA_PAT personal access token (sent as a Bearer token), or JIRA_USER and JIRA_PASSWORD basic authentication .PARAMETER BaseUrl Jira base URL, e.g. https://jira.example.com or https://example.com/jira .PARAMETER Out Output file. Default: migration-doctor-snapshot-.json in the current directory. .PARAMETER Projects Limit projects, their permission schemes, workflows and boards to these keys (ACME,OPS). .PARAMETER SqlExportDir Directory with the results of the read-only SQL queries in sql// (users, groups, filters, share-permissions, dashboards, dashboard-gadgets, workflows, plugins as .jsonl). .PARAMETER FilterScanGap Without SQL exports, probe filter IDs from 10000 upwards and stop after this many consecutive IDs that are missing or not visible (default 100, 0 disables probing). .PARAMETER DelayMs Pause between requests in milliseconds (default 100). .PARAMETER MaxRetries Retries for HTTP 429, 502, 503, 504 and network errors (default 5). .PARAMETER NoBoards Skip Jira Software boards. .EXAMPLE $env:JIRA_PAT = Read-Host 'Personal access token' -MaskInput # PowerShell 7 .\snapshot.ps1 -BaseUrl https://jira.example.com .NOTES Copyright (c) 2026 Nox Development. MIT License. #> [CmdletBinding()] param( [string]$BaseUrl, [string]$Out, [string[]]$Projects, [string]$SqlExportDir, [int]$FilterScanGap = 100, [int]$DelayMs = 100, [int]$MaxRetries = 5, [switch]$NoBoards, [switch]$Version ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 2.0 $GeneratorName = 'migration-doctor-snapshot' $GeneratorVersion = '1.0.0' if ($Version) { [Console]::Out.WriteLine("$GeneratorName $GeneratorVersion"); exit 0 } # ------------------------------------------------------------------ output helpers $script:StartTime = Get-Date $script:Warnings = New-Object System.Collections.Generic.List[string] $script:RequestCount = 0 function Log([string]$Text) { [Console]::Error.WriteLine($Text) } function Step([string]$Text) { Log ''; Log "==> $Text" } function Info([string]$Text) { Log " $Text" } function Warn([string]$Text) { $script:Warnings.Add($Text); Log " WARNING: $Text" } # Known failures carry an exit code (1 = run failed, 2 = usage error) and a clean message. $script:FailCode = 0 $script:FailText = '' function Fail([string]$Text) { $script:FailCode = 1; $script:FailText = $Text; throw 'migration-doctor-snapshot failure' } function UsageFail([string]$Text) { $script:FailCode = 2; $script:FailText = "$Text`nRun Get-Help .\snapshot.ps1 -Detailed for usage."; throw 'migration-doctor-snapshot failure' } function NewMap { return , ([System.Collections.Hashtable]::new([System.StringComparer]::Ordinal)) } # ------------------------------------------------------------------ JSON $script:UseSerializer = $PSVersionTable.PSVersion.Major -lt 6 if ($script:UseSerializer) { Add-Type -AssemblyName System.Web.Extensions $script:Serializer = New-Object System.Web.Script.Serialization.JavaScriptSerializer $script:Serializer.MaxJsonLength = [int]::MaxValue $script:Serializer.RecursionLimit = 1000 } # Parses JSON into dictionaries and object[] on both PowerShell editions. function ConvertFrom-JsonText([string]$Text) { if ($script:UseSerializer) { return , $script:Serializer.DeserializeObject($Text) } return , (ConvertFrom-Json -InputObject $Text -AsHashtable -NoEnumerate) } function V($Obj, [string]$Key) { if ($null -eq $Obj -or -not ($Obj -is [System.Collections.IDictionary])) { return $null } if ($Obj -is [System.Collections.Specialized.OrderedDictionary]) { if ($Obj.Contains($Key)) { return $Obj[$Key] }; return $null } if ($Obj.ContainsKey($Key)) { return $Obj[$Key] } return $null } function Arr($Value) { if ($null -eq $Value) { return , @() } if ($Value -is [System.Collections.IList] -and -not ($Value -is [string])) { return , $Value } $one = New-Object 'object[]' 1 $one[0] = $Value return , $one } function Str($Value) { if ($null -eq $Value) { return $null } if ($Value -is [string]) { return $Value } if ($Value -is [bool]) { if ($Value) { return 'true' } else { return 'false' } } return [System.Convert]::ToString($Value, [System.Globalization.CultureInfo]::InvariantCulture) } function NonEmpty($Value) { if ($null -eq $Value -or ($Value -is [string] -and $Value -eq '')) { return $null }; return $Value } function ToBool($Value) { if ($Value -is [bool]) { return $Value } $s = Str $Value return ($s -ceq '1' -or $s -ceq 'true' -or $s -ceq 'Y' -or $s -ceq 'y') } function NumId($Value) { $n = 0.0 if ([double]::TryParse((Str $Value), [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$n)) { return $n } return 0.0 } function AsciiLower([string]$Text) { if ($null -eq $Text) { return $null } $chars = $Text.ToCharArray() for ($i = 0; $i -lt $chars.Length; $i++) { if ($chars[$i] -ge 'A' -and $chars[$i] -le 'Z') { $chars[$i] = [char]([int]$chars[$i] + 32) } } return -join $chars } function NewList { return , (New-Object System.Collections.Generic.List[object]) } function Quote-Json([string]$Text) { $sb = New-Object System.Text.StringBuilder ($Text.Length + 2) [void]$sb.Append('"') foreach ($c in $Text.ToCharArray()) { $code = [int]$c if ($c -eq '"') { [void]$sb.Append('\"') } elseif ($c -eq '\') { [void]$sb.Append('\\') } elseif ($code -eq 10) { [void]$sb.Append('\n') } elseif ($code -eq 13) { [void]$sb.Append('\r') } elseif ($code -eq 9) { [void]$sb.Append('\t') } elseif ($code -lt 32 -or $code -eq 127) { [void]$sb.Append(('\u{0:x4}' -f $code)) } else { [void]$sb.Append($c) } } [void]$sb.Append('"') return $sb.ToString() } function Write-JsonValue([System.Text.StringBuilder]$Sb, $Value, [int]$Indent) { $pad = ' ' * ($Indent + 1) $end = ' ' * $Indent if ($null -eq $Value) { [void]$Sb.Append('null'); return } if ($Value -is [string]) { [void]$Sb.Append((Quote-Json $Value)); return } if ($Value -is [bool]) { if ($Value) { [void]$Sb.Append('true') } else { [void]$Sb.Append('false') }; return } if ($Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal] -or $Value -is [single]) { [void]$Sb.Append([System.Convert]::ToString($Value, [System.Globalization.CultureInfo]::InvariantCulture)); return } if ($Value -is [System.Collections.IDictionary]) { if ($Value.Count -eq 0) { [void]$Sb.Append('{}'); return } [void]$Sb.Append("{`n") $first = $true foreach ($k in $Value.Keys) { if (-not $first) { [void]$Sb.Append(",`n") } $first = $false [void]$Sb.Append($pad).Append((Quote-Json ([string]$k))).Append(': ') Write-JsonValue $Sb $Value[$k] ($Indent + 1) } [void]$Sb.Append("`n").Append($end).Append('}') return } if ($Value -is [System.Collections.IEnumerable]) { $items = New-Object System.Collections.Generic.List[object] foreach ($x in $Value) { $items.Add($x) } if ($items.Count -eq 0) { [void]$Sb.Append('[]'); return } [void]$Sb.Append("[`n") for ($i = 0; $i -lt $items.Count; $i++) { if ($i -gt 0) { [void]$Sb.Append(",`n") } [void]$Sb.Append($pad) Write-JsonValue $Sb $items[$i] ($Indent + 1) } [void]$Sb.Append("`n").Append($end).Append(']') return } [void]$Sb.Append((Quote-Json ([string]$Value))) } function ConvertTo-JsonText($Value) { $sb = New-Object System.Text.StringBuilder Write-JsonValue $sb $Value 0 [void]$sb.Append("`n") return $sb.ToString() } # Stable, culture-independent sorting (ordinal strings, numeric ids). function Sort-ByString($Items, [scriptblock]$Key) { $list = Arr $Items $arr = New-Object 'object[]' $list.Count for ($i = 0; $i -lt $list.Count; $i++) { $arr[$i] = [object[]]@((& $Key $list[$i]), $i, $list[$i]) } $cmp = [System.Collections.Generic.Comparer[object]]::Create([System.Comparison[object]] { param($a, $b) $r = [string]::CompareOrdinal([string]$a[0], [string]$b[0]) if ($r -ne 0) { return $r } return ([int]$a[1]).CompareTo([int]$b[1]) }) [System.Array]::Sort($arr, $cmp) $out = NewList foreach ($e in $arr) { $out.Add($e[2]) } return , $out } function Sort-ByNumber($Items, [scriptblock]$Key) { $list = Arr $Items $arr = New-Object 'object[]' $list.Count for ($i = 0; $i -lt $list.Count; $i++) { $arr[$i] = [object[]]@((NumId (& $Key $list[$i])), $i, $list[$i]) } $cmp = [System.Collections.Generic.Comparer[object]]::Create([System.Comparison[object]] { param($a, $b) $r = ([double]$a[0]).CompareTo([double]$b[0]) if ($r -ne 0) { return $r } return ([int]$a[1]).CompareTo([int]$b[1]) }) [System.Array]::Sort($arr, $cmp) $out = NewList foreach ($e in $arr) { $out.Add($e[2]) } return , $out } # ------------------------------------------------------------------ arguments try { if ([string]::IsNullOrWhiteSpace($BaseUrl)) { UsageFail '-BaseUrl is required' } if ($BaseUrl -notmatch '^https?://') { UsageFail '-BaseUrl must start with https:// or http://' } if ($BaseUrl -match '^https?://[^/]*@') { UsageFail '-BaseUrl must not contain credentials; use $env:JIRA_PAT or $env:JIRA_USER/$env:JIRA_PASSWORD' } $script:Base = $BaseUrl.TrimEnd('/') if ($FilterScanGap -lt 0 -or $DelayMs -lt 0 -or $MaxRetries -lt 0) { UsageFail 'numeric options take a non-negative integer' } if ([string]::IsNullOrWhiteSpace($Out)) { $Out = 'migration-doctor-snapshot-' + (Get-Date).ToString('yyyy-MM-dd') + '.json' } $OutPath = [System.IO.Path]::Combine((Get-Location).ProviderPath, $Out) $ProjectKeys = New-Object System.Collections.Generic.List[string] foreach ($p in @($Projects)) { if ($null -eq $p) { continue } foreach ($k in ($p -split ',')) { $k = $k.Trim().ToUpperInvariant() if ($k -eq '') { continue } if ($k -cnotmatch '^[A-Z][A-Z0-9_]*$') { UsageFail "invalid project key: $k" } if (-not $ProjectKeys.Contains($k)) { $ProjectKeys.Add($k) } } } $ProjectKeys.Sort([System.StringComparer]::Ordinal) $Filtered = $ProjectKeys.Count -gt 0 if ($SqlExportDir) { $SqlExportDir = [System.IO.Path]::Combine((Get-Location).ProviderPath, $SqlExportDir) if (-not [System.IO.Directory]::Exists($SqlExportDir)) { UsageFail "-SqlExportDir does not exist: $SqlExportDir" } } # ---------------------------------------------------------------- authentication if ($env:JIRA_PAT) { $AuthMode = 'personal access token' $script:AuthHeader = 'Bearer ' + $env:JIRA_PAT } elseif ($env:JIRA_USER -and $env:JIRA_PASSWORD) { $AuthMode = "basic authentication as $($env:JIRA_USER)" $script:AuthHeader = 'Basic ' + [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($env:JIRA_USER + ':' + $env:JIRA_PASSWORD)) } else { UsageFail 'no credentials. Set $env:JIRA_PAT (recommended), or $env:JIRA_USER and $env:JIRA_PASSWORD.' } if ($PSVersionTable.PSVersion.Major -lt 6) { [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 } } catch { if ($script:FailCode -eq 0) { $script:FailCode = 2; $script:FailText = $_.Exception.Message } [Console]::Error.WriteLine('ERROR: ' + $script:FailText) exit $script:FailCode } # ------------------------------------------------------------------ HTTP (GET only) # Returns @{ Status; Body; Error }. Retries 429/502/503/504 and network errors with # backoff, honouring Retry-After. Redirects are not followed, so credentials are never # sent to another host. function Invoke-JiraGet([string]$PathAndQuery) { $attempt = 0 while ($true) { if ($DelayMs -gt 0) { Start-Sleep -Milliseconds $DelayMs } $script:RequestCount++ $status = 0; $body = ''; $retryAfter = $null; $netError = $null; $retryable = $true $resp = $null try { $req = [System.Net.HttpWebRequest]::Create($script:Base + $PathAndQuery) $req.Method = 'GET' $req.AllowAutoRedirect = $false $req.Accept = 'application/json' $req.Headers.Add('Authorization', $script:AuthHeader) $req.Headers.Add('X-Atlassian-Token', 'no-check') $req.Timeout = 300000 $req.ReadWriteTimeout = 300000 $resp = $req.GetResponse() } catch { $ex = $_.Exception while ($null -ne $ex -and -not ($ex -is [System.Net.WebException])) { $ex = $ex.InnerException } if ($null -ne $ex -and $null -ne $ex.Response) { $resp = $ex.Response } elseif ($null -ne $ex) { $netError = $ex.Message if ($ex.Status -eq [System.Net.WebExceptionStatus]::TrustFailure) { $retryable = $false } } else { $netError = $_.Exception.Message; $retryable = $false } } if ($null -ne $resp) { $status = [int]$resp.StatusCode $retryAfter = $resp.Headers['Retry-After'] $stream = $resp.GetResponseStream() $reader = New-Object System.IO.StreamReader($stream, (New-Object System.Text.UTF8Encoding($false))) $body = $reader.ReadToEnd() $reader.Close() $resp.Close() } if ($status -ge 200 -and $status -lt 300) { return @{ Status = $status; Body = $body; Error = $null } } $transient = ($status -in @(429, 502, 503, 504)) -or ($status -eq 0 -and $retryable) if (-not $transient) { return @{ Status = $status; Body = $body; Error = $netError } } $attempt++ if ($attempt -gt $MaxRetries) { return @{ Status = $status; Body = $body; Error = $netError } } $wait = 0 if (-not ($retryAfter -and [int]::TryParse(($retryAfter -replace '[^0-9]', ''), [ref]$wait))) { $wait = [math]::Pow(2, $attempt) } if ($wait -gt 60) { $wait = 60 } if ($status -eq 0) { Info "network error ($netError), retry $attempt/$MaxRetries in ${wait}s" } else { Info "HTTP $status from server, retry $attempt/$MaxRetries in ${wait}s" } Start-Sleep -Seconds $wait } } function Get-ErrorDetail([string]$Body) { if (-not $Body) { return '' } try { $j = ConvertFrom-JsonText $Body $parts = New-Object System.Collections.Generic.List[string] foreach ($m in (Arr (V $j 'errorMessages'))) { $parts.Add([string]$m) } $errs = V $j 'errors' if ($errs -is [System.Collections.IDictionary]) { foreach ($k in $errs.Keys) { $parts.Add("${k}: $($errs[$k])") } } if (V $j 'message') { $parts.Add([string](V $j 'message')) } $s = $parts -join '; ' if ($s.Length -gt 300) { $s = $s.Substring(0, 300) } return $s } catch { return '' } } # GET and parse JSON; fails the run on any non-2xx response. function Get-Json([string]$PathAndQuery) { $r = Invoke-JiraGet $PathAndQuery if ($r.Status -ge 200 -and $r.Status -lt 300) { try { return , (ConvertFrom-JsonText $r.Body) } catch { Fail "GET $PathAndQuery returned a response that is not JSON. Check -BaseUrl (is a proxy or SSO page answering?)." } } Fail-Http $PathAndQuery $r } function Fail-Http([string]$PathAndQuery, $R) { $detail = Get-ErrorDetail $R.Body $hint = '' switch ($R.Status) { 0 { $hint = " Could not connect: $($R.Error)" } { $_ -ge 300 -and $_ -lt 400 } { $hint = ' The server redirected the request; use the final URL as -BaseUrl (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 -BaseUrl (include the context path, e.g. /jira).' } } $said = '' if ($detail) { $said = " Server said: $detail." } Fail "GET $PathAndQuery failed with HTTP $($R.Status).$said$hint" } function Try-Json([string]$PathAndQuery) { # Returns @{ Status; Json } without failing on 4xx. $r = Invoke-JiraGet $PathAndQuery if ($r.Status -ge 200 -and $r.Status -lt 300) { return @{ Ok = $true; Status = $r.Status; Json = (ConvertFrom-JsonText $r.Body); R = $r } } return @{ Ok = $false; Status = $r.Status; Json = $null; R = $r } } function Find-SqlExport([string]$Name) { if (-not $SqlExportDir) { return $null } foreach ($ext in @('.jsonl', '.json')) { $p = [System.IO.Path]::Combine($SqlExportDir, $Name + $ext) if ([System.IO.File]::Exists($p)) { return $p } } return $null } # JSON Lines from a database client. Lines that do not start with "{" are ignored. function Read-JsonLines([string]$Path) { $list = NewList $n = 0 foreach ($line in [System.IO.File]::ReadAllLines($Path, [System.Text.Encoding]::UTF8)) { $n++ $t = $line.Trim().TrimStart([char]0xFEFF).Trim() if (-not $t.StartsWith('{')) { continue } try { $list.Add((ConvertFrom-JsonText $t)) } catch { $head = $t if ($head.Length -gt 120) { $head = $head.Substring(0, 120) } Fail "invalid JSON line in ${Path}: $head (each line must be one JSON object; see README, SQL exports)" } } return , $list } # ------------------------------------------------------------------ mapping helpers $ShareTypes = @('global', 'loggedin', 'authenticated') function Compact($Map) { $o = [ordered]@{} foreach ($k in $Map.Keys) { if ($null -ne $Map[$k]) { $o[$k] = $Map[$k] } } return $o } function Map-RestShare($S) { $t = V $S 'type' if ($t -ceq 'group') { return Compact ([ordered]@{ type = 'group'; group = (V (V $S 'group') 'name') }) } if (($t -ceq 'project' -and $null -ne (V $S 'role')) -or $t -ceq 'projectRole') { return Compact ([ordered]@{ type = 'projectRole'; projectKey = (V (V $S 'project') 'key'); role = (V (V $S 'role') 'name') }) } if ($t -ceq 'project') { return Compact ([ordered]@{ type = 'project'; projectKey = (V (V $S 'project') 'key') }) } if ($t -ceq 'user') { return Compact ([ordered]@{ type = 'user'; user = (V (V $S 'user') 'name') }) } if ($ShareTypes -ccontains $t) { return [ordered]@{ type = $t } } return $null } function Map-SqlShare($S) { $t = V $S 'shareType' if ($t -ceq 'group') { return Compact ([ordered]@{ type = 'group'; group = (V $S 'group') }) } if ($t -ceq 'project' -and $null -ne (V $S 'role')) { return Compact ([ordered]@{ type = 'projectRole'; projectKey = (V $S 'projectKey'); role = (V $S 'role') }) } if ($t -ceq 'project') { return Compact ([ordered]@{ type = 'project'; projectKey = (V $S 'projectKey') }) } if ($t -ceq 'user') { return Compact ([ordered]@{ type = 'user'; user = (V $S 'user') }) } if ($ShareTypes -ccontains $t) { return [ordered]@{ type = $t } } return $null } function Map-RestFilter($F) { $shares = NewList foreach ($s in (Arr (V $F 'sharePermissions'))) { $m = Map-RestShare $s; if ($null -ne $m) { $shares.Add($m) } } $jql = V $F 'jql'; if ($null -eq $jql) { $jql = '' } $name = V $F 'name'; if ($null -eq $name) { $name = '' } $owner = V (V $F 'owner') 'name'; if ($null -eq $owner) { $owner = '' } return [ordered]@{ id = (Str (V $F 'id')) name = $name description = (NonEmpty (V $F 'description')) jql = $jql owner = $owner sharePermissions = $shares } } function Group-SqlShares($Rows, [string]$EntityType) { $map = NewMap foreach ($s in (Sort-ByNumber ($Rows | Where-Object { (V $_ 'entityType') -ceq $EntityType }) { param($x) V $x 'id' })) { $key = Str (V $s 'entityId') if (-not $map.ContainsKey($key)) { $map[$key] = NewList } $m = Map-SqlShare $s if ($null -ne $m) { $map[$key].Add($m) } } return $map } # ------------------------------------------------------------------ workflow descriptor parser function Get-Kids($Node, [string]$Name) { $out = NewList if ($null -eq $Node) { return , $out } # get_Name() and friends: the PowerShell XML adapter would return a 'name' attribute for .Name foreach ($c in $Node.get_ChildNodes()) { if ($c.get_NodeType() -eq [System.Xml.XmlNodeType]::Element -and $c.get_Name() -ceq $Name) { $out.Add($c) } } return , $out } function Get-Descendants($Node, [string]$Name) { $out = NewList foreach ($c in $Node.SelectNodes('.//' + $Name)) { $out.Add($c) } return , $out } function Get-Text($Node) { return $Node.get_InnerText().Trim() } function Get-Arg($Node, [string]$Name) { foreach ($a in (Get-Kids $Node 'arg')) { if ($a.GetAttribute('name') -ceq $Name) { return (Get-Text $a) } } return $null } function Get-Attr($Node, [string]$Name) { if ($Node.HasAttribute($Name)) { return $Node.GetAttribute($Name) } return $null } function Get-PluginKey($Cls, $Mk, $PluginKeys) { $pk = $null if ($null -ne $Mk -and $Mk -ne '') { foreach ($p in $PluginKeys) { if ($Mk.StartsWith($p, [System.StringComparison]::Ordinal) -and $Mk.Length -gt $p.Length) { $pk = $p; break } } if ($null -eq $pk -and $Mk.Contains(':')) { $pk = $Mk.Split(':')[0] } if ($null -eq $pk -and $Mk.StartsWith('com.atlassian.jira.plugin.system.', [System.StringComparison]::Ordinal)) { $pk = 'com.atlassian.jira' } } if ($null -eq $pk -and $null -ne $Cls -and $Cls -cmatch '^com\.atlassian\.jira\.|^com\.opensymphony\.') { $pk = 'com.atlassian.jira' } if ($null -ne $pk -and $pk.StartsWith('com.atlassian.jira.plugin.system.', [System.StringComparison]::Ordinal)) { $pk = 'com.atlassian.jira' } return $pk } function New-Rule($Node, [string]$Kind, $PluginKeys) { $cls = Get-Arg $Node 'class.name' $mk = Get-Arg $Node 'full.module.key' $type = $cls if ($null -eq $type) { $type = $mk } if ($null -eq $type) { $type = Get-Attr $Node 'type' } if ($null -eq $type) { $type = 'unknown' } $r = [ordered]@{ kind = $Kind; type = $type } $pk = Get-PluginKey $cls $mk $PluginKeys if ($null -ne $pk) { $r.pluginKey = $pk } if ($null -ne $mk -and $mk -ne '') { $r.moduleKey = $mk } return $r } function Get-Rules($Action, $PluginKeys) { $rules = NewList foreach ($rt in (Get-Kids $Action 'restrict-to')) { foreach ($c in (Get-Descendants $rt 'condition')) { $rules.Add((New-Rule $c 'condition' $PluginKeys)) } } foreach ($vs in (Get-Kids $Action 'validators')) { foreach ($v in (Get-Kids $vs 'validator')) { $rules.Add((New-Rule $v 'validator' $PluginKeys)) } } foreach ($pf in (Get-Descendants $Action 'post-functions')) { foreach ($f in (Get-Kids $pf 'function')) { $rules.Add((New-Rule $f 'postFunction' $PluginKeys)) } } return , $rules } function New-Transition($Action, $From, $StepStatus, $PluginKeys) { $to = $null foreach ($res in (Get-Kids $Action 'results')) { foreach ($u in (Get-Kids $res 'unconditional-result')) { $to = Get-Attr $u 'step'; break } break } $toName = '' if ($null -ne $to -and $to -cne '-1' -and $StepStatus.ContainsKey($to)) { $toName = $StepStatus[$to] } $id = Get-Attr $Action 'id'; if ($null -eq $id) { $id = '' } $name = Get-Attr $Action 'name'; if ($null -eq $name) { $name = '' } $t = [ordered]@{ id = $id; name = $name; from = $From; to = $toName; rules = (Get-Rules $Action $PluginKeys) } if ($to -ceq '-1') { $t.looped = $true } return $t } function Get-Transitions([string]$Descriptor, $StatusNames, $PluginKeys) { $settings = New-Object System.Xml.XmlReaderSettings $settings.DtdProcessing = [System.Xml.DtdProcessing]::Ignore $settings.XmlResolver = $null $reader = [System.Xml.XmlReader]::Create((New-Object System.IO.StringReader($Descriptor)), $settings) try { $doc = New-Object System.Xml.XmlDocument $doc.XmlResolver = $null $doc.Load($reader) } finally { $reader.Close() } $wf = $doc.get_DocumentElement() $steps = NewList foreach ($ss in (Get-Kids $wf 'steps')) { foreach ($s in (Get-Kids $ss 'step')) { $steps.Add($s) } } $stepStatus = NewMap foreach ($s in $steps) { $sid = $null foreach ($m in (Get-Kids $s 'meta')) { if ($m.GetAttribute('name') -ceq 'jira.status.id') { $sid = Get-Text $m; break } } $sname = Get-Attr $s 'name'; if ($null -eq $sname) { $sname = '' } if ($null -ne $sid -and $StatusNames.ContainsKey($sid)) { $sname = $StatusNames[$sid] } $sk = Get-Attr $s 'id'; if ($null -eq $sk) { $sk = '' } $stepStatus[$sk] = $sname } $list = NewList foreach ($g in (Get-Kids $wf 'initial-actions')) { foreach ($a in (Get-Kids $g 'action')) { $list.Add((New-Transition $a (NewList) $stepStatus $PluginKeys)) } } foreach ($g in (Get-Kids $wf 'global-actions')) { foreach ($a in (Get-Kids $g 'action')) { $list.Add((New-Transition $a (NewList) $stepStatus $PluginKeys)) } } foreach ($g in (Get-Kids $wf 'common-actions')) { foreach ($a in (Get-Kids $g 'action')) { $from = NewList foreach ($s in $steps) { $uses = $false foreach ($acts in (Get-Kids $s 'actions')) { foreach ($ca in (Get-Kids $acts 'common-action')) { if ((Get-Attr $ca 'id') -ceq (Get-Attr $a 'id')) { $uses = $true } } } $sk = Get-Attr $s 'id'; if ($null -eq $sk) { $sk = '' } if ($uses -and -not $from.Contains($stepStatus[$sk])) { $from.Add($stepStatus[$sk]) } } $list.Add((New-Transition $a $from $stepStatus $PluginKeys)) } } foreach ($s in $steps) { $sk = Get-Attr $s 'id'; if ($null -eq $sk) { $sk = '' } foreach ($acts in (Get-Kids $s 'actions')) { foreach ($a in (Get-Kids $acts 'action')) { $from = NewList; $from.Add($stepStatus[$sk]) $list.Add((New-Transition $a $from $stepStatus $PluginKeys)) } } } return , (Sort-ByNumber $list { param($t) $t.id }) } # ------------------------------------------------------------------ run try { $ps = $PSVersionTable.PSVersion Log "Migration Doctor snapshot $GeneratorVersion (PowerShell $($ps.Major).$($ps.Minor))" Log "Jira: $($script:Base)" Log "Auth: $AuthMode (from the environment)" Log "Output: $OutPath" if ($Filtered) { Log ('Projects: ' + ($ProjectKeys -join ' ')) } if ($SqlExportDir) { Log "SQL exports: $SqlExportDir" } Log 'Only HTTP GET requests are sent. Nothing in Jira is changed.' $TotalSteps = 11; if ($NoBoards) { $TotalSteps = 10 } $script:StepNo = 0 function Next-Step([string]$Text) { $script:StepNo++; Step "[$($script:StepNo)/$TotalSteps] $Text" } $SqlUsers = Find-SqlExport 'users' $SqlGroups = Find-SqlExport 'groups' $SqlFilters = Find-SqlExport 'filters' $SqlShares = Find-SqlExport 'share-permissions' $SqlDashboards = Find-SqlExport 'dashboards' $SqlGadgets = Find-SqlExport 'dashboard-gadgets' $SqlWorkflows = Find-SqlExport 'workflows' $SqlPlugins = Find-SqlExport 'plugins' $SqlShareRows = @() if ($SqlExportDir) { if (($SqlFilters -or $SqlDashboards) -and -not $SqlShares) { Fail 'SQL exports: filters.jsonl and dashboards.jsonl need share-permissions.jsonl from the same run' } if ($SqlDashboards -and -not $SqlGadgets) { Fail 'SQL exports: dashboards.jsonl needs dashboard-gadgets.jsonl from the same run' } $found = @() foreach ($pair in @(@('users', $SqlUsers), @('groups', $SqlGroups), @('filters', $SqlFilters), @('share-permissions', $SqlShares), @('dashboards', $SqlDashboards), @('dashboard-gadgets', $SqlGadgets), @('workflows', $SqlWorkflows), @('plugins', $SqlPlugins))) { if ($pair[1]) { $found += $pair[0] } } if ($found.Count -eq 0) { Fail "SQL exports: no known .jsonl files in $SqlExportDir (expected users, groups, filters, share-permissions, dashboards, dashboard-gadgets, workflows, plugins)" } Info ('SQL exports found: ' + ($found -join ' ')) if ($SqlShares) { $SqlShareRows = Read-JsonLines $SqlShares } } # 1. Server info ----------------------------------------------------------- Next-Step 'Server info' $info = Get-Json '/rest/api/2/serverInfo' if ((V $info 'deploymentType') -ceq 'Cloud') { Fail 'this is a Jira Cloud site. The snapshot is for Jira Data Center (and Server).' } $JiraVersion = Str (V $info 'version') if (-not $JiraVersion) { Fail "serverInfo did not return a version. Is $($script:Base) a Jira base URL?" } $title = V $info 'serverTitle'; if (-not $title) { $title = 'untitled' } Info "Jira $JiraVersion, $title" $major = ($JiraVersion -split '\.')[0] if (@('8', '9', '10', '11') -notcontains $major) { Warn "Jira $JiraVersion is outside the tested range (8.20 to 11.x); continuing" } # 2. Users ----------------------------------------------------------------- Next-Step 'Users' $rawUsers = NewList $first = Try-Json '/rest/api/2/user/list?maxResults=1000' if ($first.Status -ge 200 -and $first.Status -lt 300) { Info 'using /rest/api/2/user/list (cursor paging)' $page = $first.Json; $pages = 0 while ($true) { $pages++ $vals = Arr (V $page 'values') foreach ($u in $vals) { $rawUsers.Add($u) } $isLast = V $page 'isLast' $cursor = Str (V $page 'nextCursor') if ($isLast -eq $true -or $vals.Count -eq 0 -or -not $cursor) { break } if ($pages -ge 100000) { Fail "user/list did not finish after $pages pages" } $page = Get-Json "/rest/api/2/user/list?maxResults=1000&cursor=$cursor" } } elseif ($first.Status -eq 404) { Info 'using /rest/api/2/user/search (startAt paging)' $start = 0 while ($true) { $page = Arr (Get-Json "/rest/api/2/user/search?username=.&includeActive=true&includeInactive=true&startAt=$start&maxResults=1000") if ($page.Count -eq 0) { break } foreach ($u in $page) { $rawUsers.Add($u) } $start += $page.Count Info "$start users so far" } if (($major -eq '10' -or $major -eq '11') -and $start -eq 100) { Warn "exactly 100 users returned; Jira $JiraVersion caps user/search at 100 results. Use the SQL export users.jsonl for a complete list." } } else { Fail-Http '/rest/api/2/user/list?maxResults=1000' $first.R } $seen = NewMap $users = NewList foreach ($u in (Sort-ByString $rawUsers { param($x) $k = V $x 'key'; if ($null -eq $k) { $k = V $x 'name' }; $k })) { $id = V $u 'key'; if ($null -eq $id) { $id = V $u 'name' } $id = Str $id if ($seen.ContainsKey($id)) { continue } $seen[$id] = $true $active = V $u 'active'; if ($null -eq $active) { $active = $true } $name = V $u 'name'; if ($null -eq $name) { $name = '' } $o = [ordered]@{ name = $name; active = (ToBool $active) } if (V $u 'key') { $o.key = V $u 'key' } $dn = V $u 'displayName'; if ($null -eq $dn) { $dn = $name } $o.displayName = $dn $o.email = NonEmpty (V $u 'emailAddress') $users.Add($o) } $CovUsers = 'full' if ($SqlUsers) { $byName = NewMap $sqlList = NewList foreach ($s in (Read-JsonLines $SqlUsers)) { $n = V $s 'name'; if ($null -eq $n) { $n = '' }; $n = Str $n if ($n -eq '') { continue } $o = [ordered]@{ name = $n; active = (ToBool (V $s 'active')) } $k = V $s 'key'; if ($k) { $o.key = Str $k } $dn = V $s 'displayName'; if ($null -eq $dn) { $dn = $n } $o.displayName = $dn $o.email = NonEmpty (V $s 'email') $sqlList.Add($o) $byName[(AsciiLower $n)] = $o } $restNames = NewMap foreach ($u in $users) { $l = AsciiLower $u.name $restNames[$l] = $true if ($null -eq $u.email -and $byName.ContainsKey($l) -and $null -ne $byName[$l].email) { $u.email = $byName[$l].email } } foreach ($s in $sqlList) { if (-not $restNames.ContainsKey((AsciiLower $s.name))) { $users.Add($s) } } Info 'merged users.jsonl from SQL' } $inactive = @($users | Where-Object { -not $_.active }).Count Info "$($users.Count) users ($inactive inactive)" # 3. Groups ---------------------------------------------------------------- Next-Step 'Groups' $groupNames = New-Object 'System.Collections.Generic.SortedSet[string]' ([System.StringComparer]::Ordinal) if ($SqlGroups) { foreach ($g in (Read-JsonLines $SqlGroups)) { $n = V $g 'name'; if ($null -ne $n) { [void]$groupNames.Add((Str $n)) } } $CovGroups = 'full' Info 'from SQL groups.jsonl' } else { $gp = Get-Json '/rest/api/2/groups/picker?query=&maxResults=100000' foreach ($g in (Arr (V $gp 'groups'))) { [void]$groupNames.Add((Str (V $g 'name'))) } $total = V $gp 'total'; if ($null -eq $total) { $total = 0 } if ([int]$total -gt $groupNames.Count) { $CovGroups = 'rest-partial' Warn "the group picker returned $($groupNames.Count) of $total groups (capped by jira.ajax.autocomplete.limit). Export groups.jsonl with the SQL queries for the full list." } else { $CovGroups = 'full' } } $groups = NewList foreach ($n in $groupNames) { $groups.Add([ordered]@{ name = $n }) } Info "$($groups.Count) groups" # 4. Projects -------------------------------------------------------------- Next-Step 'Projects' $projectList = NewList foreach ($p in (Arr (Get-Json '/rest/api/2/project?includeArchived=true'))) { $o = [ordered]@{ id = (Str (V $p 'id')); key = (V $p 'key'); name = (V $p 'name') } if ($null -eq $o.name) { $o.name = '' } if ($null -ne (V $p 'projectTypeKey')) { $o.type = V $p 'projectTypeKey' } if (-not $Filtered -or $ProjectKeys.Contains([string]$o.key)) { $projectList.Add($o) } } if ($Filtered) { $have = @($projectList | ForEach-Object { $_.key }) $missing = @($ProjectKeys | Where-Object { $have -cnotcontains $_ }) if ($missing.Count -gt 0) { Fail ('unknown project key(s): ' + ($missing -join ', ')) } } $selectedKeys = Sort-ByString $projectList { param($x) $x.key } Info "$($projectList.Count) projects" # 5. Fields ---------------------------------------------------------------- Next-Step 'Fields' $fields = NewList foreach ($f in (Arr (Get-Json '/rest/api/2/field'))) { $custom = V $f 'custom'; if ($null -eq $custom) { $custom = $false } $type = $null if ($custom) { $type = V (V $f 'schema') 'custom' } $name = V $f 'name'; if ($null -eq $name) { $name = V $f 'id' } $fields.Add([ordered]@{ id = (V $f 'id'); name = $name; custom = [bool]$custom; type = $type }) } Info "$($fields.Count) fields ($(@($fields | Where-Object { $_.custom }).Count) custom)" # 6. Statuses -------------------------------------------------------------- Next-Step 'Statuses' $statuses = NewList $statusNames = NewMap foreach ($s in (Arr (Get-Json '/rest/api/2/status'))) { $n = V $s 'name'; if ($null -eq $n) { $n = '' } $o = [ordered]@{ id = (Str (V $s 'id')); name = $n } $statuses.Add($o) $statusNames[$o.id] = $n } Info "$($statuses.Count) statuses" # 7. Permission schemes ---------------------------------------------------- Next-Step 'Permission schemes' $ps = Get-Json '/rest/api/2/permissionscheme?expand=permissions,user,group,projectRole,field' $schemeProjects = NewMap foreach ($p in $selectedKeys) { $r = Try-Json "/rest/api/2/project/$($p.key)/permissionscheme" if ($r.Ok) { $sid = Str (V $r.Json 'id') if (-not $schemeProjects.ContainsKey($sid)) { $schemeProjects[$sid] = NewList } $schemeProjects[$sid].Add($p.key) } else { Warn "could not read the permission scheme of project $($p.key) (HTTP $($r.Status))" } } $permSchemes = NewList foreach ($s in (Arr (V $ps 'permissionSchemes'))) { $perms = NewList foreach ($g in (Arr (V $s 'permissions'))) { $h = V $g 'holder' $ht = V $h 'type'; if ($null -eq $ht) { $ht = 'unknown' } $perm = V $g 'permission'; if ($null -eq $perm) { $perm = '' } $perms.Add([ordered]@{ permission = $perm; holder = [ordered]@{ type = $ht; parameter = (Str (V $h 'parameter')) } }) } $id = Str (V $s 'id') $keys = NewList if ($schemeProjects.ContainsKey($id)) { $keys = Sort-ByString $schemeProjects[$id] { param($x) $x } } if ($Filtered -and $keys.Count -eq 0) { continue } $sn = V $s 'name'; if ($null -eq $sn) { $sn = '' } $permSchemes.Add([ordered]@{ id = $id; name = $sn; permissions = $perms; projectKeys = $keys }) } Info "$($permSchemes.Count) permission schemes" # 8. Workflows ------------------------------------------------------------- Next-Step 'Workflows' $wfNames = New-Object 'System.Collections.Generic.SortedSet[string]' ([System.StringComparer]::Ordinal) foreach ($w in (Arr (Get-Json '/rest/api/2/workflow'))) { [void]$wfNames.Add([string](V $w 'name')) } if ($Filtered) { $used = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::Ordinal) foreach ($p in $selectedKeys) { $r = Try-Json "/rest/api/2/project/$($p.key)/workflowscheme" if ($r.Ok) { $dw = V $r.Json 'defaultWorkflow'; if ($null -eq $dw) { $dw = 'jira' } [void]$used.Add($dw) $m = V $r.Json 'issueTypeMappings' if ($m -is [System.Collections.IDictionary]) { foreach ($k in $m.Keys) { [void]$used.Add([string]$m[$k]) } } } else { Warn "could not read the workflow scheme of project $($p.key) (HTTP $($r.Status)); its workflows may be missing" } } $wfNames.IntersectWith($used) } $workflows = NewList if ($SqlWorkflows) { $pluginKeys = @() if ($SqlPlugins) { $pkSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::Ordinal) foreach ($p in (Read-JsonLines $SqlPlugins)) { $k = V $p 'key'; if ($null -ne $k) { [void]$pkSet.Add((Str $k)) } } $pluginKeys = @($pkSet | Sort-Object -Property @{ Expression = { $_.Length }; Descending = $true }, @{ Expression = { $_ }; Descending = $false }) } else { Info 'no plugins.jsonl: plugin keys of app-provided rules are derived from built-in prefixes only' } $desc = NewMap foreach ($d in (Read-JsonLines $SqlWorkflows)) { $n = Str (V $d 'name'); if ($null -ne $n) { $desc[$n] = V $d 'descriptor' } } $missingWf = NewList foreach ($n in $wfNames) { $text = $null if ($desc.ContainsKey($n)) { $text = $desc[$n] } if ([string]::IsNullOrEmpty($text)) { $workflows.Add([ordered]@{ name = $n; transitions = (NewList); transitionsAvailable = $false }) $missingWf.Add($n) continue } try { $workflows.Add([ordered]@{ name = $n; transitions = (Get-Transitions $text $statusNames $pluginKeys) }) } catch { Warn "could not parse the workflow descriptor of ${n}: $($_.Exception.Message)" $workflows.Add([ordered]@{ name = $n; transitions = (NewList); transitionsAvailable = $false }) $missingWf.Add($n) } } $nonSystem = @($missingWf | Where-Object { $_ -cne 'jira' }) if ($nonSystem.Count -gt 0) { $CovWorkflows = 'rest-partial' Warn ('no usable descriptor in workflows.jsonl for: ' + ($missingWf -join ', ')) } else { $CovWorkflows = 'full' if ($missingWf.Count -gt 0) { Info 'the read-only system workflow "jira" is not stored in the database; its transitions are not exported' } } $tc = 0; $rc = 0 foreach ($w in $workflows) { foreach ($t in $w.transitions) { $tc++; $rc += $t.rules.Count } } Info "$tc transitions, $rc rules" } else { foreach ($n in $wfNames) { $workflows.Add([ordered]@{ name = $n; transitions = (NewList); transitionsAvailable = $false }) } $CovWorkflows = 'none' Warn 'Jira DC REST does not expose workflow transitions or rules. Run the SQL query workflows.sql and pass -SqlExportDir to include them.' } Info "$($workflows.Count) workflows" # 9. Boards ---------------------------------------------------------------- $boards = $null $boardFilterIds = New-Object 'System.Collections.Generic.SortedSet[string]' ([System.StringComparer]::Ordinal) $CovBoards = 'full' if (-not $NoBoards) { Next-Step 'Boards (Jira Software)' $rawBoards = NewList $agile = $true $scopes = @('__ALL__') if ($Filtered) { $scopes = @($selectedKeys | ForEach-Object { $_.key }) } foreach ($scope in $scopes) { if (-not $agile) { break } $start = 0 while ($true) { if ($scope -eq '__ALL__') { $q = "startAt=$start&maxResults=50" } else { $q = "projectKeyOrId=$scope&startAt=$start&maxResults=50" } $r = Try-Json "/rest/agile/1.0/board?$q" if (-not $r.Ok) { if ($r.Status -eq 404 -and $start -eq 0 -and ($scope -eq '__ALL__' -or $rawBoards.Count -eq 0)) { Info 'the agile REST API is not available (Jira Software not installed?); no boards' $agile = $false break } Fail-Http "/rest/agile/1.0/board?$q" $r.R } $vals = Arr (V $r.Json 'values') foreach ($b in $vals) { $rawBoards.Add($b) } $start += $vals.Count if ((V $r.Json 'isLast') -eq $true -or $vals.Count -eq 0) { break } } } $boards = NewList $seenB = NewMap $denied = 0 foreach ($b in (Sort-ByString $rawBoards { param($x) Str (V $x 'id') })) { $id = Str (V $b 'id') if ($seenB.ContainsKey($id)) { continue } $seenB[$id] = $true $o = [ordered]@{ id = $id; name = (V $b 'name') } if ($null -eq $o.name) { $o.name = '' } if ($null -ne (V $b 'type')) { $o.type = V $b 'type' } $r = Try-Json "/rest/agile/1.0/board/$id/configuration" $fid = $null if ($r.Ok) { $fid = Str (V (V $r.Json 'filter') 'id') } elseif (@(400, 403, 404) -contains $r.Status) { $denied++ } else { Fail-Http "/rest/agile/1.0/board/$id/configuration" $r.R } $o.filterId = $fid if ($fid) { [void]$boardFilterIds.Add($fid) } $boards.Add($o) } if ($denied -gt 0) { $CovBoards = 'rest-partial' Warn "$denied board configuration(s) not readable by this account (board filter not shared with it); their filterId is null" } Info "$($boards.Count) boards" } # 10. Filters -------------------------------------------------------------- Next-Step 'Filters' $filters = NewList if ($SqlFilters) { $shareMap = Group-SqlShares $SqlShareRows 'SearchRequest' foreach ($f in (Read-JsonLines $SqlFilters)) { $id = Str (V $f 'id') $name = V $f 'name'; if ($null -eq $name) { $name = '' } $jql = V $f 'jql'; if ($null -eq $jql) { $jql = '' } $owner = V $f 'owner'; if ($null -eq $owner) { $owner = '' } $sh = NewList; if ($shareMap.ContainsKey($id)) { $sh = $shareMap[$id] } $filters.Add([ordered]@{ id = $id; name = $name; description = (NonEmpty (V $f 'description')); jql = $jql; owner = (Str $owner); sharePermissions = $sh }) } $CovFilters = 'full' Info 'from SQL filters.jsonl (all filters, including private ones)' } else { $rawFilters = NewList $have = NewMap $fav = Arr (Get-Json '/rest/api/2/filter/favourite') foreach ($f in $fav) { $rawFilters.Add($f); $have[(Str (V $f 'id'))] = $true } Info "$($fav.Count) favourite filters of this account" if ($FilterScanGap -gt 0) { $maxKnown = 10000 foreach ($k in @($have.Keys) + @($boardFilterIds)) { $n = [long](NumId $k); if ($n -gt $maxKnown) { $maxKnown = $n } } Info "probing filter IDs from 10000 (highest known $maxKnown, stop after $FilterScanGap consecutive misses)" $id = 10000; $misses = 0; $hidden = 0; $probed = 0 while ($id -le $maxKnown -or $misses -lt $FilterScanGap) { if ($have.ContainsKey([string]$id)) { $misses = 0; $id++; continue } $probed++ $r = Try-Json "/rest/api/2/filter/$id" if ($r.Ok) { $rawFilters.Add($r.Json); $have[[string]$id] = $true; $misses = 0 } elseif (@(400, 403, 404) -contains $r.Status) { $misses++; $hidden++ } else { Fail-Http "/rest/api/2/filter/$id" $r.R } if ($probed % 200 -eq 0) { Info "probed up to filter $id" } $id++ } Info "probed $probed IDs; $hidden missing or not visible to this account" } else { foreach ($fid in $boardFilterIds) { if ($have.ContainsKey($fid)) { continue } $r = Try-Json "/rest/api/2/filter/$fid" if ($r.Ok) { $rawFilters.Add($r.Json); $have[$fid] = $true } elseif (@(400, 403, 404) -contains $r.Status) { Info "board filter $fid is not visible to this account" } else { Fail-Http "/rest/api/2/filter/$fid" $r.R } } } $seenF = NewMap foreach ($f in $rawFilters) { $m = Map-RestFilter $f if ($seenF.ContainsKey($m.id)) { continue } $seenF[$m.id] = $true $filters.Add($m) } $CovFilters = '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 -SqlExportDir for a complete list.' } $shareCount = 0; foreach ($f in $filters) { $shareCount += $f.sharePermissions.Count } Info "$($filters.Count) filters, $shareCount share permissions" # 11. Dashboards ----------------------------------------------------------- Next-Step 'Dashboards' $dashboards = NewList if ($SqlDashboards) { $shareMap = Group-SqlShares $SqlShareRows 'PortalPage' $gadgetMap = NewMap $gRows = Read-JsonLines $SqlGadgets $gSorted = Sort-ByString $gRows { param($x) '{0:000000000000000000}|{1:000000000000000000}|{2:000000000000000000}' -f [long](NumId (V $x 'column')), [long](NumId (V $x 'row')), [long](NumId (V $x 'gadgetId')) } foreach ($g in $gSorted) { $did = Str (V $g 'dashboardId') if (-not $gadgetMap.ContainsKey($did)) { $gadgetMap[$did] = NewList } $o = [ordered]@{} $mk = NonEmpty (V $g 'moduleKey') $gx = NonEmpty (V $g 'gadgetXml') if ($null -ne $mk) { $o.moduleKey = $mk } elseif ($null -ne $gx) { if ($gx -match '/g/([^/]+)/') { $o.moduleKey = $Matches[1] } else { $o.moduleKey = $gx } } $pref = Str (V $g 'filterPref'); if ($null -eq $pref) { $pref = '' } $fid = $null if ($pref -cmatch '^filter-[0-9]+$') { $fid = $pref.Substring(7) } elseif ($pref -cmatch '^[0-9]+$') { $fid = $pref } $o.filterId = $fid $gadgetMap[$did].Add($o) } foreach ($d in (Read-JsonLines $SqlDashboards)) { $id = Str (V $d 'id') $name = V $d 'name'; if ($null -eq $name) { $name = '' } $sh = NewList; if ($shareMap.ContainsKey($id)) { $sh = $shareMap[$id] } $gd = NewList; if ($gadgetMap.ContainsKey($id)) { $gd = $gadgetMap[$id] } $dashboards.Add([ordered]@{ id = $id; name = $name; owner = (NonEmpty (V $d 'owner')); sharePermissions = $sh; gadgets = $gd }) } $CovDashboards = 'full' Info 'from SQL dashboards.jsonl with owners, shares and gadgets' } else { $start = 0; $max = 50 $seenD = NewMap while ($true) { $page = Get-Json "/rest/api/2/dashboard?startAt=$start&maxResults=$max" $list = Arr (V $page 'dashboards') foreach ($d in $list) { $id = Str (V $d 'id') if ($seenD.ContainsKey($id)) { continue } $seenD[$id] = $true $name = V $d 'name'; if ($null -eq $name) { $name = '' } $dashboards.Add([ordered]@{ id = $id; name = $name }) } $total = V $page 'total'; if ($null -eq $total) { $total = 0 } $pm = V $page 'maxResults'; if ($null -eq $pm -or [int]$pm -le 0) { $pm = $list.Count } $start += [int]$pm; $max = [int]$pm if ($list.Count -eq 0 -or $start -ge [int]$total) { break } } $CovDashboards = '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 -SqlExportDir for full data.' } Info "$($dashboards.Count) dashboards" # ---------------------------------------------------------------- assemble Step "Writing $OutPath" $source = [ordered]@{ product = 'jira'; deployment = 'datacenter'; version = $JiraVersion } $bu = V $info 'baseUrl'; if (-not $bu) { $bu = $script:Base } $source.baseUrl = $bu if (V $info 'serverTitle') { $source.serverTitle = V $info 'serverTitle' } $coverage = [ordered]@{ users = $CovUsers; groups = $CovGroups; projects = 'full'; fields = 'full'; statuses = 'full' filters = $CovFilters; dashboards = $CovDashboards; workflows = $CovWorkflows; permissionSchemes = 'full' } if ($null -ne $boards) { $coverage.boards = $CovBoards } $snapshot = [ordered]@{ format = 'migration-doctor-snapshot' schemaVersion = 1 createdAt = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", [System.Globalization.CultureInfo]::InvariantCulture) generator = [ordered]@{ name = $GeneratorName; version = $GeneratorVersion; runtime = 'powershell' } source = $source coverage = $coverage users = (Sort-ByString $users { param($x) $x.name }) groups = $groups projects = (Sort-ByString $projectList { param($x) $x.key }) fields = (Sort-ByString $fields { param($x) $x.id }) statuses = (Sort-ByNumber $statuses { param($x) $x.id }) filters = (Sort-ByNumber $filters { param($x) $x.id }) dashboards = (Sort-ByNumber $dashboards { param($x) $x.id }) workflows = (Sort-ByString $workflows { param($x) $x.name }) permissionSchemes = (Sort-ByNumber $permSchemes { param($x) $x.id }) } if ($null -ne $boards) { $snapshot.boards = (Sort-ByNumber $boards { param($x) $x.id }) } $outDir = [System.IO.Path]::GetDirectoryName($OutPath) if (-not [System.IO.Directory]::Exists($outDir)) { Fail "output directory does not exist: $outDir" } $json = ConvertTo-JsonText $snapshot [System.IO.File]::WriteAllText($OutPath, $json, (New-Object System.Text.UTF8Encoding($false))) # ---------------------------------------------------------------- summary $elapsed = [int]((Get-Date) - $script:StartTime).TotalSeconds $size = (New-Object System.IO.FileInfo($OutPath)).Length Log '' Log "Snapshot written: $OutPath ($size bytes, $($script:RequestCount) GET requests, ${elapsed}s)" Log '' $tc = 0; $rc = 0 foreach ($w in $workflows) { foreach ($t in $w.transitions) { $tc++; $rc += $t.rules.Count } } $rows = @( @('users', $users.Count), @('groups', $groups.Count), @('projects', $projectList.Count), @('fields', $fields.Count), @('statuses', $statuses.Count), @('filters', $filters.Count), @('dashboards', $dashboards.Count), @('workflows', "$($workflows.Count) ($tc transitions, $rc rules)"), @('permission schemes', $permSchemes.Count) ) if ($null -ne $boards) { $rows += , @('boards', $boards.Count) } foreach ($r in $rows) { Log (' ' + ([string]$r[0]).PadRight(20) + [string]$r[1]) } Log '' Log (' Coverage: ' + (($coverage.Keys | ForEach-Object { "$_=$($coverage[$_])" }) -join ', ')) if ($script:Warnings.Count -gt 0) { Log '' Log "$($script:Warnings.Count) warning(s):" foreach ($w in $script:Warnings) { Log " - $w" } } 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 } catch { Log '' if ($script:FailCode -ne 0) { Log ('ERROR: ' + $script:FailText) exit $script:FailCode } Log ('ERROR: unexpected failure: ' + $_.Exception.Message) Log ($_.ScriptStackTrace) exit 1 }