KeepAChangelog.psm1
|
Set-StrictMode -Version Latest # Source: src/public/Convert-ChangelogReleaseNotesToTagMessage.ps1 function Convert-ChangelogReleaseNotesToTagMessage { [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$ReleaseNotes ) $lineList = [System.Collections.Generic.List[string]]::new() $allowBlankLine = $false foreach ($line in $ReleaseNotes -split '\r?\n') { $trimmedLine = $line.Trim() if ([string]::IsNullOrWhiteSpace($trimmedLine)) { if ($allowBlankLine -and $lineList.Count -gt 0) { $lineList.Add('') $allowBlankLine = $false } continue } if ($trimmedLine -match '^###\s+(?<value>.+)$') { $lineList.Add($Matches.value) $allowBlankLine = $true continue } if ($trimmedLine -match '^(?:-|\*)\s+(?<value>.+)$') { $lineList.Add($Matches.value) $allowBlankLine = $true continue } $lineList.Add($trimmedLine) $allowBlankLine = $true } while ($lineList.Count -gt 0 -and [string]::IsNullOrWhiteSpace($lineList[$lineList.Count - 1])) { $lineList.RemoveAt($lineList.Count - 1) } return ($lineList -join "`n").Trim() } # Source: src/public/Initialize-KeepAChangelogFile.ps1 function Initialize-KeepAChangelogFile { [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Position = 0)] [string]$Path = 'CHANGELOG.md', [Parameter(Mandatory)] [string]$RepositoryUrl, [string]$PreviousReleaseReference, [string[]]$SectionHeading = @('Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security'), [switch]$Force ) if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { throw 'RepositoryUrl is required.' } if ((Test-Path -LiteralPath $Path) -and -not $Force) { throw "File already exists at '$Path'. Use -Force to overwrite it." } $normalizedRepositoryUrl = $RepositoryUrl.TrimEnd('/') $hasPreviousReleaseReference = -not [string]::IsNullOrWhiteSpace($PreviousReleaseReference) $lineList = [System.Collections.Generic.List[string]]::new() $lineList.Add('# Changelog') $lineList.Add('') $lineList.Add('All notable changes to this project will be documented in this file.') $lineList.Add('') $lineList.Add('The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),') $lineList.Add('and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).') $lineList.Add('') $lineList.Add('## [Unreleased]') $lineList.Add('') foreach ($heading in $SectionHeading) { $lineList.Add("### $heading") $lineList.Add('') } if ($hasPreviousReleaseReference) { $lineList.Add("[Unreleased]: $normalizedRepositoryUrl/compare/$PreviousReleaseReference...HEAD") } $template = ($lineList -join "`n").TrimEnd() + "`n" if (-not $PSCmdlet.ShouldProcess($Path, 'Initialize Keep a Changelog template')) { return } Set-Content -LiteralPath $Path -Value $template -Encoding utf8 return [pscustomobject]@{ Path = $Path RepositoryUrl = $normalizedRepositoryUrl PreviousReleaseReference = if ($hasPreviousReleaseReference) { $PreviousReleaseReference } else { $null } } } # Source: src/public/Move-UnreleasedChangelog.ps1 function Move-UnreleasedChangelog { [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Position = 0)] [string]$Path = 'CHANGELOG.md', [Parameter(Mandatory)] [string]$Version, [string]$Date, [string]$RepositoryUrl ) if ([string]::IsNullOrWhiteSpace($Version)) { throw 'Version is required.' } if (-not (Test-Path -LiteralPath $Path)) { throw "Could not find CHANGELOG file at '$Path'." } $releaseDate = Resolve-KeepAChangelogReleaseDate -Date $Date $release = @{ Version = $Version Date = $releaseDate Tag = $Version } $text = Get-Content -LiteralPath $Path -Raw $result = Resolve-KeepAChangelogReleaseData -Text $text -Release $release -RepositoryUrl $RepositoryUrl $targetVersion = $result.Release.Version if (-not $PSCmdlet.ShouldProcess($Path, "Promote [Unreleased] to [$targetVersion]")) { return $result } Set-Content -LiteralPath $Path -Value $result.UpdatedText -Encoding utf8 return $result } # Source: src/public/Test-KeepAChangelogFile.ps1 function Test-KeepAChangelogFile { [CmdletBinding()] param( [Parameter(Position = 0)] [string]$Path = 'CHANGELOG.md', [switch]$ThrowOnError ) if (-not (Test-Path -LiteralPath $Path)) { throw "Could not find CHANGELOG file at '$Path'." } $text = Get-Content -LiteralPath $Path -Raw $result = Get-KeepAChangelogValidationResult -Text $text $result | Add-Member -NotePropertyName Path -NotePropertyValue $Path -Force if ($ThrowOnError -and -not $result.IsValid) { throw ($result.Errors -join ' ') } return $result } # Source: src/private/Assert-KeepAChangelogRelease.ps1 function Assert-KeepAChangelogRelease { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$Release ) $requiredKeyList = @('Version', 'Date', 'Tag') foreach ($requiredKey in $requiredKeyList) { if (-not $Release.ContainsKey($requiredKey)) { throw "Release.$requiredKey is required." } if ([string]::IsNullOrWhiteSpace([string]$Release[$requiredKey])) { throw "Release.$requiredKey is required." } } return [pscustomobject]@{ Version = [string]$Release.Version Date = [string]$Release.Date Tag = [string]$Release.Tag } } # Source: src/private/Get-ChangelogReleaseNotesBody.ps1 function Get-ChangelogReleaseNotesBody { [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Body ) $trimmedBody = $Body.Trim() if ([string]::IsNullOrWhiteSpace($trimmedBody)) { return '' } if ($trimmedBody -notmatch '(?m)^[ \t]*###\s+') { return $trimmedBody } $sectionPattern = '(?ms)^(?<heading>[ \t]*###\s+[^\r\n]+)\r?\n(?<content>.*?)(?=^[ \t]*###\s+|\z)' $sectionMatchList = [regex]::Matches($trimmedBody, $sectionPattern) $sectionTextList = [System.Collections.Generic.List[string]]::new() foreach ($sectionMatch in $sectionMatchList) { $heading = $sectionMatch.Groups['heading'].Value.TrimEnd() $content = $sectionMatch.Groups['content'].Value.Trim() if ([string]::IsNullOrWhiteSpace($content)) { continue } $sectionTextList.Add("$heading`n`n$content") } return ($sectionTextList -join "`n`n").Trim() } # Source: src/private/Get-ClearedUnreleasedBody.ps1 function Get-ClearedUnreleasedBody { [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Body ) $headingList = @( [regex]::Matches($Body, '(?m)^[ \t]*###\s+[^\r\n]+') | ForEach-Object { $_.Value.TrimEnd() } ) if (-not $headingList) { return '' } return ($headingList -join "`n`n").TrimEnd() } # Source: src/private/Get-KeepAChangelogValidationResult.ps1 function Get-KeepAChangelogValidationResult { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Text ) $errorList = [System.Collections.Generic.List[string]]::new() $parts = Split-KeepAChangelogText -Text $Text $unreleasedCompareLinkPrefix = $null $previousReleaseReference = $null if ($parts.Body -notmatch '(?ms)^##\s+\[Unreleased\]\s*\r?\n') { $errorList.Add('Could not find ## [Unreleased] section in CHANGELOG.md.') } $releaseHeadingLineList = @( [regex]::Matches($parts.Body, '(?m)^##\s+\[(?<label>[^\]]+)\].*$') | ForEach-Object { $_ } ) $releaseVersionList = [System.Collections.Generic.List[string]]::new() foreach ($releaseHeadingLine in $releaseHeadingLineList) { $label = $releaseHeadingLine.Groups['label'].Value $line = $releaseHeadingLine.Value.TrimEnd() if ($label -eq 'Unreleased') { continue } if ($line -notmatch '^## \[[^\]]+\] - \d{4}-\d{2}-\d{2}$') { $errorList.Add("Release section '$line' must use '## [<version>] - <date>' format.") continue } if ($releaseVersionList.Contains($label)) { $errorList.Add("Release section [$label] is duplicated.") continue } $releaseVersionList.Add($label) } $hasReferenceFooter = -not [string]::IsNullOrWhiteSpace($parts.Footer) if (-not $hasReferenceFooter -and $releaseVersionList.Count -gt 0) { $errorList.Add('CHANGELOG.md must end with reference links once releases exist.') } $referenceLabelCountMap = @{} if ($hasReferenceFooter) { foreach ($referenceMatch in [regex]::Matches($parts.Footer, '(?m)^\[(?<label>[^\]]+)\]:')) { $label = $referenceMatch.Groups['label'].Value if (-not $referenceLabelCountMap.ContainsKey($label)) { $referenceLabelCountMap[$label] = 0 } $referenceLabelCountMap[$label]++ } } foreach ($label in $referenceLabelCountMap.Keys) { if ($referenceLabelCountMap[$label] -gt 1) { $errorList.Add("Reference link [$label] is duplicated.") } } if ($hasReferenceFooter) { $unreleasedCompareLinkPattern = '(?m)^\[Unreleased\]:\s*(?<prefix>\S+/compare/)(?<from>.+?)\.\.\.HEAD\s*$' $unreleasedCompareLinkMatchList = [regex]::Matches($parts.Footer, $unreleasedCompareLinkPattern) if ($unreleasedCompareLinkMatchList.Count -eq 0) { $errorList.Add('Could not find an [Unreleased] compare link in CHANGELOG.md.') } if ($unreleasedCompareLinkMatchList.Count -gt 1) { $errorList.Add('CHANGELOG.md must contain exactly one [Unreleased] compare link.') } if ($unreleasedCompareLinkMatchList.Count -ge 1) { $unreleasedCompareLinkPrefix = $unreleasedCompareLinkMatchList[0].Groups['prefix'].Value $previousReleaseReference = $unreleasedCompareLinkMatchList[0].Groups['from'].Value } } return [pscustomobject]@{ IsValid = ($errorList.Count -eq 0) Errors = @($errorList.ToArray()) UnreleasedCompareLinkPrefix = $unreleasedCompareLinkPrefix PreviousReleaseReference = $previousReleaseReference ReleaseVersions = @($releaseVersionList.ToArray()) } } # Source: src/private/Get-UnreleasedCompareLinkMatch.ps1 function Get-UnreleasedCompareLinkMatch { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Text ) $pattern = '(?m)^\[Unreleased\]:\s*(?<prefix>\S+/compare/)(?<from>.+?)\.\.\.HEAD\s*$' $match = [regex]::Match($Text, $pattern) if (-not $match.Success) { throw 'Could not find an [Unreleased] compare link in CHANGELOG.md.' } return $match } # Source: src/private/Get-UnreleasedSectionMatch.ps1 function Get-UnreleasedSectionMatch { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Text ) $pattern = '(?ms)^##\s+\[Unreleased\]\s*\r?\n(?<body>.*?)(?=^##\s+\[|\z)' $match = [regex]::Match($Text, $pattern) if (-not $match.Success) { throw 'Could not find ## [Unreleased] section in CHANGELOG.md.' } return $match } # Source: src/private/Get-UpdatedChangelogReferenceFooter.ps1 function Get-UpdatedChangelogReferenceFooter { [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Footer, [Parameter(Mandatory)] [string]$ReleaseVersion, [Parameter(Mandatory)] [string]$ReleaseTag, [Parameter(Mandatory)] [string]$UnreleasedCompareLinkPrefix, [Parameter(Mandatory)] [AllowEmptyString()] [string]$PreviousReleaseReference, [string]$RepositoryUrl ) $orderedLabelList = [System.Collections.Generic.List[string]]::new() $linkMap = [ordered]@{} foreach ($referenceMatch in [regex]::Matches($Footer, '(?m)^\[(?<label>[^\]]+)\]:\s*(?<url>\S.*?)\s*$')) { $label = $referenceMatch.Groups['label'].Value $url = $referenceMatch.Groups['url'].Value.Trim() if (-not $linkMap.Contains($label)) { $orderedLabelList.Add($label) } $linkMap[$label] = $url } if (-not $linkMap.Contains('Unreleased')) { $orderedLabelList.Insert(0, 'Unreleased') } $normalizedRepositoryUrl = if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { $UnreleasedCompareLinkPrefix -replace '/compare/$', '' } else { $RepositoryUrl.TrimEnd('/') } $updatedUnreleasedLink = "$UnreleasedCompareLinkPrefix$ReleaseTag...HEAD" $newReleaseLink = if ([string]::IsNullOrWhiteSpace($PreviousReleaseReference)) { "$normalizedRepositoryUrl/releases/tag/$ReleaseTag" } else { "$UnreleasedCompareLinkPrefix$PreviousReleaseReference...$ReleaseTag" } $linkMap['Unreleased'] = $updatedUnreleasedLink if (-not $linkMap.Contains($ReleaseVersion)) { $unreleasedIndex = $orderedLabelList.IndexOf('Unreleased') $orderedLabelList.Insert($unreleasedIndex + 1, $ReleaseVersion) } $linkMap[$ReleaseVersion] = $newReleaseLink $footerLineList = foreach ($label in $orderedLabelList) { "[$label]: $($linkMap[$label])" } return [pscustomobject]@{ Footer = ($footerLineList -join "`n").TrimEnd() UpdatedUnreleasedLink = $updatedUnreleasedLink NewReleaseCompareLink = $newReleaseLink NewReleaseLink = $newReleaseLink } } # Source: src/private/Resolve-KeepAChangelogReleaseData.ps1 function Resolve-KeepAChangelogReleaseData { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Text, [Parameter(Mandatory)] [hashtable]$Release, [string]$RepositoryUrl ) $normalizedRelease = Assert-KeepAChangelogRelease -Release $Release $validation = Get-KeepAChangelogValidationResult -Text $Text if (-not $validation.IsValid) { throw "CHANGELOG.md is not valid. $($validation.Errors -join ' ')" } $parts = Split-KeepAChangelogText -Text $Text $unreleasedSectionMatch = Get-UnreleasedSectionMatch -Text $parts.Body $normalizedRepositoryUrl = if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { $null } else { $RepositoryUrl.TrimEnd('/') } $unreleasedCompareLinkPrefix = $validation.UnreleasedCompareLinkPrefix $previousReleaseReference = $validation.PreviousReleaseReference if ([string]::IsNullOrWhiteSpace($unreleasedCompareLinkPrefix)) { if ([string]::IsNullOrWhiteSpace($normalizedRepositoryUrl)) { throw 'RepositoryUrl is required for the first release when CHANGELOG.md has no [Unreleased] compare link.' } $unreleasedCompareLinkPrefix = "$normalizedRepositoryUrl/compare/" } elseif ([string]::IsNullOrWhiteSpace($normalizedRepositoryUrl)) { $normalizedRepositoryUrl = $unreleasedCompareLinkPrefix -replace '/compare/$', '' } $unreleasedBody = $unreleasedSectionMatch.Groups['body'].Value.Trim() $releaseNotesBody = Get-ChangelogReleaseNotesBody -Body $unreleasedBody $clearedUnreleasedBody = Get-ClearedUnreleasedBody -Body $unreleasedBody $unreleasedSectionLineList = [System.Collections.Generic.List[string]]::new() $unreleasedSectionLineList.Add('## [Unreleased]') if (-not [string]::IsNullOrWhiteSpace($clearedUnreleasedBody)) { $unreleasedSectionLineList.Add('') $unreleasedSectionLineList.Add($clearedUnreleasedBody) } $newReleaseSectionLineList = [System.Collections.Generic.List[string]]::new() $newReleaseSectionLineList.Add("## [$($normalizedRelease.Version)] - $($normalizedRelease.Date)") if (-not [string]::IsNullOrWhiteSpace($releaseNotesBody)) { $newReleaseSectionLineList.Add('') $newReleaseSectionLineList.Add($releaseNotesBody) } $beforeUnreleased = $parts.Body.Substring(0, $unreleasedSectionMatch.Index).TrimEnd() $afterUnreleasedStart = $unreleasedSectionMatch.Index + $unreleasedSectionMatch.Length $afterUnreleased = $parts.Body.Substring($afterUnreleasedStart).TrimStart("`r", "`n") $bodySectionList = [System.Collections.Generic.List[string]]::new() foreach ($section in @( $beforeUnreleased ($unreleasedSectionLineList -join "`n") ($newReleaseSectionLineList -join "`n") $afterUnreleased )) { if ([string]::IsNullOrWhiteSpace($section)) { continue } $bodySectionList.Add($section.TrimEnd()) } $updatedBody = ($bodySectionList -join "`n`n").TrimEnd() $updatedFooterData = Get-UpdatedChangelogReferenceFooter ` -Footer $parts.Footer ` -ReleaseVersion $normalizedRelease.Version ` -ReleaseTag $normalizedRelease.Tag ` -UnreleasedCompareLinkPrefix $unreleasedCompareLinkPrefix ` -PreviousReleaseReference $previousReleaseReference ` -RepositoryUrl $normalizedRepositoryUrl $updatedText = ($updatedBody + "`n`n" + $updatedFooterData.Footer).TrimEnd() + "`n" $newReleaseSection = ($newReleaseSectionLineList -join "`n").TrimEnd() return [pscustomobject]@{ Release = $normalizedRelease UnreleasedCompareLinkPrefix = $unreleasedCompareLinkPrefix PreviousReleaseReference = $previousReleaseReference UnreleasedBody = $unreleasedBody ReleaseNotesBody = $releaseNotesBody ClearedUnreleasedBody = $clearedUnreleasedBody NewReleaseSection = $newReleaseSection UpdatedUnreleasedLink = $updatedFooterData.UpdatedUnreleasedLink NewReleaseCompareLink = $updatedFooterData.NewReleaseCompareLink NewReleaseLink = $updatedFooterData.NewReleaseLink TagMessageText = Convert-ChangelogReleaseNotesToTagMessage -ReleaseNotes $releaseNotesBody UpdatedText = $updatedText } } # Source: src/private/Resolve-KeepAChangelogReleaseDate.ps1 function Resolve-KeepAChangelogReleaseDate { [CmdletBinding()] param( [AllowEmptyString()] [string]$Date ) if ([string]::IsNullOrWhiteSpace($Date)) { return (Get-Date).ToString('yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture) } $parsedDate = [datetime]::MinValue $isValidDate = [datetime]::TryParseExact( $Date, 'yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None, [ref]$parsedDate ) if (-not $isValidDate) { throw "Date must use yyyy-MM-dd format. Received: '$Date'." } return $parsedDate.ToString('yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture) } # Source: src/private/Split-KeepAChangelogText.ps1 function Split-KeepAChangelogText { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Text ) $footerMatch = [regex]::Match($Text, '(?ms)(?<footer>(?:^\[[^\]]+\]:[^\r\n]*(?:\r?\n|$))+)\s*\z') if ($footerMatch.Success) { return [pscustomobject]@{ Body = $Text.Substring(0, $footerMatch.Index).TrimEnd() Footer = $footerMatch.Groups['footer'].Value.TrimEnd() } } return [pscustomobject]@{ Body = $Text.TrimEnd() Footer = '' } } |