diff --git a/eng/common-tests/ChangeLog-Operations.Tests.ps1 b/eng/common-tests/ChangeLog-Operations.Tests.ps1 new file mode 100644 index 00000000000..2a406e80aff --- /dev/null +++ b/eng/common-tests/ChangeLog-Operations.Tests.ps1 @@ -0,0 +1,227 @@ +Import-Module Pester + +BeforeAll { + . $PSScriptRoot/../common/scripts/ChangeLog-Operations.ps1 +} + +Describe "Parse-ChangelogContent" { + It "Should parse changelog text with multiple sections" { + $changelogText = "### Breaking Changes`n`n- Removed deprecated API ``oldMethod()```n- Changed return type of ``getData()```n`n### Features Added`n`n- Added new ``newMethod()`` API`n- Added support for async operations`n`n### Bugs Fixed`n`n- Fixed null pointer exception in ``processData()``" + + $result = Parse-ChangelogContent -ChangelogText $changelogText -InitialAtxHeader "#" + + $result | Should -Not -BeNullOrEmpty + $result.ReleaseContent | Should -Not -BeNullOrEmpty + $result.Sections | Should -Not -BeNullOrEmpty + $result.Sections.Keys | Should -HaveCount 3 + $result.Sections.ContainsKey("Breaking Changes") | Should -BeTrue + $result.Sections.ContainsKey("Features Added") | Should -BeTrue + $result.Sections.ContainsKey("Bugs Fixed") | Should -BeTrue + } + + It "Should handle single section changelog" { + $changelogText = "### Features Added`n`n- Added new feature X`n- Added new feature Y" + + $result = Parse-ChangelogContent -ChangelogText $changelogText -InitialAtxHeader "#" + + $result | Should -Not -BeNullOrEmpty + $result.Sections.Keys | Should -HaveCount 1 + $result.Sections.ContainsKey("Features Added") | Should -BeTrue + $result.Sections["Features Added"] | Should -Contain "" + $result.Sections["Features Added"] | Should -Contain "- Added new feature X" + $result.Sections["Features Added"] | Should -Contain "- Added new feature Y" + } + + It "Should handle empty changelog text sections with content before first section" { + $changelogText = "Some introductory text`n`n### Breaking Changes`n`n- Change 1" + + $result = Parse-ChangelogContent -ChangelogText $changelogText -InitialAtxHeader "#" + + $result | Should -Not -BeNullOrEmpty + $result.Sections.Keys | Should -HaveCount 1 + # The intro text should be in ReleaseContent but not in any section + $result.ReleaseContent | Should -Contain "Some introductory text" + } + + It "Should respect InitialAtxHeader parameter" { + # With ## as initial header, section headers are #### + $changelogText = "#### Breaking Changes`n`n- Some breaking change" + + $result = Parse-ChangelogContent -ChangelogText $changelogText -InitialAtxHeader "##" + + $result | Should -Not -BeNullOrEmpty + $result.Sections.Keys | Should -HaveCount 1 + $result.Sections.ContainsKey("Breaking Changes") | Should -BeTrue + } + + It "Should return empty sections when no section headers found" { + $changelogText = "Just some text without any section headers`nAnd another line" + + $result = Parse-ChangelogContent -ChangelogText $changelogText -InitialAtxHeader "#" + + $result | Should -Not -BeNullOrEmpty + $result.Sections.Keys | Should -HaveCount 0 + $result.ReleaseContent | Should -Not -BeNullOrEmpty + } + + It "Should handle Windows-style line endings" { + $changelogText = "### Features Added`r`n`r`n- Feature 1`r`n- Feature 2" + + $result = Parse-ChangelogContent -ChangelogText $changelogText -InitialAtxHeader "#" + + $result | Should -Not -BeNullOrEmpty + $result.Sections.ContainsKey("Features Added") | Should -BeTrue + } + + It "Should handle Unix-style line endings" { + $changelogText = "### Features Added`n`n- Feature 1`n- Feature 2" + + $result = Parse-ChangelogContent -ChangelogText $changelogText -InitialAtxHeader "#" + + $result | Should -Not -BeNullOrEmpty + $result.Sections.ContainsKey("Features Added") | Should -BeTrue + } +} + +Describe "Set-ChangeLogEntryContent" { + It "Should update changelog entry with new content" { + # Create a mock changelog entry + $entry = [PSCustomObject]@{ + ReleaseVersion = "1.0.0" + ReleaseStatus = "(Unreleased)" + ReleaseTitle = "## 1.0.0 (Unreleased)" + ReleaseContent = @() + Sections = @{} + } + + $newContent = "### Features Added`n`n- Added new feature A`n- Added new feature B" + + $result = Set-ChangeLogEntryContent -ChangeLogEntry $entry -NewContent $newContent -InitialAtxHeader "#" + + $result | Should -Not -BeNullOrEmpty + $result.ReleaseContent | Should -Not -BeNullOrEmpty + $result.Sections.ContainsKey("Features Added") | Should -BeTrue + $result.Sections["Features Added"] | Should -Contain "- Added new feature A" + $result.Sections["Features Added"] | Should -Contain "- Added new feature B" + } + + It "Should replace existing content in changelog entry" { + # Create a mock changelog entry with existing content + $entry = [PSCustomObject]@{ + ReleaseVersion = "1.0.0" + ReleaseStatus = "(Unreleased)" + ReleaseTitle = "## 1.0.0 (Unreleased)" + ReleaseContent = @("", "### Old Section", "", "- Old content") + Sections = @{ + "Old Section" = @("", "- Old content") + } + } + + $newContent = "### Breaking Changes`n`n- New breaking change" + + $result = Set-ChangeLogEntryContent -ChangeLogEntry $entry -NewContent $newContent -InitialAtxHeader "#" + + $result | Should -Not -BeNullOrEmpty + $result.Sections.ContainsKey("Breaking Changes") | Should -BeTrue + $result.Sections.ContainsKey("Old Section") | Should -BeFalse + } + + It "Should use default InitialAtxHeader when not specified" { + $entry = [PSCustomObject]@{ + ReleaseVersion = "1.0.0" + ReleaseStatus = "(Unreleased)" + ReleaseTitle = "## 1.0.0 (Unreleased)" + ReleaseContent = @() + Sections = @{} + } + + $newContent = "### Features Added`n`n- Feature 1" + + $result = Set-ChangeLogEntryContent -ChangeLogEntry $entry -NewContent $newContent + + $result | Should -Not -BeNullOrEmpty + $result.Sections.ContainsKey("Features Added") | Should -BeTrue + } +} + +Describe "Integration: Update Changelog Entry and Write Back" { + BeforeEach { + # Create a temporary changelog file for integration testing + $script:tempChangelogPath = Join-Path ([System.IO.Path]::GetTempPath()) "CHANGELOG_$([System.Guid]::NewGuid().ToString()).md" + + $initialChangelog = @" +# Release History + +## 1.0.0 (Unreleased) + +### Features Added + +### Breaking Changes + +### Bugs Fixed + +## 0.9.0 (2024-01-15) + +### Features Added + +- Initial release feature +"@ + Set-Content -Path $script:tempChangelogPath -Value $initialChangelog + } + + AfterEach { + if (Test-Path $script:tempChangelogPath) { + Remove-Item -Path $script:tempChangelogPath -Force -ErrorAction SilentlyContinue + } + } + + It "Should update changelog file through the full workflow" { + # Get existing entries + $entries = Get-ChangeLogEntries -ChangeLogLocation $script:tempChangelogPath + $entries | Should -Not -BeNullOrEmpty + + # Get the unreleased entry + $unreleasedEntry = $entries["1.0.0"] + $unreleasedEntry | Should -Not -BeNullOrEmpty + $unreleasedEntry.ReleaseStatus | Should -Be "(Unreleased)" + + # Prepare new content + $newContent = "### Breaking Changes`n`n- Removed deprecated method ``oldApi()```n- Changed signature of ``processData()```n`n### Features Added`n`n- Added async support for all operations`n- Added new ``streamData()`` method" + + # Update the entry content + Set-ChangeLogEntryContent -ChangeLogEntry $unreleasedEntry -NewContent $newContent -InitialAtxHeader $entries.InitialAtxHeader + + # Write back to file + Set-ChangeLogContent -ChangeLogLocation $script:tempChangelogPath -ChangeLogEntries $entries + + # Verify the file was updated correctly + $updatedContent = Get-Content -Path $script:tempChangelogPath -Raw + $updatedContent | Should -Match "Removed deprecated method" + $updatedContent | Should -Match "Added async support" + $updatedContent | Should -Match "streamData" + + # Verify the structure is still valid + $updatedEntries = Get-ChangeLogEntries -ChangeLogLocation $script:tempChangelogPath + $updatedEntries | Should -Not -BeNullOrEmpty + $updatedEntries["1.0.0"].Sections.ContainsKey("Breaking Changes") | Should -BeTrue + $updatedEntries["1.0.0"].Sections.ContainsKey("Features Added") | Should -BeTrue + } + + It "Should preserve other versions when updating one version" { + $entries = Get-ChangeLogEntries -ChangeLogLocation $script:tempChangelogPath + + $unreleasedEntry = $entries["1.0.0"] + $newContent = "### Features Added`n`n- New feature" + + Set-ChangeLogEntryContent -ChangeLogEntry $unreleasedEntry -NewContent $newContent -InitialAtxHeader $entries.InitialAtxHeader + Set-ChangeLogContent -ChangeLogLocation $script:tempChangelogPath -ChangeLogEntries $entries + + # Verify the old version is still present + $updatedContent = Get-Content -Path $script:tempChangelogPath -Raw + $updatedContent | Should -Match "0.9.0" + $updatedContent | Should -Match "Initial release feature" + + $updatedEntries = Get-ChangeLogEntries -ChangeLogLocation $script:tempChangelogPath + $updatedEntries["0.9.0"] | Should -Not -BeNullOrEmpty + } +} diff --git a/eng/common/scripts/ChangeLog-Operations.ps1 b/eng/common/scripts/ChangeLog-Operations.ps1 index c5c4f5ed20c..170157ff511 100644 --- a/eng/common/scripts/ChangeLog-Operations.ps1 +++ b/eng/common/scripts/ChangeLog-Operations.ps1 @@ -8,6 +8,15 @@ $CHANGELOG_UNRELEASED_STATUS = "(Unreleased)" $CHANGELOG_DATE_FORMAT = "yyyy-MM-dd" $RecommendedSectionHeaders = @("Features Added", "Breaking Changes", "Bugs Fixed", "Other Changes") +# Helper function to build the section header regex pattern +function Get-SectionHeaderRegex { + param( + [Parameter(Mandatory = $true)] + [string]$InitialAtxHeader + ) + return "^${InitialAtxHeader}${SECTION_HEADER_REGEX_SUFFIX}" +} + # Returns a Collection of changeLogEntry object containing changelog info for all versions present in the gived CHANGELOG function Get-ChangeLogEntries { param ( @@ -49,7 +58,7 @@ function Get-ChangeLogEntriesFromContent { $initialAtxHeader = $matches["HeaderLevel"] } - $sectionHeaderRegex = "^${initialAtxHeader}${SECTION_HEADER_REGEX_SUFFIX}" + $sectionHeaderRegex = Get-SectionHeaderRegex -InitialAtxHeader $initialAtxHeader $changeLogEntries | Add-Member -NotePropertyName "InitialAtxHeader" -NotePropertyValue $initialAtxHeader $releaseTitleAtxHeader = $initialAtxHeader + "#" $headerLines = @() @@ -301,7 +310,7 @@ function Remove-EmptySections { $InitialAtxHeader = "#" ) - $sectionHeaderRegex = "^${InitialAtxHeader}${SECTION_HEADER_REGEX_SUFFIX}" + $sectionHeaderRegex = Get-SectionHeaderRegex -InitialAtxHeader $InitialAtxHeader $releaseContent = $ChangeLogEntry.ReleaseContent if ($releaseContent.Count -gt 0) @@ -460,3 +469,135 @@ function Confirm-ChangeLogForRelease { } return $ChangeLogStatus.IsValid } + +function Parse-ChangelogContent { + <# + .SYNOPSIS + Parses raw changelog text into structured content with sections. + + .DESCRIPTION + Takes raw changelog text and parses it into structured arrays containing + ReleaseContent (all lines) and Sections (organized by section headers). + This function only generates content structure without modifying any files. + + .PARAMETER ChangelogText + The new changelog text containing sections (e.g., "### Breaking Changes", "### Features Added"). + + .PARAMETER InitialAtxHeader + The markdown header level used in the changelog (e.g., "#" for H1, "##" for H2). + Defaults to "#". + + .OUTPUTS + PSCustomObject with ReleaseContent and Sections properties. + + .EXAMPLE + $content = Parse-ChangelogContent -ChangelogText $changelogText -InitialAtxHeader "#" + $content.ReleaseContent # Array of all lines + $content.Sections # Hashtable of section name to content lines + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ChangelogText, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$InitialAtxHeader = "#" + ) + + Write-Verbose "Parsing changelog text into structured content..." + + # Parse the new changelog content into lines + $changelogLines = $ChangelogText -split "`r?`n" + + # Initialize content structure + $releaseContent = @() + $sections = @{} + + # Add an empty line after the version header + $releaseContent += "" + + # Parse the changelog content + # InitialAtxHeader represents the markdown header level (e.g., "#" for H1, "##" for H2) + # Section headers are two levels deeper than the changelog title + # (e.g., "### Breaking Changes" if InitialAtxHeader is "#") + $currentSection = $null + $sectionHeaderRegex = Get-SectionHeaderRegex -InitialAtxHeader $InitialAtxHeader + + foreach ($line in $changelogLines) { + if ($line.Trim() -match $sectionHeaderRegex) { + $currentSection = $matches["sectionName"].Trim() + $sections[$currentSection] = @() + $releaseContent += $line + Write-Verbose " Found section: $currentSection" + } + elseif ($currentSection) { + $sections[$currentSection] += $line + $releaseContent += $line + } + else { + $releaseContent += $line + } + } + + Write-Verbose " Parsed $($sections.Count) section(s)" + + # Return structured content + return [PSCustomObject]@{ + ReleaseContent = $releaseContent + Sections = $sections + } +} + +function Set-ChangeLogEntryContent { + <# + .SYNOPSIS + Updates a changelog entry with new content. + + .DESCRIPTION + Takes a changelog entry object and new changelog text, parses the text into + structured content, and updates the entry's ReleaseContent and Sections properties. + + .PARAMETER ChangeLogEntry + The changelog entry object to update (from Get-ChangeLogEntries). + + .PARAMETER NewContent + The new changelog text containing sections. + + .PARAMETER InitialAtxHeader + The markdown header level used in the changelog. Defaults to "#". + + .OUTPUTS + The updated changelog entry object. + + .EXAMPLE + $entries = Get-ChangeLogEntries -ChangeLogLocation $changelogPath + $entry = $entries["1.0.0"] + Set-ChangeLogEntryContent -ChangeLogEntry $entry -NewContent $newText -InitialAtxHeader $entries.InitialAtxHeader + Set-ChangeLogContent -ChangeLogLocation $changelogPath -ChangeLogEntries $entries + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [PSCustomObject]$ChangeLogEntry, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$NewContent, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$InitialAtxHeader = "#" + ) + + # Parse the new content into structured format + $parsedContent = Parse-ChangelogContent -ChangelogText $NewContent -InitialAtxHeader $InitialAtxHeader + + # Update the entry with the parsed content + $ChangeLogEntry.ReleaseContent = $parsedContent.ReleaseContent + $ChangeLogEntry.Sections = $parsedContent.Sections + + return $ChangeLogEntry +}