Public/Build/New-BrownserveChangelogEntry.ps1

<#
.SYNOPSIS
    Creates a new Keep a Changelog entry for a given version in the standard Brownserve format.
.DESCRIPTION
    Generates a new changelog entry following the Keep a Changelog standard.
    Providing the -Auto parameter causes the cmdlet to query merged GitHub pull requests since
    the last release and categorise them into sections (Breaking Changes, Added, Fixed,
    Deprecated, Removed, Changed, Security) based on their GitHub labels.
    PRs labelled 'cicd' are excluded from the changelog. PRs labelled 'removed' appear in
    both the Breaking Changes and Removed sections. All other PRs with a 'breaking' label
    appear only in Breaking Changes.
#>

function New-BrownserveChangelogEntry
{
    [CmdletBinding()]
    param
    (
        # The path to the changelog file
        [Parameter(
            Mandatory = $false,
            Position = 0,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateNotNullOrEmpty()]
        [SupportsWildcards()]
        [string]
        $ChangelogPath = (Join-Path $PWD 'CHANGELOG.md'),

        # The version number to use for the new entry
        [Parameter(
            Mandatory = $true,
            Position = 2,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateNotNullOrEmpty()]
        [System.Management.Automation.SemanticVersion]
        $Version,

        # The owner of the repo that the changelog belongs to
        [Parameter(
            Mandatory = $true,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string]
        $RepositoryOwner,

        # The name of the repo that the changelog belongs to
        [Parameter(
            Mandatory = $true,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string]
        $RepositoryName,

        # The GitHub token to use for API calls (required when using -Auto)
        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string]
        $GitHubToken,

        # An optional notice to attach to this release
        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true
        )]
        [string]
        $Notice,

        # Breaking changes to include (manual override, used without -Auto)
        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true
        )]
        [string[]]
        $BreakingChanges,

        # New additions to include (manual override, used without -Auto)
        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true
        )]
        [string[]]
        $Added,

        # Bug fixes to include (manual override, used without -Auto)
        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true
        )]
        [string[]]
        $Fixed,

        # Deprecations to include (manual override, used without -Auto)
        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true
        )]
        [string[]]
        $Deprecated,

        # Removed features to include (manual override, used without -Auto)
        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true
        )]
        [string[]]
        $Removed,

        # Backwards-compatible changes to include (manual override, used without -Auto)
        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true
        )]
        [string[]]
        $Changed,

        # Security fixes to include (manual override, used without -Auto)
        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true
        )]
        [string[]]
        $Security,

        # Attempt to automatically populate the entry from merged PRs and their labels
        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true
        )]
        [switch]
        $Auto,

        # The version to treat as the baseline when collecting merges.
        # Defaults to the most recent changelog entry, but pass the last stable version here
        # when promoting a pre-release to stable so all changes since the stable release are included.
        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true
        )]
        [System.Management.Automation.SemanticVersion]
        $SinceVersion,

        # Special hidden parameter to allow the cmdlet to be called from the pipeline
        [Parameter(
            Mandatory = $false,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            DontShow = $true
        )]
        [BrownserveChangeLog]
        $ChangelogObject
    )
    begin
    {
        if ($Auto -and !$GitHubToken)
        {
            throw 'You must provide a GitHub token when using the -Auto parameter'
        }
    }
    process
    {
        $Return = $null

        if (!$ChangelogObject)
        {
            try
            {
                Write-Verbose "Loading changelog from $ChangelogPath"
                $ChangelogObject = Read-BrownserveChangelog -Path $ChangelogPath
            }
            catch
            {
                throw "Failed to read changelog file '$ChangelogPath'.`n$($_.Exception.Message)"
            }
        }

        if ($Version -in $ChangelogObject.VersionHistory.Version)
        {
            throw "Version '$Version' already exists in the changelog"
        }

        $LastReleasedVersion = $ChangelogObject.LatestVersion

        if ($SinceVersion)
        {
            $SinceVersionEntry = $ChangelogObject.VersionHistory |
                Where-Object { $_.Version -eq $SinceVersion } |
                    Select-Object -First 1
            if (!$SinceVersionEntry)
            {
                throw "SinceVersion '$SinceVersion' not found in changelog"
            }
            $LastReleasedVersion = $SinceVersionEntry
        }

        if ($Auto)
        {
            try
            {
                $MergesSinceLastRelease = Get-GitMerges `
                    -RepositoryPath $ChangelogPath `
                    -ReferenceBranch "v$($LastReleasedVersion.Version)" `
                    -ErrorAction 'Stop'
                if (!$MergesSinceLastRelease)
                {
                    throw 'No merges found since last release'
                }
            }
            catch
            {
                throw "Failed to get git merges since last release.`n$($_.Exception.Message)"
            }

            try
            {
                $PullRequests = Get-GitHubPullRequests `
                    -RepositoryOwner $RepositoryOwner `
                    -RepositoryName $RepositoryName `
                    -GitHubToken $GitHubToken `
                    -State 'closed' `
                    -ErrorAction 'Stop'
            }
            catch
            {
                throw "Failed to get GitHub pull requests.`n$($_.Exception.Message)"
            }

            $PullRequestDetails = @()
            $MergesSinceLastRelease | ForEach-Object {
                $MergeCommit = $_
                $MatchedPR = $PullRequests | Where-Object { $_.merge_commit_sha -eq $MergeCommit }
                if ($MatchedPR)
                {
                    $PullRequestDetails += $MatchedPR
                }
                else
                {
                    Write-Warning "Merge commit '$MergeCommit' has no corresponding pull request, skipping (likely a branch sync merge)"
                }
            }

            foreach ($PR in $PullRequestDetails)
            {
                $Labels = $PR.labels.name
                $Entry = "$($PR.title) in [#$($PR.number)]($($PR.html_url)) by [@$($PR.user.login)]($($PR.user.html_url))"

                if ('removed' -in $Labels)
                {
                    # removed always implies breaking; surfaces in both sections
                    $BreakingChanges += $Entry
                    $Removed += $Entry
                }
                elseif ('breaking' -in $Labels)
                {
                    $BreakingChanges += $Entry
                }
                elseif ('enhancement' -in $Labels)
                {
                    $Added += $Entry
                }
                elseif ('bug' -in $Labels -or 'documentation' -in $Labels)
                {
                    $Fixed += $Entry
                }
                elseif ('deprecation' -in $Labels)
                {
                    $Deprecated += $Entry
                }
                elseif ('security' -in $Labels)
                {
                    $Security += $Entry
                }
                elseif ('maintenance' -in $Labels)
                {
                    $Changed += $Entry
                }
                elseif ('cicd' -in $Labels)
                {
                    Write-Verbose "Skipping CI/CD PR #$($PR.number): '$($PR.title)'"
                }
                else
                {
                    Write-Warning "PR #$($PR.number) '$($PR.title)' has no recognised changelog label, skipping"
                }
            }

            $HasEntries = $BreakingChanges -or $Added -or $Fixed -or $Deprecated -or $Removed -or $Changed -or $Security
            if (!$HasEntries)
            {
                Write-Warning 'No user-facing changes found among merged PRs - the changelog entry will have no sections'
            }
        }
        else
        {
            $HasEntries = $BreakingChanges -or $Added -or $Fixed -or $Deprecated -or $Removed -or $Changed -or $Security
            if (!$HasEntries)
            {
                throw 'You must provide at least one section of entries when not using the -Auto parameter'
            }
        }

        $ChangelogBlockParams = @{
            Version         = $Version
            RepositoryOwner = $RepositoryOwner
            RepositoryName  = $RepositoryName
            SinceVersion    = $LastReleasedVersion.Version
        }
        if ($Notice)          { $ChangelogBlockParams['Notice']         = $Notice         }
        if ($BreakingChanges) { $ChangelogBlockParams['BreakingChanges'] = $BreakingChanges }
        if ($Added)           { $ChangelogBlockParams['Added']          = $Added           }
        if ($Fixed)           { $ChangelogBlockParams['Fixed']          = $Fixed           }
        if ($Deprecated)      { $ChangelogBlockParams['Deprecated']     = $Deprecated      }
        if ($Removed)         { $ChangelogBlockParams['Removed']        = $Removed         }
        if ($Changed)         { $ChangelogBlockParams['Changed']        = $Changed         }
        if ($Security)        { $ChangelogBlockParams['Security']       = $Security        }

        try
        {
            $Return = New-BrownserveChangelogBlock @ChangelogBlockParams -ErrorAction 'Stop'
        }
        catch
        {
            throw "Failed to create changelog block.`n$($_.Exception.Message)"
        }
    }
    end
    {
        return $Return
    }
}