maksit-core/src/scripts/Generate-Changelog.ps1

453 lines
18 KiB
PowerShell

<#
.SYNOPSIS
AI-assisted changelog generation and license year update.
.DESCRIPTION
Generates changelog entries from uncommitted changes using a 3-pass LLM pipeline:
1. Analyze: Convert changes to changelog items
2. Consolidate: Merge similar items, remove duplicates
3. Format: Structure as Keep a Changelog format
Also updates LICENSE.md copyright year if needed.
Optional RAG pre-processing clusters related changes using embeddings.
All configuration is in changelogsettings.json.
.PARAMETER DryRun
Show what would be generated without making changes.
Enables debug output showing intermediate LLM results.
Does not modify CHANGELOG.md or LICENSE.md.
.USAGE
Generate changelog and update license:
.\Generate-Changelog.ps1
Dry run (preview without changes):
.\Generate-Changelog.ps1 -DryRun
.NOTES
Requires:
- Ollama running locally (configured in changelogsettings.json)
- OllamaClient.psm1 and BuildUtils.psm1 modules
Configuration (changelogsettings.json):
- csprojPath: Path to .csproj file for version
- outputFile: Path to CHANGELOG.md
- licensePath: Path to LICENSE.md
- debug: Enable debug output
- models: LLM models for each pass
- prompts: Prompt templates
#>
param(
[switch]$DryRun
)
# ==============================================================================
# PATH CONFIGURATION
# ==============================================================================
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = git rev-parse --show-toplevel 2>$null
if (-not $repoRoot) {
# Fallback if not in git repo - go up two levels (scripts -> src -> repo root)
$repoRoot = Split-Path -Parent (Split-Path -Parent $scriptDir)
}
$repoRoot = $repoRoot.Trim()
# Solution directory is one level up from scripts
$solutionDir = Split-Path -Parent $scriptDir
# ==============================================================================
# LOAD SETTINGS
# ==============================================================================
$settingsPath = Join-Path $scriptDir "changelogsettings.json"
if (-not (Test-Path $settingsPath)) {
Write-Error "Settings file not found: $settingsPath"
exit 1
}
$settings = Get-Content $settingsPath -Raw | ConvertFrom-Json
Write-Host "Loaded settings from changelogsettings.json" -ForegroundColor Gray
# Resolve paths relative to script location
$CsprojPath = if ($settings.changelog.csprojPath) {
[System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.changelog.csprojPath))
}
else {
Join-Path $solutionDir "MaksIT.Core\MaksIT.Core.csproj"
}
$OutputFile = if ($settings.changelog.outputFile) {
[System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.changelog.outputFile))
}
else {
$null
}
$LicensePath = if ($settings.changelog.licensePath) {
[System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.changelog.licensePath))
}
else {
$null
}
# ==============================================================================
# LICENSE YEAR UPDATE
# ==============================================================================
if ($LicensePath -and (Test-Path $LicensePath)) {
Write-Host "Checking LICENSE.md copyright year..." -ForegroundColor Gray
$currentYear = (Get-Date).Year
$licenseContent = Get-Content $LicensePath -Raw
# Match pattern: "Copyright (c) YYYY - YYYY" and update end year
$licensePattern = "(Copyright \(c\) \d{4}\s*-\s*)(\d{4})"
if ($licenseContent -match $licensePattern) {
$existingEndYear = [int]$Matches[2]
if ($existingEndYear -lt $currentYear) {
if ($DryRun) {
Write-Host "[DryRun] LICENSE.md needs update: $existingEndYear -> $currentYear" -ForegroundColor Yellow
}
else {
Write-Host "Updating LICENSE.md copyright year: $existingEndYear -> $currentYear" -ForegroundColor Cyan
$updatedContent = $licenseContent -replace $licensePattern, "`${1}$currentYear"
Set-Content -Path $LicensePath -Value $updatedContent -NoNewline
Write-Host "LICENSE.md updated." -ForegroundColor Green
}
}
else {
Write-Host "LICENSE.md copyright year is current ($existingEndYear)." -ForegroundColor Gray
}
}
}
# ==============================================================================
# IMPORT MODULES
# ==============================================================================
# Import build utilities
$buildUtilsPath = Join-Path $scriptDir "BuildUtils.psm1"
if (Test-Path $buildUtilsPath) {
Import-Module $buildUtilsPath -Force
}
else {
Write-Error "BuildUtils.psm1 not found: $buildUtilsPath"
exit 1
}
# Import Ollama client
$ollamaModulePath = Join-Path $scriptDir "OllamaClient.psm1"
if (-not $settings.ollama.enabled) {
Write-Error "Ollama is disabled in changelogsettings.json"
exit 1
}
if (-not (Test-Path $ollamaModulePath)) {
Write-Error "OllamaClient.psm1 not found: $ollamaModulePath"
exit 1
}
Import-Module $ollamaModulePath -Force
Set-OllamaConfig -ApiUrl $settings.ollama.apiUrl `
-DefaultContextWindow $settings.ollama.defaultContextWindow `
-DefaultTimeout $settings.ollama.defaultTimeout
# ==============================================================================
# CHANGELOG CONFIGURATION
# ==============================================================================
$clSettings = $settings.changelog
$changelogConfig = @{
Debug = if ($DryRun) { $true } else { $clSettings.debug }
EnableRAG = $clSettings.enableRAG
SimilarityThreshold = $clSettings.similarityThreshold
FileExtension = $clSettings.fileExtension
ExcludePatterns = if ($clSettings.excludePatterns) { @($clSettings.excludePatterns) } else { @() }
Models = @{
Analyze = @{
Name = $clSettings.models.analyze.name
Context = $clSettings.models.analyze.context
MaxTokens = if ($null -ne $clSettings.models.analyze.maxTokens) { $clSettings.models.analyze.maxTokens } else { 0 }
}
Reason = @{
Name = $clSettings.models.reason.name
Context = $clSettings.models.reason.context
MaxTokens = if ($null -ne $clSettings.models.reason.maxTokens) { $clSettings.models.reason.maxTokens } else { 0 }
Temperature = if ($clSettings.models.reason.temperature) { $clSettings.models.reason.temperature } else { 0.1 }
}
Write = @{
Name = $clSettings.models.write.name
Context = $clSettings.models.write.context
MaxTokens = if ($null -ne $clSettings.models.write.maxTokens) { $clSettings.models.write.maxTokens } else { 0 }
}
Embed = @{ Name = $clSettings.models.embed.name }
}
Prompts = @{
Analyze = if ($clSettings.prompts.analyze) {
if ($clSettings.prompts.analyze -is [array]) { $clSettings.prompts.analyze -join "`n" } else { $clSettings.prompts.analyze }
} else { "Convert changes to changelog: {{changes}}" }
Reason = if ($clSettings.prompts.reason) {
if ($clSettings.prompts.reason -is [array]) { $clSettings.prompts.reason -join "`n" } else { $clSettings.prompts.reason }
} else { "Consolidate: {{input}}" }
Format = if ($clSettings.prompts.format) {
if ($clSettings.prompts.format -is [array]) { $clSettings.prompts.format -join "`n" } else { $clSettings.prompts.format }
} else { "Format as changelog: {{items}}" }
}
}
# ==============================================================================
# AI CHANGELOG GENERATION FUNCTION
# ==============================================================================
function Get-AIChangelogSuggestion {
param(
[Parameter(Mandatory)][string]$Changes,
[Parameter(Mandatory)][string]$Version
)
$cfg = $script:changelogConfig
$debug = $cfg.Debug
# === RAG PRE-PROCESSING ===
$processedChanges = $Changes
if ($cfg.EnableRAG) {
Write-Host " RAG Pre-processing ($($cfg.Models.Embed.Name))..." -ForegroundColor Cyan
$changeArray = $Changes -split "`n" | Where-Object { $_.Trim() -ne "" }
if ($changeArray.Length -gt 3) {
Write-Host " RAG: Embedding $($changeArray.Length) changes..." -ForegroundColor Gray
$clusters = Group-TextsByEmbedding -Model $cfg.Models.Embed.Name -Texts $changeArray -SimilarityThreshold $cfg.SimilarityThreshold
Write-Host " RAG: Reduced to $($clusters.Length) groups" -ForegroundColor Green
# Format clusters
$grouped = @()
foreach ($cluster in $clusters) {
if ($cluster.Length -eq 1) {
$grouped += $cluster[0]
}
else {
$grouped += "[RELATED CHANGES]`n" + ($cluster -join "`n") + "`n[/RELATED CHANGES]"
}
}
$processedChanges = $grouped -join "`n"
if ($debug) {
Write-Host "`n [DEBUG] RAG grouped changes:" -ForegroundColor Magenta
Write-Host $processedChanges -ForegroundColor DarkGray
Write-Host ""
}
}
}
# === PASS 1: Analyze changes ===
$m1 = $cfg.Models.Analyze
Write-Host " Pass 1/3: Analyzing ($($m1.Name), ctx:$($m1.Context))..." -ForegroundColor Gray
$prompt1 = $cfg.Prompts.Analyze -replace '\{\{changes\}\}', $processedChanges
$pass1 = Invoke-OllamaPrompt -Model $m1.Name -ContextWindow $m1.Context -MaxTokens $m1.MaxTokens -Prompt $prompt1
if (-not $pass1) { return $null }
if ($debug) { Write-Host "`n [DEBUG] Pass 1 output:" -ForegroundColor Magenta; Write-Host $pass1 -ForegroundColor DarkGray; Write-Host "" }
# === PASS 2: Consolidate ===
$m2 = $cfg.Models.Reason
Write-Host " Pass 2/3: Consolidating ($($m2.Name), ctx:$($m2.Context))..." -ForegroundColor Gray
$prompt2 = $cfg.Prompts.Reason -replace '\{\{input\}\}', $pass1
$pass2 = Invoke-OllamaPrompt -Model $m2.Name -ContextWindow $m2.Context -MaxTokens $m2.MaxTokens -Temperature $m2.Temperature -Prompt $prompt2
if (-not $pass2) { return $pass1 }
if ($pass2 -match "</think>") { $pass2 = ($pass2 -split "</think>")[-1].Trim() }
if ($debug) { Write-Host "`n [DEBUG] Pass 2 output:" -ForegroundColor Magenta; Write-Host $pass2 -ForegroundColor DarkGray; Write-Host "" }
# === PASS 3: Format ===
$m3 = $cfg.Models.Write
Write-Host " Pass 3/3: Formatting ($($m3.Name), ctx:$($m3.Context))..." -ForegroundColor Gray
$prompt3 = $cfg.Prompts.Format -replace '\{\{items\}\}', $pass2
$pass3 = Invoke-OllamaPrompt -Model $m3.Name -ContextWindow $m3.Context -MaxTokens $m3.MaxTokens -Prompt $prompt3
if (-not $pass3) { return $pass2 }
if ($debug) { Write-Host "`n [DEBUG] Pass 3 output:" -ForegroundColor Magenta; Write-Host $pass3 -ForegroundColor DarkGray; Write-Host "" }
# Clean up preamble
if ($pass3 -match "(### Added|### Changed|### Fixed|### Removed)") {
$pass3 = $pass3.Substring($pass3.IndexOf($Matches[0]))
}
# Clean up headers - remove any extra text after "### Added" etc.
$pass3 = $pass3 -replace '(### Added)[^\n]*', '### Added'
$pass3 = $pass3 -replace '(### Changed)[^\n]*', '### Changed'
$pass3 = $pass3 -replace '(### Fixed)[^\n]*', '### Fixed'
$pass3 = $pass3 -replace '(### Removed)[^\n]*', '### Removed'
# Clean up formatting: remove extra blank lines, normalize line endings
$pass3 = $pass3 -replace "`r`n", "`n" # Normalize to LF
$pass3 = $pass3 -replace "(\n\s*){3,}", "`n`n" # Max 1 blank line
$pass3 = $pass3 -replace "- (.+)\n\n- ", "- `$1`n- " # No blank between items
$pass3 = $pass3 -replace "\n{2,}(### )", "`n`n`$1" # One blank before headers
# Remove empty sections (e.g., "### Fixed\n- (No items)" or "### Removed\n\n###")
$pass3 = $pass3 -replace "### \w+\s*\n-\s*\(No items\)\s*\n?", ""
$pass3 = $pass3 -replace "### \w+\s*\n\s*\n(?=###|$)", ""
$pass3 = $pass3.Trim()
return $pass3
}
# ==============================================================================
# MAIN EXECUTION
# ==============================================================================
Write-Host ""
Write-Host "==================================================" -ForegroundColor Cyan
Write-Host "AI CHANGELOG GENERATOR" -ForegroundColor Cyan
Write-Host "==================================================" -ForegroundColor Cyan
Write-Host ""
# Check Ollama availability
if (-not (Test-OllamaAvailable)) {
Write-Error "Ollama is not available. Start Ollama and try again."
exit 1
}
Write-Host "Ollama connected: $($settings.ollama.apiUrl)" -ForegroundColor Green
Write-Host "Models: $($changelogConfig.Models.Analyze.Name) | $($changelogConfig.Models.Reason.Name) | $($changelogConfig.Models.Embed.Name)" -ForegroundColor Gray
Write-Host ""
# Get version from csproj
if (-not (Test-Path $CsprojPath)) {
Write-Error "Csproj file not found: $CsprojPath"
exit 1
}
[xml]$csproj = Get-Content $CsprojPath
$Version = $csproj.Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1
Write-Host "Version: $Version" -ForegroundColor White
# Filter function for excluding test files
$excludePatterns = $changelogConfig.ExcludePatterns
function Test-Excluded {
param([string]$Item)
foreach ($pattern in $excludePatterns) {
if ($Item -match [regex]::Escape($pattern)) { return $true }
}
return $false
}
# Get committed changes for this version (analyzed diffs)
$committedChanges = Get-CommitChangesAnalysis -Version $Version -CsprojPath $CsprojPath -FileFilter $changelogConfig.FileExtension
$filteredCommitted = $committedChanges | Where-Object { -not (Test-Excluded $_) }
# Get uncommitted changes (staged, modified, new, deleted)
$uncommitted = Get-UncommittedChanges -FileFilter $changelogConfig.FileExtension
$filteredUncommitted = $uncommitted.Summary | Where-Object { -not (Test-Excluded $_) }
# Combine all changes
$allChanges = @()
if ($filteredCommitted.Count -gt 0) { $allChanges += $filteredCommitted }
if ($filteredUncommitted.Count -gt 0) { $allChanges += $filteredUncommitted }
if ($allChanges.Count -eq 0) {
Write-Host "No changes found for version $Version (excluding tests)" -ForegroundColor Yellow
exit 0
}
$changeLog = $allChanges -join "`n"
Write-Host "Found $($filteredCommitted.Count) committed changes" -ForegroundColor Gray
Write-Host "Found $($filteredUncommitted.Count) uncommitted changes" -ForegroundColor Gray
Write-Host ""
# Generate changelog from uncommitted changes
$suggestion = Get-AIChangelogSuggestion -Changes $changeLog -Version $Version
if ($suggestion) {
$fullEntry = "## v$Version`n`n$suggestion"
Write-Host ""
Write-Host "==========================================" -ForegroundColor Green
Write-Host "AI SUGGESTED CHANGELOG ENTRY" -ForegroundColor Green
Write-Host "==========================================" -ForegroundColor Green
Write-Host ""
Write-Host $fullEntry -ForegroundColor White
Write-Host ""
Write-Host "==========================================" -ForegroundColor Green
# Update changelog file if specified and not in DryRun mode
if ($OutputFile -and -not $DryRun) {
if (Test-Path $OutputFile) {
# Read existing content
$existingContent = Get-Content $OutputFile -Raw
# Check if this version already exists
if ($existingContent -match "## v$Version\b") {
Write-Host ""
Write-Host "WARNING: Version $Version already exists in $OutputFile" -ForegroundColor Yellow
Write-Host "Skipping file update. Review and update manually if needed." -ForegroundColor Yellow
}
else {
# Find insertion point (after header, before first version entry)
# Header typically ends before first "## v" line
if ($existingContent -match '(?s)(^.*?)(\r?\n)(## v)') {
$header = $Matches[1]
$newline = $Matches[2]
$rest = $existingContent.Substring($header.Length + $newline.Length)
$newContent = $header + "`n`n" + $fullEntry + "`n`n" + $rest
}
else {
# No existing version entries - append after content
$newContent = $existingContent.TrimEnd() + "`n`n" + $fullEntry + "`n"
}
# Normalize multiple blank lines to max 2
$newContent = $newContent -replace "(\r?\n){3,}", "`n`n"
$newContent | Out-File -FilePath $OutputFile -Encoding utf8 -NoNewline
Write-Host ""
Write-Host "Updated: $OutputFile" -ForegroundColor Cyan
}
}
else {
# Create new file with header
$newContent = @"
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
$fullEntry
"@
$newContent | Out-File -FilePath $OutputFile -Encoding utf8
Write-Host ""
Write-Host "Created: $OutputFile" -ForegroundColor Cyan
}
}
elseif ($OutputFile -and $DryRun) {
Write-Host ""
Write-Host "[DryRun] Would update: $OutputFile" -ForegroundColor Yellow
}
Write-Host ""
if ($DryRun) {
Write-Host "DryRun complete. No files were modified." -ForegroundColor Yellow
}
else {
Write-Host "Review the changelog entry, then commit." -ForegroundColor Yellow
}
}
else {
Write-Error "AI changelog generation failed"
exit 1
}
Write-Host ""