# Latchpoint - one-time backfill of existing Perforce history (Windows Helix Core servers) # # The change-commit trigger only ever sees NEW changelists. Without this, a studio # installs Latchpoint and sees an empty development panel until someone happens to # submit. This walks the history you already have and publishes it, so the panel is # populated the moment you finish setup. # # Run once, by hand, from the Perforce server or any machine with p4 access: # # $env:LATCHPOINT_URL = '' # $env:LATCHPOINT_SECRET = '' # .\latchpoint-backfill.ps1 -Max 2000 -DryRun # see what it would do # .\latchpoint-backfill.ps1 -Max 2000 # do it # # Unlike the trigger, this is NOT in the submit path. It is allowed to fail loudly, # and it does - a silent partial import would be worse than an obvious failure. # # Safe to re-run. Jira keys development information by commit id, and we derive that # id from the changelist number, so a second run updates the same entries rather than # duplicating them. [CmdletBinding()] param( # How many of the most recent changelists to consider. [int]$Max = 1000, # Only changelists NEWER than this number. Use to resume, or to import a slice. [int]$Since = 0, # Depot path to walk. Narrow it to import one stream or project. [string]$Path = '//...', # Changelists per request. The endpoint accepts up to 200. [int]$BatchSize = 200, # Seconds to wait between batches. NOT politeness - correctness. # # Jira's development-information API is asynchronous. Firing several updates at the # SAME repository within a second or so loses writes: every request returns 202 and # reports success, and the commits from the earlier batches are simply not there # afterwards. Measured directly against the stored data, not inferred from the UI: # 12 changelists in one request landed completely, the identical 12 split across # three back-to-back requests landed nothing at all. # # Do not set this to 0 on a real import. [int]$Delay = 10, # Print what would be sent and send nothing. [switch]$DryRun ) $ErrorActionPreference = 'Continue' $PSNativeCommandUseErrorActionPreference = $false $ProgressPreference = 'SilentlyContinue' $p4 = if ($env:P4_EXE) { $env:P4_EXE } else { 'p4' } $depot = if ($env:LATCHPOINT_DEPOT) { $env:LATCHPOINT_DEPOT } else { 'depot' } $swarm = $env:LATCHPOINT_SWARM_URL $url = $env:LATCHPOINT_URL $secret = $env:LATCHPOINT_SECRET if (-not $DryRun) { if (-not $url) { Write-Error 'LATCHPOINT_URL is not set.'; exit 1 } if (-not $secret) { Write-Error 'LATCHPOINT_SECRET is not set.'; exit 1 } } $ISSUE_KEY = '\b[A-Z][A-Z0-9]+-\d+\b' # Identical parsing to the trigger, and it must stay that way. p4 -ztag emits a # multi-line value as "... desc " followed by unprefixed lines; reading # only the prefixed line truncates the description and, when the Jira key sits in # the body, drops the changelist entirely. function Read-Tagged { param([string[]]$Raw) $fields = @{} $fileCount = 0 $pending = $null $blanks = 0 foreach ($line in $Raw) { $s = "$line" if ($s -match '^\.\.\.\s+(\S+)\s*(.*)$') { $k = $matches[1]; $v = $matches[2] $pending = $null; $blanks = 0 if ($k -match '^depotFile') { $fileCount++ } elseif (-not $fields.ContainsKey($k)) { $fields[$k] = $v; $pending = $k } } elseif ($null -ne $pending) { if ($s -eq '') { $blanks++ } else { $fields[$pending] = $fields[$pending] + ("`n" * ($blanks + 1)) + $s; $blanks = 0 } } } $fields['__fileCount'] = $fileCount return $fields } function To-Iso { param($Epoch) $e = 0 [void][int]::TryParse("$Epoch", [ref]$e) if ($e -gt 0) { [DateTimeOffset]::FromUnixTimeSeconds($e).UtcDateTime.ToString('yyyy-MM-ddTHH:mm:ssZ') } else { (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } } # --- 1. list the changelists ------------------------------------------------- Write-Host "Reading up to $Max changelists from $Path ..." $listRaw = & $p4 -ztag changes -m $Max $Path 2>$null if ($LASTEXITCODE -ne 0) { Write-Error "p4 changes failed. Check P4PORT / P4USER and that you are logged in."; exit 1 } $numbers = @() foreach ($line in $listRaw) { if ("$line" -match '^\.\.\.\s+change\s+(\d+)\s*$') { $n = [int]$matches[1] if ($n -gt $Since) { $numbers += $n } } } $numbers = $numbers | Sort-Object # oldest first, so Jira's ordering matches history Write-Host " $($numbers.Count) changelist(s) in range." if ($numbers.Count -eq 0) { Write-Host 'Nothing to do.'; exit 0 } # --- 2. describe each, keep the ones with a Jira key ------------------------- $items = @() $skipped = 0 $i = 0 foreach ($n in $numbers) { $i++ if ($i % 100 -eq 0) { Write-Host " ...described $i/$($numbers.Count)" } $raw = & $p4 -ztag describe -s $n 2>$null if ($LASTEXITCODE -ne 0) { Write-Warning " changelist $n : p4 describe failed, skipping"; $skipped++; continue } $f = Read-Tagged -Raw $raw $desc = $f['desc'] if (-not $desc) { $skipped++; continue } if ($desc -notmatch $ISSUE_KEY) { $skipped++; continue } $items += @{ change = if ($f['change']) { $f['change'] } else { "$n" } description = $desc user = $f['user'] time = (To-Iso $f['time']) fileCount = $f['__fileCount'] } } Write-Host "" Write-Host " $($items.Count) changelist(s) carry a Jira issue key" Write-Host " $skipped skipped (no key, or unreadable)" if ($items.Count -eq 0) { Write-Host 'Nothing to publish.'; exit 0 } # --- 3. publish in batches --------------------------------------------------- try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { } $published = 0 $failed = 0 $batches = [math]::Ceiling($items.Count / $BatchSize) for ($b = 0; $b -lt $batches; $b++) { $chunk = $items[($b * $BatchSize)..([math]::Min(($b + 1) * $BatchSize, $items.Count) - 1)] $body = @{ depot = $depot; changelists = @($chunk) } if ($swarm) { $body['serverUrl'] = $swarm.TrimEnd('/') } $json = $body | ConvertTo-Json -Depth 6 -Compress if ($DryRun) { Write-Host "[dry run] batch $($b+1)/$batches : $($chunk.Count) changelist(s), $($json.Length) bytes" if ($b -eq 0) { Write-Host "[dry run] first changelist in batch 1:" $chunk[0] | ConvertTo-Json -Depth 6 } continue } try { $resp = Invoke-WebRequest -Uri $url -Method Post ` -Body ([Text.Encoding]::UTF8.GetBytes($json)) ` -ContentType 'application/json; charset=utf-8' ` -Headers @{ 'X-Latchpoint-Secret' = $secret } ` -TimeoutSec 60 -UseBasicParsing } catch { # Build the reason separately: nesting a double-quoted string inside $() inside # another double-quoted string is a parse error on Windows PowerShell 5.1, which # is what `powershell -File` runs. $code = try { [int]$_.Exception.Response.StatusCode } catch { 0 } $why = if ($code) { "HTTP $code" } else { $_.Exception.Message } Write-Warning " batch $($b+1)/$batches FAILED ($why)" $failed += $chunk.Count continue } $r = try { ConvertFrom-Json $resp.Content } catch { $null } $n = if ($r) { $r.published } else { $chunk.Count } $published += $n $unknown = if ($r -and $r.unknownIssueKeys) { $r.unknownIssueKeys.Count } else { 0 } $note = if ($unknown -gt 0) { " ($unknown issue key(s) not found in Jira)" } else { '' } Write-Host " batch $($b+1)/$batches : published $n$note" # See the note on -Delay. A 202 here does NOT mean the data is stored, # and the next batch must not overtake this one. if ($b -lt ($batches - 1) -and $Delay -gt 0) { Start-Sleep -Seconds $Delay } } Write-Host "" if ($DryRun) { Write-Host "Dry run complete. Nothing was sent. Re-run without -DryRun to publish." } elseif ($failed -gt 0) { Write-Host "Backfill finished with errors: $published published, $failed not sent." Write-Host "Safe to re-run - republishing updates existing entries rather than duplicating them." exit 1 } else { Write-Host "Backfill complete: $published changelist(s) published." Write-Host "They may take a few minutes to appear in Jira's development panel." } exit 0