Private/Compile/Resolve-Iterations.ps1
|
# Compile stage — derives the Year → Season → Sprint iteration calendar from cadence config. function Get-IterationCalendar { <# .SYNOPSIS Computes the Year/Season/Sprint iteration calendar from a cadence config object. Generates from the current execution year through yearHorizon years ahead. Execution year starts on the first Monday of February. Standard sprint: N working weeks (Mon-Fri), defaulting to 3 (19 calendar days). Some final season sprints may be shortened for boundary alignment. Returns an array of year objects, each containing seasons containing sprints. #> [CmdletBinding()] param( [Parameter(Mandatory)][object]$Cadence, [Parameter(Mandatory)][string]$ProgramRoot ) $cfg = $Cadence.iterations $horizon = if ($cfg.yearHorizon) { [int]$cfg.yearHorizon } else { 2 } $defWeeks = if ($cfg.defaultSprintWeeks) { [int]$cfg.defaultSprintWeeks } else { 3 } $seasonDefs = @($cfg.seasons | Where-Object { $_ }) # Start generation from the execution year containing today. # S3 runs into January of the next calendar year, so if today is in January # the active execution year started the previous February. $today = [DateTime]::Today $execStart = if ($today.Month -le 1) { $today.Year - 1 } else { $today.Year } $calendar = [System.Collections.Generic.List[object]]::new() for ($y = $execStart; $y -le ($execStart + $horizon); $y++) { # First Monday of February in year $y $feb1 = [DateTime]::new($y, 2, 1) $daysToMon = (8 - [int]$feb1.DayOfWeek) % 7 # 0 if already Monday $seasonCursor = $feb1.AddDays($daysToMon) $yearNode = [ordered]@{ name = "$y" path = "$ProgramRoot\$y" seasons = [System.Collections.Generic.List[object]]::new() } foreach ($sDef in $seasonDefs) { $sId = $sDef.id $numSprints = if ($sDef.sprints) { [int]$sDef.sprints } else { 6 } $finalWks = if ($null -ne $sDef.finalSprintWeeks) { [int]$sDef.finalSprintWeeks } else { $defWeeks } $seasonNode = [ordered]@{ name = $sId path = "$ProgramRoot\$y\$sId" sprints = [System.Collections.Generic.List[object]]::new() } $sprintCursor = $seasonCursor for ($w = 1; $w -le $numSprints; $w++) { $weeks = if ($w -eq $numSprints) { $finalWks } else { $defWeeks } # Mon-Fri over $weeks working weeks = ($weeks * 7 - 2) calendar days inclusive $calDays = $weeks * 7 - 2 $sprintEnd = $sprintCursor.AddDays($calDays - 1) # inclusive end date $sprintId = "$sId-W$w" $seasonNode.sprints.Add([ordered]@{ name = $sprintId path = "$ProgramRoot\$y\$sId\$sprintId" startDate = $sprintCursor.ToString('yyyy-MM-ddT00:00:00Z') endDate = $sprintEnd.ToString('yyyy-MM-ddT00:00:00Z') }) # Next sprint starts the Monday after this sprint ends (Fri + 3 = Mon) $sprintCursor = $sprintEnd.AddDays(3) } $seasonNode['startDate'] = $seasonNode.sprints[0].startDate $seasonNode['endDate'] = $seasonNode.sprints[$numSprints - 1].endDate $yearNode.seasons.Add($seasonNode) # Next season starts the Monday after this season ends $seasonCursor = ([DateTime]$seasonNode['endDate'].Substring(0, 10)).AddDays(3) } $yearNode['startDate'] = $yearNode.seasons[0].startDate $yearNode['endDate'] = $yearNode.seasons[$yearNode.seasons.Count - 1].endDate $calendar.Add($yearNode) } return $calendar } function ConvertTo-FlatIterationPaths { <# .SYNOPSIS Flattens the nested calendar into a sorted list of iteration path objects suitable for storing in the resolved model and creating in ADO. #> [CmdletBinding()] param([Parameter(Mandatory)][object[]]$Calendar) $paths = [System.Collections.Generic.List[object]]::new() foreach ($year in $Calendar) { $paths.Add([ordered]@{ path = $year.path; kind = 'year' }) foreach ($season in $year.seasons) { $paths.Add([ordered]@{ path = $season.path kind = 'season' startDate = $season.startDate endDate = $season.endDate }) foreach ($sprint in $season.sprints) { $paths.Add([ordered]@{ path = $sprint.path kind = 'sprint' startDate = $sprint.startDate endDate = $sprint.endDate }) } } } return $paths } function Get-InScopeSprintPaths { <# .SYNOPSIS Returns the sprint paths that a delivery team should track, based on the number of sprints back/forward from the current sprint. #> [CmdletBinding()] param( [Parameter(Mandatory)][object[]]$Calendar, [int]$Back = 10, [int]$Forward = 10 ) $allSprints = @($Calendar | ForEach-Object { $_.seasons | ForEach-Object { $_.sprints } } | Where-Object { $_ }) $today = [DateTime]::Today $idx = -1 # Find current sprint (contains today) for ($i = 0; $i -lt $allSprints.Count; $i++) { $s = [DateTime]$allSprints[$i].startDate.Substring(0, 10) $e = [DateTime]$allSprints[$i].endDate.Substring(0, 10) if ($today -ge $s -and $today -le $e) { $idx = $i; break } } # If between sprints, use next upcoming if ($idx -lt 0) { for ($i = 0; $i -lt $allSprints.Count; $i++) { if ([DateTime]$allSprints[$i].startDate.Substring(0, 10) -gt $today) { $idx = $i; break } } } if ($idx -lt 0) { return @() } $from = [Math]::Max(0, $idx - $Back) $to = [Math]::Min($allSprints.Count - 1, $idx + $Forward) return @($allSprints[$from..$to] | ForEach-Object { $_.path }) } function Get-InScopeSeasonPaths { <# .SYNOPSIS Returns the season paths that a portfolio team should track, based on the number of seasons back/forward from the current season. #> [CmdletBinding()] param( [Parameter(Mandatory)][object[]]$Calendar, [int]$Back = 3, [int]$Forward = 3 ) $allSeasons = @($Calendar | ForEach-Object { $_.seasons } | Where-Object { $_ }) $today = [DateTime]::Today $idx = -1 for ($i = 0; $i -lt $allSeasons.Count; $i++) { $s = [DateTime]$allSeasons[$i].startDate.Substring(0, 10) $e = [DateTime]$allSeasons[$i].endDate.Substring(0, 10) if ($today -ge $s -and $today -le $e) { $idx = $i; break } } if ($idx -lt 0) { for ($i = 0; $i -lt $allSeasons.Count; $i++) { if ([DateTime]$allSeasons[$i].startDate.Substring(0, 10) -gt $today) { $idx = $i; break } } } if ($idx -lt 0) { return @() } $from = [Math]::Max(0, $idx - $Back) $to = [Math]::Min($allSeasons.Count - 1, $idx + $Forward) return @($allSeasons[$from..$to] | ForEach-Object { $_.path }) } |