Eigenverft.Manifested.Drydock.Deployment.ps1
function Convert-BranchToDeploymentInfo { <# .SYNOPSIS Validate a Git branch, resolve its deployment channel, and create prefix/suffix tokens. Segments keep ORIGINAL case; all comparisons/mappings are case-insensitive. Branch section exposes path-sanitized segments. .DESCRIPTION One entrypoint that: 1) Validates & splits the branch name (subset of Git ref rules), optional path-sanitization helper (non-destructive). - Keeps input segment casing as-is. - Always emits Branch.PathSegmentsSanitized (safe for filesystem paths). 2) Resolves a deployment channel from the FIRST segment (case-insensitive); defaults cover GitFlow + conventional prefixes. 3) Builds label/prefix/suffix tokens (Short/Long styles) for artifact names or SemVer prereleases. Return is sectioned for clarity: - .Branch -> Segments (original case), PathSegmentsSanitized (safe), FirstSegmentLower - .Channel -> Value, Source, SegmentsWithChannelFirst (first replaced by channel) - .Affix -> Label, Prefix, Suffix, Separator, LabelCase, HasLabel .PARAMETER BranchName Full branch name (e.g., "feature/foo-bar"). Backslashes are normalized to "/". .PARAMETER MaxSegments Maximum allowed "/"-separated segments (default 3). .PARAMETER ForbiddenSegments Case-insensitive list of forbidden segment values. Default: @('latest'). .PARAMETER RequiredFirstSegments Case-insensitive allow-list for the first segment. Default covers GitFlow + common prefixes: main, master, develop, feature, release, hotfix, bugfix, support, fix, chore, docs, build, ci, perf, refactor, style, test. # Channel resolution: .PARAMETER ChannelMap Hashtable mapping first-segment -> channel (case-insensitive). Your entries override defaults. .PARAMETER DefaultChannel Channel to use if the first segment is unmapped (unless -ErrorOnMissingChannel). Default: 'no-deploy'. .PARAMETER ErrorOnMissingChannel Throw if the first segment is unmapped and no DefaultChannel should be used. .PARAMETER KnownFirstSegments Baseline set for completeness validation of ChannelMap. Defaults to the same list as RequiredFirstSegments. .PARAMETER ValidateChannelMap Throw if ChannelMap (plus defaults) does not cover all KnownFirstSegments. # Label/prefix/suffix: .PARAMETER LabelMap Hashtable mapping channel -> label (case-insensitive). Your entries override built-in label defaults. .PARAMETER LabelStyle Built-in labels when LabelMap isn’t provided: Short | Long Short: production='', staging='rc', quality='qa', development='dev' (default) Long : production='', staging='staging', quality='quality', development='development' .PARAMETER DefaultLabel Label to use if a channel has no label mapping (unless -ErrorOnMissingLabel). Default: $null. .PARAMETER LabelCase Case for the label: Lower (default), Upper, Preserve. .PARAMETER Separator Separator for prefix/suffix around the label. Default: "-". .PARAMETER IncludeSeparator Include the separator in Prefix/Suffix. Default: $true. .PARAMETER NoSuffixChannels Channels that must never receive a suffix. Default: @('production'). .PARAMETER NoPrefixChannels Channels that must never receive a prefix. Default: @(). .PARAMETER ErrorOnMissingLabel Throw if label is missing for the resolved channel and DefaultLabel is $null. .PARAMETER KnownChannels Baseline set for completeness validation of LabelMap. Default: @('production','staging','quality','development'). .PARAMETER ValidateLabelMap Throw if LabelMap (plus defaults) does not cover all KnownChannels. .OUTPUTS System.Object (PSCustomObject) # Sections: # .Branch : @{ Segments; PathSegmentsSanitized; FirstSegmentLower } # .Channel : @{ Value; Source; SegmentsWithChannelFirst } # .Affix : @{ Label; Prefix; Suffix; Separator; LabelCase; HasLabel } #> [CmdletBinding()] param( # --- Branch validation --- [Parameter(Mandatory)] [string]$BranchName, [int]$MaxSegments = 3, [string[]]$ForbiddenSegments = @('latest'), [string[]]$RequiredFirstSegments = @( 'main','master','develop','feature','release','hotfix','bugfix','support', 'fix','chore','docs','build','ci','perf','refactor','style','test' ), # --- Channel resolution --- [hashtable]$ChannelMap, [string]$DefaultChannel = 'no-deploy', [switch]$ErrorOnMissingChannel, [string[]]$KnownFirstSegments = @( 'main','master','develop','feature','release','hotfix','bugfix','support', 'fix','chore','docs','build','ci','perf','refactor','style','test' ), [switch]$ValidateChannelMap, # --- Label/prefix/suffix --- [hashtable]$LabelMap, [ValidateSet('Short','Long')] [string]$LabelStyle = 'Short', [AllowNull()] [string]$DefaultLabel = $null, [ValidateSet('Lower','Upper','Preserve')] [string]$LabelCase = 'Lower', [string]$Separator = '-', [bool]$IncludeSeparator = $true, [string[]]$NoSuffixChannels = @('production'), [string[]]$NoPrefixChannels = @(), [switch]$ErrorOnMissingLabel, [string[]]$KnownChannels = @('production','staging','quality','development'), [switch]$ValidateLabelMap ) # ========================= # 1) Branch validation (preserve original case) # ========================= if ([string]::IsNullOrWhiteSpace($BranchName)) { throw "BranchName is empty." } $bn = $BranchName -replace '\\','/' # normalize slashes only if ($bn.StartsWith('/')) { throw "Branch name cannot start with '/'." } if ($bn.EndsWith('/')) { throw "Branch name cannot end with '/'." } if ($bn -match '//') { throw "Branch name cannot contain '//'." } if ($bn -match '\.\.') { throw "Branch name cannot contain '..'." } if ($bn -match '@\{') { throw "Branch name cannot contain '@{'." } if ($bn -match '\.lock$'){ throw "Branch name cannot end with '.lock'." } if ($bn -match '[~^:?*\[]') { throw "Branch name contains forbidden characters (~ ^ : ? * [ )." } if ($bn -match '\\') { throw "Branch name cannot contain backslash '\'. Use '/' as separator." } if ($bn -match '[\x00-\x1F]') { throw "Branch name contains control characters." } $segments = @($bn -split '/' | Where-Object { $_ -ne '' }) foreach ($seg in $segments) { if ($seg -eq '.' -or $seg -eq '..') { throw "Segment '$seg' is invalid ('.' or '..' not allowed)." } if ($seg.StartsWith('.')) { throw "Segment '$seg' cannot start with '.'." } if ($seg.EndsWith('.')) { throw "Segment '$seg' cannot end with '.'." } } if ($segments.Count -gt $MaxSegments) { throw "Number of segments ($($segments.Count)) exceeds the maximum allowed ($MaxSegments)." } # Forbidden segments (case-insensitive) $forbid = $ForbiddenSegments | ForEach-Object { $_.ToLowerInvariant() } foreach ($seg in $segments) { if ($forbid -contains $seg.ToLowerInvariant()) { throw "Segment '$seg' is forbidden." } } # Required first segment (case-insensitive) if ($segments.Count -ge 1) { $allowedFirst = $RequiredFirstSegments | ForEach-Object { $_.ToLowerInvariant() } $firstRawLower = ([string]$segments[0]).ToLowerInvariant() if ($allowedFirst.Count -gt 0 -and $allowedFirst -notcontains $firstRawLower) { $list = ($RequiredFirstSegments -join "', '") throw "First segment '$($segments[0])' is not allowed. Allowed first segments: '$list'." } } # Always compute a path-safe version of segments (no case changes) $pathSegments = @($segments) $invalid = [System.IO.Path]::GetInvalidFileNameChars() for ($i = 0; $i -lt $pathSegments.Count; $i++) { foreach ($ch in $invalid) { $pathSegments[$i] = $pathSegments[$i] -replace ([regex]::Escape($ch)), '_' } $pathSegments[$i] = $pathSegments[$i] -replace ' ', '_' } $firstSegmentLower = if ($segments.Count -ge 1) { ([string]$segments[0]).ToLowerInvariant() } else { "" } # ========================= # 2) Channel resolution (case-insensitive) # ========================= $channelDefaults = @{ 'main' = 'production' 'master' = 'production' 'hotfix' = 'production' 'release' = 'staging' 'develop' = 'quality' 'bugfix' = 'quality' 'fix' = 'quality' 'support' = 'quality' 'feature' = 'development' 'chore' = 'development' 'docs' = 'development' 'build' = 'development' 'ci' = 'development' 'perf' = 'development' 'refactor'= 'development' 'style' = 'development' 'test' = 'development' } $channelMapEff = @{} foreach ($k in $channelDefaults.Keys) { $channelMapEff[$k.ToLowerInvariant()] = $channelDefaults[$k] } if ($ChannelMap) { foreach ($k in $ChannelMap.Keys) { $channelMapEff[$k.ToLowerInvariant()] = $ChannelMap[$k] } } if ($ValidateChannelMap) { $missing = @() foreach ($k in $KnownFirstSegments) { if (-not $channelMapEff.ContainsKey($k.ToLowerInvariant())) { $missing += $k } } if ($missing.Count -gt 0) { throw ("ChannelMap is incomplete. Missing mappings for: {0}" -f ($missing -join ', ')) } } $channel = $null $channelSource = 'Default' if ($channelMapEff.ContainsKey($firstSegmentLower)) { $channel = [string]$channelMapEff[$firstSegmentLower] if ($ChannelMap -and $ChannelMap.ContainsKey($firstSegmentLower)) { $channelSource = 'Override' } } else { if ($ErrorOnMissingChannel) { throw ("No channel mapping for first segment '{0}'." -f $firstSegmentLower) } $channel = $DefaultChannel $channelSource = 'Fallback' } $segmentsWithChan = @($segments) if ($segmentsWithChan.Count -ge 1) { $segmentsWithChan[0] = $channel } # ========================= # 3) Label/Affix generation (case-insensitive mapping; label case optional) # ========================= $labelDefaultsShort = @{ 'production' = '' 'staging' = 'rc' 'quality' = 'qa' 'development' = 'dev' } $labelDefaultsLong = @{ 'production' = '' 'staging' = 'staging' 'quality' = 'quality' 'development' = 'development' } $labelDefaults = if ($LabelStyle -eq 'Long') { $labelDefaultsLong } else { $labelDefaultsShort } $labelMapEff = @{} foreach ($k in $labelDefaults.Keys) { $labelMapEff[$k.ToLowerInvariant()] = $labelDefaults[$k] } if ($LabelMap) { foreach ($k in $LabelMap.Keys) { $labelMapEff[$k.ToLowerInvariant()] = $LabelMap[$k] } } if ($ValidateLabelMap) { $missingL = @() foreach ($k in $KnownChannels) { if (-not $labelMapEff.ContainsKey($k.ToLowerInvariant())) { $missingL += $k } } if ($missingL.Count -gt 0) { throw ("LabelMap is incomplete. Missing labels for: {0}" -f ($missingL -join ', ')) } } $chLower = $channel.ToLowerInvariant() $label = $null if ($labelMapEff.ContainsKey($chLower)) { $label = [string]$labelMapEff[$chLower] } elseif ($PSBoundParameters.ContainsKey('DefaultLabel')) { $label = $DefaultLabel } if ($null -eq $label) { if ($ErrorOnMissingLabel) { throw "No label mapping for channel '$channel' and no DefaultLabel provided." } $label = '' } switch ($LabelCase) { 'Lower' { $label = $label.ToLowerInvariant() } 'Upper' { $label = $label.ToUpperInvariant() } 'Preserve' { } } $hasLabel = -not [string]::IsNullOrEmpty($label) $noSuffixSet = $NoSuffixChannels | ForEach-Object { $_.ToLowerInvariant() } $noPrefixSet = $NoPrefixChannels | ForEach-Object { $_.ToLowerInvariant() } $prefix = '' $suffix = '' if ($hasLabel -and ($noPrefixSet -notcontains $chLower)) { $prefix = if ($IncludeSeparator -and $Separator) { "$label$Separator" } else { "$label" } } if ($hasLabel -and ($noSuffixSet -notcontains $chLower)) { $suffix = if ($IncludeSeparator -and $Separator) { "$Separator$label" } else { "$label" } } # ========================= # 4) Return SECTIONED object # ========================= $branchSection = [pscustomobject]@{ Segments = @($segments) # original case, non-destructive PathSegmentsSanitized = @($pathSegments) # safe for filesystem (spaces & invalid chars -> '_') FirstSegmentLower = $firstSegmentLower # for case-insensitive mapping } $channelSection = [pscustomobject]@{ Value = $channel Source = $channelSource # 'Default' | 'Override' | 'Fallback' SegmentsWithChannelFirst = @($segmentsWithChan) } $affixSection = [pscustomobject]@{ Label = $label Prefix = $prefix Suffix = $suffix Separator = $Separator LabelCase = $LabelCase HasLabel = [bool]$hasLabel } [pscustomobject]@{ Branch = $branchSection Channel = $channelSection Affix = $affixSection } } |