From 53b51223e4e22e6bbb02d2d83b48db16a38999b8 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Fri, 13 Feb 2026 20:45:55 +0100 Subject: [PATCH] (feature): BaseFileLogger idempotent log folder creation and tests --- CHANGELOG.md | 12 +- .../Logging/FileLoggerTests.cs | 38 ++ .../Logging/JsonFileLoggerTests.cs | 38 ++ src/MaksIT.Core/Logging/BaseFileLogger.cs | 12 + src/MaksIT.Core/Logging/FileLoggerProvider.cs | 6 +- .../Logging/JsonFileLoggerProvider.cs | 6 +- src/MaksIT.Core/MaksIT.Core.csproj | 2 +- src/scripts/Generate-Changelog.bat | 9 - src/scripts/Generate-Changelog.ps1 | 452 -------------- src/scripts/OllamaClient.psm1 | 572 ------------------ src/scripts/changelogsettings.json | 105 ---- 11 files changed, 108 insertions(+), 1144 deletions(-) delete mode 100644 src/scripts/Generate-Changelog.bat delete mode 100644 src/scripts/Generate-Changelog.ps1 delete mode 100644 src/scripts/OllamaClient.psm1 delete mode 100644 src/scripts/changelogsettings.json diff --git a/CHANGELOG.md b/CHANGELOG.md index d050c06..e8a7d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,17 @@ 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). -## v1.6.1 +## v1.6.2 - 2026-02-13 + +### Added +- `BaseFileLogger` idempotent log folder creation and tests + +### Changed +- Improved `BaseFileLogger` to ensure log folder is recreated if deleted during runtime (idempotent folder creation). +- Added comprehensive tests verifying log folder recreation and robustness against folder deletion scenarios. +- Removed AI assisted CHANGELOG.md generation as it's weak and not worth the effort. + +## v1.6.1 - 2026-31-01 ### Added - Added `CreateMutex` method to `BaseFileLogger` diff --git a/src/MaksIT.Core.Tests/Logging/FileLoggerTests.cs b/src/MaksIT.Core.Tests/Logging/FileLoggerTests.cs index 31cc90c..49622b4 100644 --- a/src/MaksIT.Core.Tests/Logging/FileLoggerTests.cs +++ b/src/MaksIT.Core.Tests/Logging/FileLoggerTests.cs @@ -217,4 +217,42 @@ public class FileLoggerTests { var logContent = File.ReadAllText(logFile); Assert.Contains("Empty folder prefix log message", logContent); } + + [Fact] + public void ShouldRecreateLogFolderIfDeleted() { + // Arrange + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(sp => + new TestHostEnvironment { + EnvironmentName = Environments.Development, + ApplicationName = "TestApp", + ContentRootPath = Directory.GetCurrentDirectory() + }); + + serviceCollection.AddLogging(builder => builder.AddFileLogger(_testFolderPath, TimeSpan.FromDays(7))); + + var provider = serviceCollection.BuildServiceProvider(); + var loggerFactory = provider.GetRequiredService(); + + // Act - Create logger and write a log (folder is created) + var logger = loggerFactory.CreateLogger(LoggerPrefix.Folder.WithValue("Audit")); + logger.LogInformation("First log message"); + + var auditFolder = Path.Combine(_testFolderPath, "Audit"); + Assert.True(Directory.Exists(auditFolder), "Audit subfolder should be created"); + + // Delete the folder + Directory.Delete(auditFolder, true); + Assert.False(Directory.Exists(auditFolder), "Audit subfolder should be deleted"); + + // Write another log, which should trigger folder recreation + logger.LogInformation("Second log message after folder deletion"); + + // Assert + Assert.True(Directory.Exists(auditFolder), "Audit subfolder should be recreated"); + var logFile = Directory.GetFiles(auditFolder, "log_*.txt").FirstOrDefault(); + Assert.NotNull(logFile); + var logContent = File.ReadAllText(logFile); + Assert.Contains("Second log message after folder deletion", logContent); + } } diff --git a/src/MaksIT.Core.Tests/Logging/JsonFileLoggerTests.cs b/src/MaksIT.Core.Tests/Logging/JsonFileLoggerTests.cs index 544b81e..926d178 100644 --- a/src/MaksIT.Core.Tests/Logging/JsonFileLoggerTests.cs +++ b/src/MaksIT.Core.Tests/Logging/JsonFileLoggerTests.cs @@ -197,4 +197,42 @@ public class JsonFileLoggerTests { var logContent = File.ReadAllText(logFile); Assert.Contains("Order service JSON log message", logContent); } + + [Fact] + public void ShouldRecreateJsonLogFolderIfDeleted() { + // Arrange + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(sp => + new TestHostEnvironment { + EnvironmentName = Environments.Development, + ApplicationName = "TestApp", + ContentRootPath = Directory.GetCurrentDirectory() + }); + + serviceCollection.AddLogging(builder => builder.AddJsonFileLogger(_testFolderPath, TimeSpan.FromDays(7))); + + var provider = serviceCollection.BuildServiceProvider(); + var loggerFactory = provider.GetRequiredService(); + + // Act - Create logger and write a log (folder is created) + var logger = loggerFactory.CreateLogger(LoggerPrefix.Folder.WithValue("Audit")); + logger.LogInformation("First JSON log message"); + + var auditFolder = Path.Combine(_testFolderPath, "Audit"); + Assert.True(Directory.Exists(auditFolder), "Audit subfolder should be created"); + + // Delete the folder + Directory.Delete(auditFolder, true); + Assert.False(Directory.Exists(auditFolder), "Audit subfolder should be deleted"); + + // Write another log, which should trigger folder recreation + logger.LogInformation("Second JSON log message after folder deletion"); + + // Assert + Assert.True(Directory.Exists(auditFolder), "Audit subfolder should be recreated"); + var logFile = Directory.GetFiles(auditFolder, "log_*.json").FirstOrDefault(); + Assert.NotNull(logFile); + var logContent = File.ReadAllText(logFile); + Assert.Contains("Second JSON log message after folder deletion", logContent); + } } \ No newline at end of file diff --git a/src/MaksIT.Core/Logging/BaseFileLogger.cs b/src/MaksIT.Core/Logging/BaseFileLogger.cs index 8178cd2..cc0abf2 100644 --- a/src/MaksIT.Core/Logging/BaseFileLogger.cs +++ b/src/MaksIT.Core/Logging/BaseFileLogger.cs @@ -45,6 +45,18 @@ public abstract class BaseFileLogger : ILogger, IDisposable { } protected Task AppendToLogFileAsync(string logFileName, string content) { + + if (string.IsNullOrWhiteSpace(logFileName)) + throw new ArgumentException("Log file name must not be null or empty.", nameof(logFileName)); + + var directory = Path.GetDirectoryName(logFileName); + + if (string.IsNullOrWhiteSpace(directory)) + throw new ArgumentException("Log file path must include a directory.", nameof(logFileName)); + + if (!Directory.Exists(directory)) + Directory.CreateDirectory(directory); + bool mutexAcquired = false; try { mutexAcquired = _fileMutex.WaitOne(10000); diff --git a/src/MaksIT.Core/Logging/FileLoggerProvider.cs b/src/MaksIT.Core/Logging/FileLoggerProvider.cs index f10b7da..3b234cf 100644 --- a/src/MaksIT.Core/Logging/FileLoggerProvider.cs +++ b/src/MaksIT.Core/Logging/FileLoggerProvider.cs @@ -20,11 +20,13 @@ public class FileLoggerProvider : ILoggerProvider { private string ResolveFolderPath(string categoryName) { var (prefix, value) = LoggerPrefix.Parse(categoryName); + var newFolderPath = _folderPath; + if (prefix == LoggerPrefix.Folder && !string.IsNullOrWhiteSpace(value)) { - return Path.Combine(_folderPath, SanitizeForPath(value)); + newFolderPath = Path.Combine(newFolderPath, SanitizeForPath(value)); } - return _folderPath; + return newFolderPath; } private static string SanitizeForPath(string input) { diff --git a/src/MaksIT.Core/Logging/JsonFileLoggerProvider.cs b/src/MaksIT.Core/Logging/JsonFileLoggerProvider.cs index 5c09ce5..7cee394 100644 --- a/src/MaksIT.Core/Logging/JsonFileLoggerProvider.cs +++ b/src/MaksIT.Core/Logging/JsonFileLoggerProvider.cs @@ -20,11 +20,13 @@ public class JsonFileLoggerProvider : ILoggerProvider { private string ResolveFolderPath(string categoryName) { var (prefix, value) = LoggerPrefix.Parse(categoryName); + var newFolderPath = _folderPath; + if (prefix == LoggerPrefix.Folder && !string.IsNullOrWhiteSpace(value)) { - return Path.Combine(_folderPath, SanitizeForPath(value)); + newFolderPath = Path.Combine(newFolderPath, SanitizeForPath(value)); } - return _folderPath; + return newFolderPath; } private static string SanitizeForPath(string input) { diff --git a/src/MaksIT.Core/MaksIT.Core.csproj b/src/MaksIT.Core/MaksIT.Core.csproj index cb7245d..b7bd517 100644 --- a/src/MaksIT.Core/MaksIT.Core.csproj +++ b/src/MaksIT.Core/MaksIT.Core.csproj @@ -12,7 +12,7 @@ MaksIT.Core - 1.6.1 + 1.6.2 Maksym Sadovnychyy MAKS-IT MaksIT.Core diff --git a/src/scripts/Generate-Changelog.bat b/src/scripts/Generate-Changelog.bat deleted file mode 100644 index 07ca203..0000000 --- a/src/scripts/Generate-Changelog.bat +++ /dev/null @@ -1,9 +0,0 @@ -@echo off - -REM Change directory to the location of the script -cd /d %~dp0 - -REM Run AI changelog generator (dry-run mode with debug output) -powershell -ExecutionPolicy Bypass -File "%~dp0Generate-Changelog.ps1" - -pause diff --git a/src/scripts/Generate-Changelog.ps1 b/src/scripts/Generate-Changelog.ps1 deleted file mode 100644 index 8a33243..0000000 --- a/src/scripts/Generate-Changelog.ps1 +++ /dev/null @@ -1,452 +0,0 @@ -<# -.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 "") { $pass2 = ($pass2 -split "")[-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 "" diff --git a/src/scripts/OllamaClient.psm1 b/src/scripts/OllamaClient.psm1 deleted file mode 100644 index 859a5fd..0000000 --- a/src/scripts/OllamaClient.psm1 +++ /dev/null @@ -1,572 +0,0 @@ -<# -.SYNOPSIS - Generic Ollama API client module for PowerShell. - -.DESCRIPTION - Provides a simple interface to interact with Ollama's local LLM API: - - Text generation (chat/completion) - - Embeddings generation - - Model management - - RAG utilities (cosine similarity, clustering) - -.REQUIREMENTS - - Ollama running locally (default: http://localhost:11434) - -.USAGE - Import-Module .\OllamaClient.psm1 - - # Configure - Set-OllamaConfig -ApiUrl "http://localhost:11434" - - # Check availability - if (Test-OllamaAvailable) { - # Generate text - $response = Invoke-OllamaPrompt -Model "llama3.1:8b" -Prompt "Hello!" - - # Get embeddings - $embedding = Get-OllamaEmbedding -Model "nomic-embed-text" -Text "Sample text" - } -#> - -# ============================================================================== -# MODULE CONFIGURATION -# ============================================================================== - -$script:OllamaConfig = @{ - ApiUrl = "http://localhost:11434" - DefaultTimeout = 180 - DefaultTemperature = 0.2 - DefaultMaxTokens = 0 - DefaultContextWindow = 0 -} - -# ============================================================================== -# CONFIGURATION FUNCTIONS -# ============================================================================== - -function Set-OllamaConfig { - <# - .SYNOPSIS - Configure Ollama client settings. - .PARAMETER ApiUrl - Ollama API endpoint URL (default: http://localhost:11434). - .PARAMETER DefaultTimeout - Default timeout in seconds for API calls. - .PARAMETER DefaultTemperature - Default temperature for text generation (0.0-1.0). - .PARAMETER DefaultMaxTokens - Default maximum tokens to generate. - .PARAMETER DefaultContextWindow - Default context window size (num_ctx). - #> - param( - [string]$ApiUrl, - [int]$DefaultTimeout, - [double]$DefaultTemperature, - [int]$DefaultMaxTokens, - [int]$DefaultContextWindow - ) - - if ($ApiUrl) { - $script:OllamaConfig.ApiUrl = $ApiUrl - } - - if ($PSBoundParameters.ContainsKey('DefaultTimeout')) { - $script:OllamaConfig.DefaultTimeout = $DefaultTimeout - } - - if ($PSBoundParameters.ContainsKey('DefaultTemperature')) { - $script:OllamaConfig.DefaultTemperature = $DefaultTemperature - } - - if ($PSBoundParameters.ContainsKey('DefaultMaxTokens')) { - $script:OllamaConfig.DefaultMaxTokens = $DefaultMaxTokens - } - - if ($PSBoundParameters.ContainsKey('DefaultContextWindow')) { - $script:OllamaConfig.DefaultContextWindow = $DefaultContextWindow - } -} - -function Get-OllamaConfig { - <# - .SYNOPSIS - Get current Ollama client configuration. - #> - return $script:OllamaConfig.Clone() -} - -# ============================================================================== -# CONNECTION & STATUS -# ============================================================================== - -function Test-OllamaAvailable { - <# - .SYNOPSIS - Check if Ollama API is available and responding. - .OUTPUTS - Boolean indicating if Ollama is available. - #> - try { - $null = Invoke-RestMethod -Uri "$($script:OllamaConfig.ApiUrl)/api/tags" -TimeoutSec 5 -ErrorAction Stop - return $true - } - catch { - return $false - } -} - -function Get-OllamaModels { - <# - .SYNOPSIS - Get list of available models from Ollama. - .OUTPUTS - Array of model objects with name, size, and other properties. - #> - try { - $response = Invoke-RestMethod -Uri "$($script:OllamaConfig.ApiUrl)/api/tags" -TimeoutSec 10 -ErrorAction Stop - return $response.models - } - catch { - Write-Warning "Failed to get Ollama models: $_" - return @() - } -} - -function Test-OllamaModel { - <# - .SYNOPSIS - Check if a specific model is available in Ollama. - .PARAMETER Model - Model name to check. - #> - param([Parameter(Mandatory)][string]$Model) - - $models = Get-OllamaModels - return ($models | Where-Object { $_.name -eq $Model -or $_.name -like "${Model}:*" }) -ne $null -} - -# ============================================================================== -# TEXT GENERATION -# ============================================================================== - -function Invoke-OllamaPrompt { - <# - .SYNOPSIS - Send a prompt to an Ollama model and get a response. - .PARAMETER Model - Model name (e.g., "llama3.1:8b", "qwen2.5-coder:7b"). - .PARAMETER Prompt - The prompt text to send. - .PARAMETER ContextWindow - Context window size (num_ctx). Uses default if not specified. - .PARAMETER MaxTokens - Maximum tokens to generate (num_predict). Uses default if not specified. - .PARAMETER Temperature - Temperature for generation (0.0-1.0). Uses default if not specified. - .PARAMETER Timeout - Timeout in seconds. Uses default if not specified. - .PARAMETER System - Optional system prompt. - .OUTPUTS - Generated text response or $null if failed. - #> - param( - [Parameter(Mandatory)][string]$Model, - [Parameter(Mandatory)][string]$Prompt, - [int]$ContextWindow, - [int]$MaxTokens, - [double]$Temperature, - [int]$Timeout, - [string]$System - ) - - $config = $script:OllamaConfig - - # Use defaults if not specified - if (-not $PSBoundParameters.ContainsKey('MaxTokens')) { $MaxTokens = $config.DefaultMaxTokens } - if (-not $PSBoundParameters.ContainsKey('Temperature')) { $Temperature = $config.DefaultTemperature } - if (-not $PSBoundParameters.ContainsKey('Timeout')) { $Timeout = $config.DefaultTimeout } - - $options = @{ - temperature = $Temperature - } - - # Only set num_predict if MaxTokens > 0 (0 = unlimited/model default) - if ($MaxTokens -and $MaxTokens -gt 0) { - $options.num_predict = $MaxTokens - } - - # Only set context window if explicitly provided (let model use its default otherwise) - if ($ContextWindow -and $ContextWindow -gt 0) { - $options.num_ctx = $ContextWindow - } - - $body = @{ - model = $Model - prompt = $Prompt - stream = $false - options = $options - } - - if ($System) { - $body.system = $System - } - - $jsonBody = $body | ConvertTo-Json -Depth 3 - - # TimeoutSec 0 = infinite wait - $restParams = @{ - Uri = "$($config.ApiUrl)/api/generate" - Method = "Post" - Body = $jsonBody - ContentType = "application/json" - } - if ($Timeout -gt 0) { $restParams.TimeoutSec = $Timeout } - - try { - $response = Invoke-RestMethod @restParams - return $response.response.Trim() - } - catch { - Write-Warning "Ollama prompt failed: $_" - return $null - } -} - -function Invoke-OllamaChat { - <# - .SYNOPSIS - Send a chat conversation to an Ollama model. - .PARAMETER Model - Model name. - .PARAMETER Messages - Array of message objects with 'role' and 'content' properties. - Roles: "system", "user", "assistant" - .PARAMETER ContextWindow - Context window size. - .PARAMETER MaxTokens - Maximum tokens to generate. - .PARAMETER Temperature - Temperature for generation. - .OUTPUTS - Generated response text or $null if failed. - #> - param( - [Parameter(Mandatory)][string]$Model, - [Parameter(Mandatory)][array]$Messages, - [int]$ContextWindow, - [int]$MaxTokens, - [double]$Temperature, - [int]$Timeout - ) - - $config = $script:OllamaConfig - - if (-not $PSBoundParameters.ContainsKey('MaxTokens')) { $MaxTokens = $config.DefaultMaxTokens } - if (-not $PSBoundParameters.ContainsKey('Temperature')) { $Temperature = $config.DefaultTemperature } - if (-not $PSBoundParameters.ContainsKey('Timeout')) { $Timeout = $config.DefaultTimeout } - - $options = @{ - temperature = $Temperature - } - - # Only set num_predict if MaxTokens > 0 (0 = unlimited/model default) - if ($MaxTokens -and $MaxTokens -gt 0) { - $options.num_predict = $MaxTokens - } - - # Only set context window if explicitly provided - if ($ContextWindow -and $ContextWindow -gt 0) { - $options.num_ctx = $ContextWindow - } - - $body = @{ - model = $Model - messages = $Messages - stream = $false - options = $options - } - - $jsonBody = $body | ConvertTo-Json -Depth 4 - - # TimeoutSec 0 = infinite wait - $restParams = @{ - Uri = "$($config.ApiUrl)/api/chat" - Method = "Post" - Body = $jsonBody - ContentType = "application/json" - } - if ($Timeout -gt 0) { $restParams.TimeoutSec = $Timeout } - - try { - $response = Invoke-RestMethod @restParams - return $response.message.content.Trim() - } - catch { - Write-Warning "Ollama chat failed: $_" - return $null - } -} - -# ============================================================================== -# EMBEDDINGS -# ============================================================================== - -function Get-OllamaEmbedding { - <# - .SYNOPSIS - Get embedding vector for text using an Ollama embedding model. - .PARAMETER Model - Embedding model name (e.g., "nomic-embed-text", "mxbai-embed-large"). - .PARAMETER Text - Text to embed. - .PARAMETER Timeout - Timeout in seconds. - .OUTPUTS - Array of doubles representing the embedding vector, or $null if failed. - #> - param( - [Parameter(Mandatory)][string]$Model, - [Parameter(Mandatory)][string]$Text, - [int]$Timeout = 30 - ) - - $body = @{ - model = $Model - prompt = $Text - } | ConvertTo-Json - - try { - $response = Invoke-RestMethod -Uri "$($script:OllamaConfig.ApiUrl)/api/embeddings" -Method Post -Body $body -ContentType "application/json" -TimeoutSec $Timeout - return $response.embedding - } - catch { - Write-Warning "Ollama embedding failed: $_" - return $null - } -} - -function Get-OllamaEmbeddings { - <# - .SYNOPSIS - Get embeddings for multiple texts (batch). - .PARAMETER Model - Embedding model name. - .PARAMETER Texts - Array of texts to embed. - .PARAMETER ShowProgress - Show progress indicator. - .OUTPUTS - Array of objects with Text and Embedding properties. - #> - param( - [Parameter(Mandatory)][string]$Model, - [Parameter(Mandatory)][string[]]$Texts, - [switch]$ShowProgress - ) - - $results = @() - $total = $Texts.Count - $current = 0 - - foreach ($text in $Texts) { - $current++ - if ($ShowProgress) { - Write-Progress -Activity "Getting embeddings" -Status "$current of $total" -PercentComplete (($current / $total) * 100) - } - - $embedding = Get-OllamaEmbedding -Model $Model -Text $text - if ($embedding) { - $results += @{ - Text = $text - Embedding = $embedding - } - } - } - - if ($ShowProgress) { - Write-Progress -Activity "Getting embeddings" -Completed - } - - return $results -} - -# ============================================================================== -# RAG UTILITIES -# ============================================================================== - -function Get-CosineSimilarity { - <# - .SYNOPSIS - Calculate cosine similarity between two embedding vectors. - .PARAMETER Vector1 - First embedding vector. - .PARAMETER Vector2 - Second embedding vector. - .OUTPUTS - Cosine similarity value between -1 and 1. - #> - param( - [Parameter(Mandatory)][double[]]$Vector1, - [Parameter(Mandatory)][double[]]$Vector2 - ) - - if ($Vector1.Length -ne $Vector2.Length) { - Write-Warning "Vector lengths don't match: $($Vector1.Length) vs $($Vector2.Length)" - return 0 - } - - $dotProduct = 0.0 - $norm1 = 0.0 - $norm2 = 0.0 - - for ($i = 0; $i -lt $Vector1.Length; $i++) { - $dotProduct += $Vector1[$i] * $Vector2[$i] - $norm1 += $Vector1[$i] * $Vector1[$i] - $norm2 += $Vector2[$i] * $Vector2[$i] - } - - $norm1 = [Math]::Sqrt($norm1) - $norm2 = [Math]::Sqrt($norm2) - - if ($norm1 -eq 0 -or $norm2 -eq 0) { return 0 } - return $dotProduct / ($norm1 * $norm2) -} - -function Group-TextsByEmbedding { - <# - .SYNOPSIS - Cluster texts by embedding similarity. - .PARAMETER Model - Embedding model name. - .PARAMETER Texts - Array of texts to cluster. - .PARAMETER SimilarityThreshold - Minimum cosine similarity to group texts together (0.0-1.0). - .PARAMETER ShowProgress - Show progress during embedding. - .OUTPUTS - Array of clusters (each cluster is an array of texts). - #> - param( - [Parameter(Mandatory)][string]$Model, - [Parameter(Mandatory)][string[]]$Texts, - [double]$SimilarityThreshold = 0.65, - [switch]$ShowProgress - ) - - if ($Texts.Length -eq 0) { return @() } - if ($Texts.Length -eq 1) { return @(,@($Texts[0])) } - - # Get embeddings - $embeddings = Get-OllamaEmbeddings -Model $Model -Texts $Texts -ShowProgress:$ShowProgress - - if ($embeddings.Length -eq 0) { - return @($Texts | ForEach-Object { ,@($_) }) - } - - # Mark all as unclustered - $embeddings | ForEach-Object { $_.Clustered = $false } - - # Cluster similar texts - $clusters = @() - - for ($i = 0; $i -lt $embeddings.Length; $i++) { - if ($embeddings[$i].Clustered) { continue } - - $cluster = @($embeddings[$i].Text) - $embeddings[$i].Clustered = $true - - for ($j = $i + 1; $j -lt $embeddings.Length; $j++) { - if ($embeddings[$j].Clustered) { continue } - - $similarity = Get-CosineSimilarity -Vector1 $embeddings[$i].Embedding -Vector2 $embeddings[$j].Embedding - - if ($similarity -ge $SimilarityThreshold) { - $cluster += $embeddings[$j].Text - $embeddings[$j].Clustered = $true - } - } - - $clusters += ,@($cluster) - } - - return $clusters -} - -function Find-SimilarTexts { - <# - .SYNOPSIS - Find texts most similar to a query using embeddings. - .PARAMETER Model - Embedding model name. - .PARAMETER Query - Query text to find similar texts for. - .PARAMETER Texts - Array of texts to search through. - .PARAMETER TopK - Number of most similar texts to return. - .PARAMETER MinSimilarity - Minimum similarity threshold. - .OUTPUTS - Array of objects with Text and Similarity properties, sorted by similarity. - #> - param( - [Parameter(Mandatory)][string]$Model, - [Parameter(Mandatory)][string]$Query, - [Parameter(Mandatory)][string[]]$Texts, - [int]$TopK = 5, - [double]$MinSimilarity = 0.0 - ) - - # Get query embedding - $queryEmbedding = Get-OllamaEmbedding -Model $Model -Text $Query - if (-not $queryEmbedding) { return @() } - - # Get text embeddings and calculate similarities - $results = @() - foreach ($text in $Texts) { - $textEmbedding = Get-OllamaEmbedding -Model $Model -Text $text - if ($textEmbedding) { - $similarity = Get-CosineSimilarity -Vector1 $queryEmbedding -Vector2 $textEmbedding - if ($similarity -ge $MinSimilarity) { - $results += @{ - Text = $text - Similarity = $similarity - } - } - } - } - - # Sort by similarity and return top K - return $results | Sort-Object -Property Similarity -Descending | Select-Object -First $TopK -} - -# ============================================================================== -# MODULE EXPORTS -# ============================================================================== - -Export-ModuleMember -Function @( - # Configuration - 'Set-OllamaConfig' - 'Get-OllamaConfig' - - # Connection & Status - 'Test-OllamaAvailable' - 'Get-OllamaModels' - 'Test-OllamaModel' - - # Text Generation - 'Invoke-OllamaPrompt' - 'Invoke-OllamaChat' - - # Embeddings - 'Get-OllamaEmbedding' - 'Get-OllamaEmbeddings' - - # RAG Utilities - 'Get-CosineSimilarity' - 'Group-TextsByEmbedding' - 'Find-SimilarTexts' -) diff --git a/src/scripts/changelogsettings.json b/src/scripts/changelogsettings.json deleted file mode 100644 index 46fc1b7..0000000 --- a/src/scripts/changelogsettings.json +++ /dev/null @@ -1,105 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$comment": "Configuration for Generate-Changelog.ps1 (AI-assisted changelog generation)", - - "ollama": { - "enabled": true, - "apiUrl": "http://localhost:11434", - "defaultTimeout": 0, - "defaultContextWindow": 0 - }, - - "changelog": { - "debug": true, - "enableRAG": true, - "similarityThreshold": 0.65, - - "csprojPath": "../MaksIT.Core/MaksIT.Core.csproj", - "outputFile": "../../CHANGELOG.md", - "licensePath": "../../LICENSE.md", - "fileExtension": ".cs", - "excludePatterns": ["Tests:", "Tests.cs", ".Tests."], - - "models": { - "analyze": { - "name": "qwen2.5-coder:7b-instruct-q6_K", - "context": 0, - "maxTokens": 0, - "description": "Pass 1: Code commit analysis (7B, fast)" - }, - "reason": { - "name": "qwen2.5:7b-instruct-q8_0", - "context": 0, - "maxTokens": 0, - "temperature": 0.1, - "description": "Pass 2: Consolidation (7B, fast)" - }, - "write": { - "name": "qwen2.5:7b-instruct-q8_0", - "context": 0, - "maxTokens": 0, - "description": "Pass 3: Formatting (7B, fast)" - }, - "embed": { - "name": "mxbai-embed-large", - "description": "RAG: Commit clustering" - } - }, - - "prompts": { - "analyze": [ - "Convert code changes to changelog entries. Include ALL items.", - "", - "Changes:", - "{{changes}}", - "", - "RULES:", - "1. Create ONE bullet point per item", - "2. Include method names mentioned (CreateMutex, ResolveFolderPath, etc.)", - "3. New classes = \"Added [class] for [purpose]\"", - "4. New methods = \"Added [method] to [class]\"", - "5. Deleted files = \"Removed [class/feature]\"", - "6. Exception handling = \"Improved error handling in [class]\"", - "", - "Output bullet points for each change:" - ], - - "reason": [ - "Keep all important details from this changelog.", - "", - "Input:", - "{{input}}", - "", - "RULES:", - "1. KEEP specific method names and class names", - "2. KEEP all distinct features - do not over-consolidate", - "3. Merge ONLY if items are nearly identical", - "4. DO NOT invent new information", - "5. Output 3-10 bullet points", - "", - "Output:" - ], - - "format": [ - "Categorize these items under the correct changelog headers.", - "", - "Items:", - "{{items}}", - "", - "HEADERS (use exactly as shown):", - "### Added", - "### Changed", - "### Fixed", - "### Removed", - "", - "CATEGORIZATION RULES:", - "- \"Added [class/method]\" -> ### Added", - "- \"Improved...\" or \"Enhanced...\" -> ### Changed", - "- \"Fixed...\" -> ### Fixed", - "- \"Removed...\" -> ### Removed", - "", - "Output each item under correct header. Omit empty sections:" - ] - } - } -}