scripts/internal/task-progress.ps1
|
Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $sharedGovernancePath = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'extensions\specrew-speckit\scripts\shared-governance.ps1' if (-not (Test-Path -LiteralPath $sharedGovernancePath -PathType Leaf)) { throw "Missing shared governance helper '$sharedGovernancePath'." } . $sharedGovernancePath function Get-TaskProgressMarkdownContent { param([string]$Path) return @(Get-Content -LiteralPath $Path -Encoding UTF8) } function Get-TaskProgressMarkdownSectionTable { param( [string[]]$Lines, [string]$Heading ) $headingPattern = '^##\s+' + [regex]::Escape($Heading) + '\b' $startIndex = -1 for ($index = 0; $index -lt $Lines.Count; $index++) { if ($Lines[$index] -match $headingPattern) { $startIndex = $index break } } if ($startIndex -lt 0) { return @() } $tableLines = New-Object System.Collections.Generic.List[string] for ($index = $startIndex + 1; $index -lt $Lines.Count; $index++) { $currentLine = $Lines[$index] if ($currentLine -match '^##\s+') { break } if ($currentLine.Trim().StartsWith('|')) { $null = $tableLines.Add($currentLine) } } if ($tableLines.Count -lt 2) { return @() } $headers = ($tableLines[0].Trim('|') -split '\|') | ForEach-Object { $_.Trim() } $rows = New-Object System.Collections.Generic.List[object] for ($rowIndex = 1; $rowIndex -lt $tableLines.Count; $rowIndex++) { $cells = ($tableLines[$rowIndex].Trim('|') -split '\|') | ForEach-Object { $_.Trim() } $isSeparator = $true foreach ($cell in $cells) { if ($cell -notmatch '^:?-{3,}:?$') { $isSeparator = $false break } } if ($isSeparator) { continue } $row = [ordered]@{} for ($cellIndex = 0; $cellIndex -lt $headers.Count; $cellIndex++) { $row[$headers[$cellIndex]] = if ($cellIndex -lt $cells.Count) { $cells[$cellIndex] } else { '' } } $rows.Add([pscustomobject]$row) | Out-Null } return $rows.ToArray() } function Get-SpecrewYamlScalarValue { param([AllowNull()][string]$Value) if ($null -eq $Value) { return $null } $text = $Value.Trim() if (($text.StartsWith('"') -and $text.EndsWith('"')) -or ($text.StartsWith("'") -and $text.EndsWith("'"))) { $text = $text.Substring(1, $text.Length - 2) } if ($text -eq 'null') { return $null } return $text.Replace('\"', '"') } function ConvertTo-SpecrewYamlScalar { param([AllowNull()][string]$Value) if ($null -eq $Value) { return 'null' } return '"' + $Value.Replace('\', '\\').Replace('"', '\"') + '"' } function Resolve-TaskProgressFeatureRef { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowNull()][string]$FeatureRef, [AllowNull()][string]$ResolvedFeaturePath ) if (-not [string]::IsNullOrWhiteSpace($FeatureRef)) { return $FeatureRef.Trim() } if (-not [string]::IsNullOrWhiteSpace($ResolvedFeaturePath)) { return Split-Path -Leaf $ResolvedFeaturePath } $featureJsonPath = Join-Path (Resolve-ProjectPath -Path $ProjectRoot) '.specify\feature.json' if (Test-Path -LiteralPath $featureJsonPath -PathType Leaf) { try { $featureJson = Get-Content -LiteralPath $featureJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json if (-not [string]::IsNullOrWhiteSpace([string]$featureJson.feature_directory)) { return Split-Path -Leaf ([string]$featureJson.feature_directory) } } catch { } } throw 'Could not resolve the active feature reference for task-progress tracking.' } function Get-IterationTaskProgressPath { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowNull()][string]$FeatureRef, [Parameter(Mandatory = $true)][string]$IterationNumber, [AllowNull()][string]$ResolvedFeaturePath ) $resolvedProjectRoot = Resolve-ProjectPath -Path $ProjectRoot $effectiveFeatureRef = Resolve-TaskProgressFeatureRef -ProjectRoot $resolvedProjectRoot -FeatureRef $FeatureRef -ResolvedFeaturePath $ResolvedFeaturePath return Join-Path $resolvedProjectRoot ("specs\{0}\iterations\{1}\tasks-progress.yml" -f $effectiveFeatureRef, $IterationNumber) } function Get-IterationPlanPath { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowNull()][string]$FeatureRef, [Parameter(Mandatory = $true)][string]$IterationNumber, [AllowNull()][string]$ResolvedFeaturePath ) $resolvedProjectRoot = Resolve-ProjectPath -Path $ProjectRoot $effectiveFeatureRef = Resolve-TaskProgressFeatureRef -ProjectRoot $resolvedProjectRoot -FeatureRef $FeatureRef -ResolvedFeaturePath $ResolvedFeaturePath return Join-Path $resolvedProjectRoot ("specs\{0}\iterations\{1}\plan.md" -f $effectiveFeatureRef, $IterationNumber) } function Get-IterationTaskCatalog { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowNull()][string]$FeatureRef, [Parameter(Mandatory = $true)][string]$IterationNumber, [AllowNull()][string]$ResolvedFeaturePath ) $planPath = Get-IterationPlanPath -ProjectRoot $ProjectRoot -FeatureRef $FeatureRef -IterationNumber $IterationNumber -ResolvedFeaturePath $ResolvedFeaturePath if (-not (Test-Path -LiteralPath $planPath -PathType Leaf)) { throw "Iteration plan not found: $planPath" } $rows = @(Get-TaskProgressMarkdownSectionTable -Lines (Get-TaskProgressMarkdownContent -Path $planPath) -Heading 'Tasks') return @( $rows | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.Task) } | ForEach-Object { [pscustomobject]@{ Task = [string]$_.Task Title = [string]$_.Title Requirement = [string]$_.Requirement Story = [string]$_.Story Effort = [string]$_.Effort } } ) } function New-TaskProgressEntry { param([Parameter(Mandatory = $true)][pscustomobject]$TaskRow) return [ordered]@{ title = $TaskRow.Title status = 'pending' started_at = $null completed_at = $null blocked_reason = $null } } function Get-TaskProgressState { param([Parameter(Mandatory = $true)][string]$Path) $metadata = [ordered]@{} $tasks = [ordered]@{} if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return [pscustomobject]@{ Metadata = $metadata Tasks = $tasks } } $currentTaskId = $null foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8) { if ($line -match '^(schema|feature|iteration|updated_at):\s*(.+?)\s*$') { $metadata[$Matches[1]] = Get-SpecrewYamlScalarValue -Value $Matches[2] continue } if ($line -match '^ ([^:]+):\s*$') { $currentTaskId = $Matches[1] $tasks[$currentTaskId] = [ordered]@{} continue } if (-not [string]::IsNullOrWhiteSpace($currentTaskId) -and $line -match '^ ([^:]+):\s*(.+?)\s*$') { $tasks[$currentTaskId][$Matches[1]] = Get-SpecrewYamlScalarValue -Value $Matches[2] } } return [pscustomobject]@{ Metadata = $metadata Tasks = $tasks } } function ConvertTo-TaskProgressContent { param( [Parameter(Mandatory = $true)][string]$FeatureRef, [Parameter(Mandatory = $true)][string]$IterationNumber, [Parameter(Mandatory = $true)][System.Collections.Specialized.OrderedDictionary]$Tasks ) $lines = New-Object System.Collections.Generic.List[string] $lines.Add(('schema: {0}' -f (ConvertTo-SpecrewYamlScalar -Value 'v1'))) | Out-Null $lines.Add(('feature: {0}' -f (ConvertTo-SpecrewYamlScalar -Value $FeatureRef))) | Out-Null $lines.Add(('iteration: {0}' -f (ConvertTo-SpecrewYamlScalar -Value $IterationNumber))) | Out-Null $lines.Add(('updated_at: {0}' -f (ConvertTo-SpecrewYamlScalar -Value ((Get-Date).ToUniversalTime().ToString('o'))))) | Out-Null $lines.Add('tasks:') | Out-Null foreach ($taskId in $Tasks.Keys) { $entry = $Tasks[$taskId] $lines.Add((" {0}:" -f $taskId)) | Out-Null $lines.Add((" title: {0}" -f (ConvertTo-SpecrewYamlScalar -Value ([string]$entry.title)))) | Out-Null $lines.Add((" status: {0}" -f (ConvertTo-SpecrewYamlScalar -Value ([string]$entry.status)))) | Out-Null $lines.Add((" started_at: {0}" -f (ConvertTo-SpecrewYamlScalar -Value ([string]$entry.started_at)))) | Out-Null $lines.Add((" completed_at: {0}" -f (ConvertTo-SpecrewYamlScalar -Value ([string]$entry.completed_at)))) | Out-Null $lines.Add((" blocked_reason: {0}" -f (ConvertTo-SpecrewYamlScalar -Value ([string]$entry.blocked_reason)))) | Out-Null } return ($lines -join [Environment]::NewLine) + [Environment]::NewLine } function Sync-IterationTaskProgress { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowNull()][string]$FeatureRef, [Parameter(Mandatory = $true)][string]$IterationNumber, [AllowNull()][string]$ResolvedFeaturePath ) $effectiveFeatureRef = Resolve-TaskProgressFeatureRef -ProjectRoot $ProjectRoot -FeatureRef $FeatureRef -ResolvedFeaturePath $ResolvedFeaturePath $catalog = @(Get-IterationTaskCatalog -ProjectRoot $ProjectRoot -FeatureRef $effectiveFeatureRef -IterationNumber $IterationNumber -ResolvedFeaturePath $ResolvedFeaturePath) $path = Get-IterationTaskProgressPath -ProjectRoot $ProjectRoot -FeatureRef $effectiveFeatureRef -IterationNumber $IterationNumber -ResolvedFeaturePath $ResolvedFeaturePath $existing = Get-TaskProgressState -Path $path $tasks = [ordered]@{} foreach ($taskRow in $catalog) { if ($existing.Tasks.Contains($taskRow.Task)) { $entry = [ordered]@{ title = $taskRow.Title status = if ([string]::IsNullOrWhiteSpace([string]$existing.Tasks[$taskRow.Task].status)) { 'pending' } else { [string]$existing.Tasks[$taskRow.Task].status } started_at = [string]$existing.Tasks[$taskRow.Task].started_at completed_at = [string]$existing.Tasks[$taskRow.Task].completed_at blocked_reason = [string]$existing.Tasks[$taskRow.Task].blocked_reason } } else { $entry = New-TaskProgressEntry -TaskRow $taskRow } $tasks[$taskRow.Task] = $entry } $content = ConvertTo-TaskProgressContent -FeatureRef $effectiveFeatureRef -IterationNumber $IterationNumber -Tasks $tasks $existingContent = if (Test-Path -LiteralPath $path -PathType Leaf) { Get-Content -LiteralPath $path -Raw -Encoding UTF8 } else { $null } if ($content -ne $existingContent) { Write-Utf8FileAtomic -Path $path -Content $content } return [pscustomobject]@{ Path = $path FeatureRef = $effectiveFeatureRef Iteration = $IterationNumber Tasks = $tasks } } function Set-TaskStatus { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowNull()][string]$FeatureRef, [Parameter(Mandatory = $true)][string]$IterationNumber, [Parameter(Mandatory = $true)][string]$TaskId, [Parameter(Mandatory = $true)][ValidateSet('pending', 'in-progress', 'complete', 'blocked')][string]$Status, [AllowNull()][string]$Reason, [AllowNull()][string]$ResolvedFeaturePath ) $state = Sync-IterationTaskProgress -ProjectRoot $ProjectRoot -FeatureRef $FeatureRef -IterationNumber $IterationNumber -ResolvedFeaturePath $ResolvedFeaturePath if (-not $state.Tasks.Contains($TaskId)) { throw "Task ID '$TaskId' is not present in Iteration $IterationNumber." } if ($Status -eq 'blocked' -and [string]::IsNullOrWhiteSpace($Reason)) { throw "Task '$TaskId' requires -Reason when status is 'blocked'." } $entry = $state.Tasks[$TaskId] $timestamp = (Get-Date).ToUniversalTime().ToString('o') $entry.status = $Status switch ($Status) { 'pending' { $entry.blocked_reason = $null } 'in-progress' { if ([string]::IsNullOrWhiteSpace([string]$entry.started_at)) { $entry.started_at = $timestamp } $entry.completed_at = $null $entry.blocked_reason = $null } 'complete' { if ([string]::IsNullOrWhiteSpace([string]$entry.started_at)) { $entry.started_at = $timestamp } $entry.completed_at = $timestamp $entry.blocked_reason = $null } 'blocked' { if ([string]::IsNullOrWhiteSpace([string]$entry.started_at)) { $entry.started_at = $timestamp } $entry.completed_at = $null $entry.blocked_reason = $Reason.Trim() } } $content = ConvertTo-TaskProgressContent -FeatureRef $state.FeatureRef -IterationNumber $IterationNumber -Tasks $state.Tasks Write-Utf8FileAtomic -Path $state.Path -Content $content return [pscustomobject]@{ Path = $state.Path TaskId = $TaskId Status = $Status StartedAt = $entry.started_at CompletedAt = $entry.completed_at BlockedReason = $entry.blocked_reason } } function Set-TaskComplete { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowNull()][string]$FeatureRef, [Parameter(Mandatory = $true)][string]$IterationNumber, [Parameter(Mandatory = $true)][string]$TaskId, [AllowNull()][string]$ResolvedFeaturePath ) return Set-TaskStatus -ProjectRoot $ProjectRoot -FeatureRef $FeatureRef -IterationNumber $IterationNumber -TaskId $TaskId -Status 'complete' -ResolvedFeaturePath $ResolvedFeaturePath } function Set-TaskBlocked { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowNull()][string]$FeatureRef, [Parameter(Mandatory = $true)][string]$IterationNumber, [Parameter(Mandatory = $true)][string]$TaskId, [Parameter(Mandatory = $true)][string]$Reason, [AllowNull()][string]$ResolvedFeaturePath ) return Set-TaskStatus -ProjectRoot $ProjectRoot -FeatureRef $FeatureRef -IterationNumber $IterationNumber -TaskId $TaskId -Status 'blocked' -Reason $Reason -ResolvedFeaturePath $ResolvedFeaturePath } function Get-TaskProgressSummary { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowNull()][string]$FeatureRef, [Parameter(Mandatory = $true)][string]$IterationNumber, [AllowNull()][string]$ResolvedFeaturePath ) $effectiveFeatureRef = Resolve-TaskProgressFeatureRef -ProjectRoot $ProjectRoot -FeatureRef $FeatureRef -ResolvedFeaturePath $ResolvedFeaturePath $planPath = Get-IterationPlanPath -ProjectRoot $ProjectRoot -FeatureRef $effectiveFeatureRef -IterationNumber $IterationNumber -ResolvedFeaturePath $ResolvedFeaturePath $progressPath = Get-IterationTaskProgressPath -ProjectRoot $ProjectRoot -FeatureRef $effectiveFeatureRef -IterationNumber $IterationNumber -ResolvedFeaturePath $ResolvedFeaturePath $state = if (Test-Path -LiteralPath $planPath -PathType Leaf) { Sync-IterationTaskProgress -ProjectRoot $ProjectRoot -FeatureRef $effectiveFeatureRef -IterationNumber $IterationNumber -ResolvedFeaturePath $ResolvedFeaturePath } else { [pscustomobject]@{ Path = $progressPath FeatureRef = $effectiveFeatureRef Iteration = $IterationNumber Tasks = (Get-TaskProgressState -Path $progressPath).Tasks } } $complete = New-Object System.Collections.Generic.List[object] $inProgress = New-Object System.Collections.Generic.List[object] $pending = New-Object System.Collections.Generic.List[object] $blocked = New-Object System.Collections.Generic.List[object] $latestCompleted = $null foreach ($taskId in $state.Tasks.Keys) { $entry = $state.Tasks[$taskId] $taskRecord = [pscustomobject]@{ id = $taskId title = [string]$entry.title status = [string]$entry.status started_at = [string]$entry.started_at completed_at = [string]$entry.completed_at blocked_reason = [string]$entry.blocked_reason } switch ($taskRecord.status) { 'complete' { $complete.Add($taskRecord) | Out-Null if (-not [string]::IsNullOrWhiteSpace($taskRecord.completed_at)) { $completedAt = [datetime]::Parse($taskRecord.completed_at, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal) if ($null -eq $latestCompleted -or $completedAt -gt $latestCompleted.timestamp) { $latestCompleted = [pscustomobject]@{ id = $taskRecord.id title = $taskRecord.title timestamp = $completedAt } } } } 'in-progress' { $inProgress.Add($taskRecord) | Out-Null } 'blocked' { $blocked.Add($taskRecord) | Out-Null } default { $pending.Add($taskRecord) | Out-Null } } } return [pscustomobject]@{ Path = $state.Path Complete = $complete.ToArray() InProgress = $inProgress.ToArray() Pending = $pending.ToArray() Blocked = $blocked.ToArray() LatestCompleted = if ($null -ne $latestCompleted) { [pscustomobject]@{ id = $latestCompleted.id title = $latestCompleted.title timestamp = $latestCompleted.timestamp.ToString('o') } } else { $null } } } |