Public/Test-PCReleaseReady.ps1

<#
.SYNOPSIS
    Validates that the repository is ready for a release.
.DESCRIPTION
    Performs pre-release validation checks including git status, branch,
    remote sync, optional test and changelog validation.
.PARAMETER RequireBranch
    Branch name(s) that releases must be created from. Default: main, master
.PARAMETER RequireTests
    Require Pester tests to pass before release.
.PARAMETER RequireChangelog
    Require CHANGELOG.md to exist and be updated.
.PARAMETER Path
    Project directory. Defaults to current directory.
.EXAMPLE
    Test-PCReleaseReady
    # Basic validation: git repo, branch, clean, remote sync
.EXAMPLE
    Test-PCReleaseReady -RequireTests -RequireChangelog
    # Full validation including tests and changelog
.OUTPUTS
    [PSCustomObject] with Pass, Errors, Warnings, and Checks properties.
#>

function Test-PCReleaseReady {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [string[]]$RequireBranch = @('main', 'master'),

        [switch]$RequireTests,

        [switch]$RequireChangelog,

        [Parameter()]
        [string]$Path = (Get-Location).Path
    )

    Push-Location $Path
    try {
        $result = [PSCustomObject]@{
            Pass     = $true
            Errors   = [System.Collections.Generic.List[string]]::new()
            Warnings = [System.Collections.Generic.List[string]]::new()
            Checks   = [System.Collections.Generic.List[PSCustomObject]]::new()
        }

        # Check 1: Git repository
        if (-not (Test-GitRepository -Path $Path)) {
            $result.Pass = $false
            $result.Errors.Add("Not in a git repository")
            $result.Checks.Add([PSCustomObject]@{ Check = "Git Repository"; Status = "FAIL"; Message = "Not in a git repository" })
            return $result
        }
        $result.Checks.Add([PSCustomObject]@{ Check = "Git Repository"; Status = "PASS"; Message = "Valid git repository" })

        # Check 2: Current branch
        try {
            $currentBranch = Get-CurrentBranch
            if ($currentBranch -notin $RequireBranch) {
                $result.Pass = $false
                $result.Errors.Add("Must be on branch: $($RequireBranch -join ' or '). Currently on: $currentBranch")
                $result.Checks.Add([PSCustomObject]@{ Check = "Branch"; Status = "FAIL"; Message = "On '$currentBranch', expected: $($RequireBranch -join '/')" })
            }
            else {
                $result.Checks.Add([PSCustomObject]@{ Check = "Branch"; Status = "PASS"; Message = "On valid branch: $currentBranch" })
            }
        }
        catch {
            $result.Pass = $false
            $result.Errors.Add("Failed to get current branch: $_")
            $result.Checks.Add([PSCustomObject]@{ Check = "Branch"; Status = "FAIL"; Message = $_.Exception.Message })
        }

        # Check 3: Working directory clean
        if (-not (Test-WorkingDirectoryClean)) {
            $result.Pass = $false
            $result.Errors.Add("Working directory has uncommitted changes")
            $result.Checks.Add([PSCustomObject]@{ Check = "Working Directory"; Status = "FAIL"; Message = "Uncommitted changes detected" })
        }
        else {
            $result.Checks.Add([PSCustomObject]@{ Check = "Working Directory"; Status = "PASS"; Message = "Clean working directory" })
        }

        # Check 4: Remote sync
        try {
            $branchStatus = Test-BranchUpToDate
            if (-not $branchStatus.IsUpToDate) {
                if ($branchStatus.BehindBy -gt 0) {
                    $result.Pass = $false
                    $result.Errors.Add("Branch is $($branchStatus.BehindBy) commit(s) behind remote")
                }
                if ($branchStatus.AheadBy -gt 0) {
                    $result.Pass = $false
                    $result.Errors.Add("Branch is $($branchStatus.AheadBy) commit(s) ahead of remote. Push first.")
                }
                $result.Checks.Add([PSCustomObject]@{ Check = "Remote Sync"; Status = "FAIL"; Message = $branchStatus.Message })
            }
            else {
                $result.Checks.Add([PSCustomObject]@{ Check = "Remote Sync"; Status = "PASS"; Message = "Up to date with remote" })
            }
        }
        catch {
            $result.Warnings.Add("Could not verify remote sync: $_")
            $result.Checks.Add([PSCustomObject]@{ Check = "Remote Sync"; Status = "WARN"; Message = "Could not verify: $($_.Exception.Message)" })
        }

        # Check 5: Version detectable
        try {
            $versionInfo = Get-PCProjectVersion -Path $Path
            $result.Checks.Add([PSCustomObject]@{ Check = "Version"; Status = "PASS"; Message = "Current version: $($versionInfo.Version)" })
        }
        catch {
            $result.Pass = $false
            $result.Errors.Add("Cannot detect project version: $_")
            $result.Checks.Add([PSCustomObject]@{ Check = "Version"; Status = "FAIL"; Message = $_.Exception.Message })
        }

        # Check 6: Tests (if required)
        if ($RequireTests) {
            $testFiles = Get-ChildItem -Path $Path -Filter '*.Tests.ps1' -Recurse
            if ($testFiles) {
                try {
                    $testResult = Invoke-Pester -Path $Path -PassThru -Show None
                    if ($testResult.FailedCount -gt 0) {
                        $result.Pass = $false
                        $result.Errors.Add("$($testResult.FailedCount) test(s) failed")
                        $result.Checks.Add([PSCustomObject]@{ Check = "Tests"; Status = "FAIL"; Message = "$($testResult.FailedCount) failed, $($testResult.PassedCount) passed" })
                    }
                    else {
                        $result.Checks.Add([PSCustomObject]@{ Check = "Tests"; Status = "PASS"; Message = "$($testResult.PassedCount) test(s) passed" })
                    }
                }
                catch {
                    $result.Pass = $false
                    $result.Errors.Add("Test execution failed: $_")
                    $result.Checks.Add([PSCustomObject]@{ Check = "Tests"; Status = "FAIL"; Message = $_.Exception.Message })
                }
            }
            else {
                $result.Warnings.Add("No Pester test files found")
                $result.Checks.Add([PSCustomObject]@{ Check = "Tests"; Status = "WARN"; Message = "No .Tests.ps1 files found" })
            }
        }

        # Check 7: CHANGELOG (if required)
        if ($RequireChangelog) {
            $changelogPath = Join-Path $Path 'CHANGELOG.md'
            if (-not (Test-Path $changelogPath)) {
                $result.Pass = $false
                $result.Errors.Add("CHANGELOG.md not found")
                $result.Checks.Add([PSCustomObject]@{ Check = "CHANGELOG"; Status = "FAIL"; Message = "File not found" })
            }
            else {
                $changelogContent = Get-Content $changelogPath -Raw
                if ($changelogContent -match '## \[Unreleased\]' -and -not ($changelogContent -match '## \[\d+\.\d+\.\d+\]')) {
                    $result.Warnings.Add("CHANGELOG.md only has [Unreleased] section — no versioned entries")
                    $result.Checks.Add([PSCustomObject]@{ Check = "CHANGELOG"; Status = "WARN"; Message = "Only [Unreleased] section present" })
                }
                else {
                    $result.Checks.Add([PSCustomObject]@{ Check = "CHANGELOG"; Status = "PASS"; Message = "CHANGELOG.md exists with versioned entries" })
                }
            }
        }

        return $result
    }
    finally {
        Pop-Location
    }
}