ChangelogManagement.psm1

$NL = [System.Environment]::NewLine

function Get-ChangelogData {
    <#
    .SYNOPSIS
        Takes a changelog in Keep a Changelog 1.0.0 format and parses the data into a PowerShell object.
 
    .DESCRIPTION
        This cmdlet parses the data in a changelog file using Keep a Changelog 1.0.0 format into a PowerShell object.
 
    .INPUTS
        This cmdlet does not accept pipeline input.
 
    .OUTPUTS
        This cmdlet outputs a PSCustomObject containing the changelog data.
 
    .EXAMPLE
        Get-ChangelogData
        Returns an object containing Header, Unreleased, Released, Footer, and LastVersion properties.
 
    .LINK
        https://github.com/natescherer/ChangelogManagement
    #>


    [CmdletBinding()]
    param (
        [parameter(Mandatory=$false)]
        [ValidateScript({Test-Path -Path $_})]
        # Path to the changelog; defaults to ".\CHANGELOG.md"
        [string]$Path = "CHANGELOG.md"
    )

    $ChangeTypes = @("Added", "Changed", "Deprecated", "Removed", "Fixed", "Security")
    $ChangelogData = Get-Content -Path $Path -Raw

    $Output = [PSCustomObject]@{
        "Header" = ""
        "Unreleased" = [PSCustomObject]@{}
        "Released" = @()
        "Footer" = ""
        "LastVersion" = ""
    }

    # Split changelog into $Sections and split header and footer into their own variables
    [System.Collections.ArrayList]$Sections = $ChangelogData -split "## \["
    $Output.Header = $Sections[0]
    $Sections.Remove($Output.Header)
    if ($Sections[-1] -match ".*\[Unreleased\]:.*") {
        $Output.Footer = "[Unreleased]:" + ($Sections[-1] -split "\[Unreleased\]:")[1]
        $Sections[-1] = ($Sections[-1] -split "\[Unreleased\]:")[0]
    }

    # Restore the leading "## [" onto each section that was previously removed by split function, and trim extra
    # line breaks
    $i = 1
    while ($i -le $Sections.Count) {
        $Sections[$i - 1] = "## [" + $Sections[$i - 1]
        $i++
    }

    # If found, split the Unreleased section into $UnreleasedTemp, then remove it from $Sections
    if ($Sections[0] -match "## \[Unreleased\].*") {
        $UnreleasedTemp = $Sections[0]
        $Sections.Remove($UnreleasedTemp)
    } else {
        $UnreleasedTemp = ""
    }

    # Construct the $Output.Unreleased object
    foreach ($ChangeType in $ChangeTypes) {
        if ($UnreleasedTemp -notlike "*### $ChangeType*") {
            Set-Variable -Name "Unreleased$ChangeType" -Value $null
        }
        else {
            $Value = (($UnreleasedTemp -split "### $ChangeType$NL")[1] -split "###")[0].TrimEnd($NL) -split $NL | ForEach-Object { $_.TrimStart("- ") }
            Set-Variable -Name "Unreleased$ChangeType" -Value $Value
        }
    }
    $Output.Unreleased = [PSCustomObject]@{
        "RawData" = $UnreleasedTemp
        "Link" = (($Output.Footer -split "Unreleased\]: ")[1] -split $NL)[0]
        "Data" = [PSCustomObject]@{
            Added = $UnreleasedAdded
            Changed = $UnreleasedChanged
            Deprecated = $UnreleasedDeprecated
            Removed = $UnreleasedRemoved
            Fixed = $UnreleasedFixed
            Security = $UnreleasedSecurity
        }
    }

    # Construct the $Output.Released array
    foreach ($Release in $Sections) {
        foreach ($ChangeType in $ChangeTypes) {
            if ($Release -notlike "*### $ChangeType*") {
                Set-Variable -Name "Release$ChangeType" -Value $null
            }
            else {
                $Value = (($Release -split "### $ChangeType$NL")[1] -split "###")[0].TrimEnd($NL) -split $NL | ForEach-Object { $_.TrimStart("- ") }
                Set-Variable -Name "Release$ChangeType" -Value $Value
            }
        }

        $LoopVersionNumber = $Release.Split("[")[1].Split("]")[0]
        $Output.Released += [PSCustomObject]@{
            "RawData" = $Release
            "Date" = Get-Date ($Release -split "\] \- ")[1].Split($NL)[0]
            "Version" = $LoopVersionNumber
            "Link" = (($Output.Footer -split "$LoopVersionNumber\]: ")[1] -split $NL)[0]
            "Data" = [PSCustomObject]@{
                Added = $ReleaseAdded
                Changed = $ReleaseChanged
                Deprecated = $ReleaseDeprecated
                Removed = $ReleaseRemoved
                Fixed = $ReleaseFixed
                Security = $ReleaseSecurity
            }
        }
    }

    # Set $Output.LastVersion to the version number of the latest release listed in the changelog, or null if there
    # have not been any releases yet
    if ($Output.Released[0].Version) {
        $Output.LastVersion = $Output.Released[0].Version
    } else {
        $Output.LastVersion = $null
    }

    $Output
}

function Add-ChangelogData {
    <#
    .SYNOPSIS
        Adds an item to a changelog file in Keep a Changelog 1.0.0 format.
 
    .DESCRIPTION
        This cmdlet adds new Added/Changed/Deprecated/Removed/Fixed/Security items to the Unreleased section of a
        changelog in Keep a Changelog 1.0.0 format.
 
    .INPUTS
        This cmdlet does not accept pipeline input.
 
    .OUTPUTS
        This cmdlet does not generate output.
 
    .EXAMPLE
        Add-ChangelogData -Type "Added" -Data "Spanish language translation"
        Does not generate output, but adds a new Added change into changelog at .\CHANGELOG.md.
 
    .EXAMPLE
        Add-ChangelogData -Type "Removed" -Data "TLS 1.0 support" -Path project\CHANGELOG.md
        Does not generate output, but adds a new Security change into changelog at project\CHANGELOG.md.
 
    .LINK
        https://github.com/natescherer/ChangelogManagement
    #>


    [CmdletBinding()]
    param (
        [parameter(Mandatory=$false)]
        [ValidateScript({Test-Path -Path $_})]
        # The path to the source changelog file; defaults to .\CHANGELOG.md
        [string]$Path = "CHANGELOG.md",

        [parameter(Mandatory=$false)]
        [ValidatePattern(".*\.md")]
        # The path to the output changelog file; defaults to the same path as the source file
        [string]$OutputPath = $Path,

        [parameter(Mandatory=$true)]
        [ValidateSet("Added","Changed","Deprecated","Removed","Fixed","Security")]
        # Type of change to add to the changelog (Added, Changed, Deprecated, Removed, Fixed, or Security)
        [string]$Type,

        [parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        # The value of the change you are adding to the changelog
        [string]$Data
    )

    $ChangeTypes = @("Added", "Changed", "Deprecated", "Removed", "Fixed", "Security")
    $ChangelogData = Get-ChangelogData -Path $Path

    $Output = ""
    $Output += $ChangelogData.Header
    $Output += "## [Unreleased]$NL"
    foreach ($ChangeType in $ChangeTypes) {
        $ChangeMade = $false
        if ($Type -eq $ChangeType) {
            $Output += "### $ChangeType$NL"
            $Output += "- $Data$NL"
            $ChangeMade = $true
        }
        if ($ChangelogData.Unreleased.Data.$ChangeType) {
            if ($Output -notlike "*### $ChangeType*") {
                $Output += "### $ChangeType$NL"
            }
            foreach ($Datum in $ChangelogData.Unreleased.Data.$ChangeType) {
                $Output += "- $Datum$NL"
                $ChangeMade = $true
            }
        }
        if ($ChangeMade) {
            $Output += $NL
        }
    }
    foreach ($Release in $ChangelogData.Released) {
        $Output += $Release.RawData
    }
    $Output += $ChangelogData.Footer

    Set-Content -Value $Output -Path $OutputPath -NoNewline
}

function New-Changelog {
    <#
    .SYNOPSIS
        Creates a new, blank changelog in Keep a Changelog 1.0.0 format.
 
    .DESCRIPTION
        This cmdlet creates a new, blank changelog in Keep a Changelog 1.0.0 format.
 
    .INPUTS
        This cmdlet does not accept pipeline input.
 
    .OUTPUTS
        This cmdlet does not generate output.
 
    .EXAMPLE
        New-Changelog
        Does not generate output, but creates a new changelog at .\CHANGELOG.md
 
    .EXAMPLE
        New-Changelog -Path project\CHANGELOG.md -NoSemVer
        Does not generate output, but creates a new changelog at project\CHANGELOG.md while excluding SemVer statement from the header
 
    .LINK
        https://github.com/natescherer/ChangelogManagement
    #>


    [CmdletBinding()]
    param (
        [parameter(Mandatory=$false)]
        [ValidateNotNullOrEmpty()]
        # The path to output the changelog file; defaults to .\CHANGELOG.md
        [string]$Path = "CHANGELOG.md",

        [parameter(Mandatory=$false)]
        # Exclude the statement about Semantic Versioning from the changelog
        [switch]$NoSemVer
    )

    $Output = ""

    $Output += "# Changelog$NL"
    $Output += "All notable changes to this project will be documented in this file.$NL$NL"
    $Output += "The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)"
    if ($NoSemVer -eq $false) {
        $Output += ",$NL"
        $Output += "and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)"
    }
    $Output += ".$NL$NL"
    $Output += "## [Unreleased]$NL"

    Set-Content -Value $Output -Path $Path -NoNewline
}

function Update-Changelog {
    <#
    .SYNOPSIS
        Takes all unreleased changes listed in changelog, adds them to a new version,
        and makes a new, blank Unreleased section.
 
    .DESCRIPTION
        This cmdlet automates the updating of changelogs in Keep a Changelog 1.0.0 format at release time. It
        takes all changes in the Unreleased section, adds them to a new section with a version number you specify,
        then makes a new, blank Unreleased section.
 
    .INPUTS
        This cmdlet does not accept pipeline input.
 
    .OUTPUTS
        This cmdlet does not generate output except in the event of an error or notice.
 
    .EXAMPLE
        Update-Changelog -ReleaseVersion 1.1.1 -LinkMode Automatic -LinkPattern @{FirstRelease="https://github.com/testuser/testrepo/tree/v{CUR}";NormalRelease="https://github.com/testuser/testrepo/compare/v{PREV}..v{CUR}";Unreleased="https://github.com/testuser/testrepo/compare/v{CUR}..HEAD"}
        Does not generate output, but:
        1. Takes all Unreleased changes in .\CHANGELOG.md and adds them to a new release tagged with ReleaseVersion and today's date.
        2. Updates links according to LinkPattern.
        3. Creates a new, blank Unreleased section
 
    .LINK
        https://github.com/natescherer/ChangelogManagement
    #>


    [CmdletBinding()]
    param (
        [parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        # Version number for the new release
        [string]$ReleaseVersion,

        [parameter(Mandatory=$false)]
        [ValidateScript({Test-Path -Path $_})]
        # The path to the source changelog file; defaults to .\CHANGELOG.md
        [string]$Path = "CHANGELOG.md",

        [parameter(Mandatory=$false)]
        [ValidatePattern(".*\.md")]
        # The path to the output changelog file; defaults to the source file
        [string]$OutputPath = $Path,

        [parameter(Mandatory=$true)]
        # Mode used for adding links at the bottom of the Changelog for new versions. Can either be Automatic
        # (adding based pattern provided via -LinkPattern), Manual (adding placeholders which
        # will need manually updated), or None (not adding links).
        [ValidateSet("Automatic","Manual","None")]
        [string]$LinkMode,

        [parameter(Mandatory=$false)]
        # Pattern used for adding links at the bottom of the Changelog when -LinkMode is set to Automatic. This
        # is a hashtable that defines the format for the three possible types of links needed: FirstRelease, NormalRelease,
        # and Unreleased. The current version in the patterns should be replaced with {CUR} and the previous
        # version with {PREV}. See examples for details on format of hashtable.
        [ValidateNotNullOrEmpty()]
        [hashtable]$LinkPattern
    )

    if (($LinkMode -eq "Automatic") -and !($LinkPattern)) {
        throw "-LinkPattern must be used when -LinkMode is set to Automatic"
    }

    $ChangelogData = Get-ChangelogData -Path $Path

    <#
        Create $NewRelease by removing header from old Unreleased section
 
        Using the regular expression '\r?\n' to look for either CRLF or just LF.
        This resolves issue #11.
    #>

    $NewRelease = $ChangelogData.Unreleased.RawData -replace "## \[Unreleased\]\r?\n",""

    If ([string]::IsNullOrWhiteSpace($NewRelease)) {
        Throw "No changes detected in current release, exiting."
    }

    # Edit $NewRelease to add version number and today's date
    $Today = (Get-Date -Format 'o').Split('T')[0]
    $NewRelease = "## [$ReleaseVersion] - $Today$NL" + $NewRelease

    # Inject links into footer
    if ($LinkMode -eq "Automatic") {
        if ($ChangelogData.Released -ne "") {
            $NewFooter = ("[Unreleased]: " + ($LinkPattern['Unreleased'] -replace "{CUR}", $ReleaseVersion) + "$NL" +
                "[$ReleaseVersion]: " + (($LinkPattern['NormalRelease'] -replace "{CUR}", $ReleaseVersion) -replace "{PREV}", $ChangelogData.LastVersion) + "$NL" +
                ($ChangelogData.Footer.Trim() -replace "\[Unreleased\].*","").TrimStart($NL))
        } else {
            $NewFooter = ("[Unreleased]: " + ($LinkPattern['Unreleased'] -replace "{CUR}", $ReleaseVersion) + "$NL" +
                "[$ReleaseVersion]: " + ($LinkPattern['FirstRelease'] -replace "{CUR}", $ReleaseVersion))
        }
    }
    if ($LinkMode -eq "Manual") {
        if ($ChangelogData.Released -ne "") {
            $NewFooter = ("[Unreleased]: ENTER-URL-HERE$NL" +
                "[$ReleaseVersion]: ENTER-URL-HERE$NL" +
                ($ChangelogData.Footer.Trim() -replace "\[Unreleased\].*","").TrimStart($NL))

        } else {
            $NewFooter = ("[Unreleased]: ENTER-URL-HERE$NL" +
                "[$ReleaseVersion]: ENTER-URL-HERE")
        }
        Write-Output ("Because you selected LinkMode Manual, you will need to manually update the links at the " +
            "bottom of the output file.")
    }
    if ($LinkMode -eq "None") {
        $NewFooter = $ChangelogData.Footer.Trim()
    }

    # Build & write updated CHANGELOG.md
    $Output += $ChangelogData.Header
    $Output += "## [Unreleased]$NL$NL"
    $Output += $NewRelease
    if ($ChangelogData.Released) {
        #$Output += $NL
        foreach ($Release in $ChangelogData.Released) {
            $Output += $Release.RawData
        }
    }
    $Output += $NewFooter

    Set-Content -Value $Output -Path $OutputPath -NoNewline
}

function ConvertFrom-Changelog {
    <#
    .SYNOPSIS
        Takes a changelog in Keep a Changelog 1.0.0 format and converts it to another format.
 
    .DESCRIPTION
        This cmdlet converts a changelog file using Keep a Changelog 1.0.0 format into one of several other formats.
        Valid formats are Release (same as input, but with the Unreleased section removed), Text
        (markdown and links removed), and TextRelease (Unreleased section, markdown, and links removed).
 
    .INPUTS
        This cmdlet does not accept pipeline input.
 
    .OUTPUTS
        This cmdlet does not generate output.
 
    .EXAMPLE
        ConvertFrom-Changelog -Path .\CHANGELOG.md -Format Release -OutputPath docs\CHANGELOG.md
        Does not generate output, but creates a file at docs\CHANGELOG.md that is the same as the input with the Unreleased section removed.
 
    .EXAMPLE
        ConvertFrom-Changelog -Path .\CHANGELOG.md -Format Text -OutputPath CHANGELOG.txt
        .Does not generate output, but creates a file at CHANGELOG.txt that has header, markdown, and links removed.
 
    .LINK
        https://github.com/natescherer/ChangelogManagement
    #>


    [CmdletBinding()]
    param (
        [parameter(Mandatory=$false)]
        [ValidateScript({Test-Path -Path $_})]
        # Path to the changelog; defaults to ".\CHANGELOG.md"
        [string]$Path = "CHANGELOG.md",

        [parameter(Mandatory=$false)]
        [ValidateNotNullOrEmpty()]
        # The path to the output changelog file; defaults to source path
        [string]$OutputPath = $Path,

        [parameter(Mandatory=$true)]
        # Format to convert changelog into. Valid values are Release (same as input, but with the Unreleased
        # section removed), Text (markdown and links removed), and TextRelease (Unreleased section, markdown, and
        # links removed).
        [ValidateSet("Release","Text","TextRelease")]
        [string]$Format,

        [parameter(Mandatory=$false)]
        # Exclude header from output
        [switch]$NoHeader
    )

    $ChangelogData = Get-ChangelogData -Path $Path
    $Output = ""
    if ($NoHeader -eq $false) {
        if ($Format -like "Text*") {
            $Output += (($ChangelogData.Header -replace "\[","") -replace "\]"," ").Trim()
        } else {
            $Output += $ChangelogData.Header.Trim()
        }
    }
    if ($Format -notlike "*Release") {
        if ($Output -ne "") {
            $Output += "$NL$NL"
        }
        $Output += $ChangelogData.Unreleased.RawData.Trim()
    }
    foreach ($Release in $ChangelogData.Released) {
        if ($Output -ne "") {
            $Output += "$NL$NL"
        }
        $Output += $Release.RawData.Trim()
    }
    if ($Format -eq "Release") {
        $Output += "$NL$NL"
        $Output += $ChangelogData.Footer -replace "\[Unreleased\].*$NL",""
    }

    if ($Format -like "Text*") {
        $Output = $Output -replace "### ",""
        $Output = $Output -replace "## ",""
        $Output = $Output -replace "# ",""
    }

    Set-Content -Value $Output -Path $OutputPath -NoNewline
}

Export-ModuleMember -Function Get-ChangelogData, Add-ChangelogData, New-Changelog, Update-Changelog, ConvertFrom-Changelog