From 98abbe661c70189f0dad3c22fbd7bffc6626f767 Mon Sep 17 00:00:00 2001 From: edraft Date: Sat, 14 Feb 2026 20:48:34 +0100 Subject: [PATCH] Added package filtering --- .gitea/workflows/build dev.yaml | 18 - .gitignore | 9 +- .../Service/PackageServiceDateVersionTests.cs | 617 ++++++++++++++++++ .../PackageServiceSemanticVersionTests.cs | 498 ++++++++++++++ .../PackageServiceVersionDetectionTests.cs | 349 ++++++++++ .../sh.actions.package-cleanup.Tests.csproj | 26 + .../ConfigurationExtension.cs | 26 + .../Models/GiteaPackage.cs | 0 sh.actions.package-cleanup/Program.cs | 21 + .../Properties/launchSettings.json | 12 + .../Service/GiteaPackageService.cs | 95 +++ .../Service/IGiteaPackageService.cs | 1 + .../Service/IPackageService.cs | 9 + .../Service/PackageService.cs | 311 +++++++++ sh.actions.package-cleanup/Worker.cs | 41 ++ sh.actions.package-cleanup/appsettings.json | 1 + .../sh.actions.package-cleanup.csproj | 15 + sh.actions.sln | 42 +- sh.actions.sln.DotSettings.user | 7 + .../Models/GiteaConfig.cs | 23 - .../sh.actions.package-cleanup/Program.cs | 65 -- .../Service/GiteaPackageService.cs | 48 -- .../sh.actions.package-cleanup.csproj | 31 - 23 files changed, 2072 insertions(+), 193 deletions(-) create mode 100644 sh.actions.package-cleanup.Tests/Service/PackageServiceDateVersionTests.cs create mode 100644 sh.actions.package-cleanup.Tests/Service/PackageServiceSemanticVersionTests.cs create mode 100644 sh.actions.package-cleanup.Tests/Service/PackageServiceVersionDetectionTests.cs create mode 100644 sh.actions.package-cleanup.Tests/sh.actions.package-cleanup.Tests.csproj create mode 100644 sh.actions.package-cleanup/ConfigurationExtension.cs rename {sh.actions/sh.actions.package-cleanup => sh.actions.package-cleanup}/Models/GiteaPackage.cs (100%) create mode 100644 sh.actions.package-cleanup/Program.cs create mode 100644 sh.actions.package-cleanup/Properties/launchSettings.json create mode 100644 sh.actions.package-cleanup/Service/GiteaPackageService.cs rename {sh.actions/sh.actions.package-cleanup => sh.actions.package-cleanup}/Service/IGiteaPackageService.cs (71%) create mode 100644 sh.actions.package-cleanup/Service/IPackageService.cs create mode 100644 sh.actions.package-cleanup/Service/PackageService.cs create mode 100644 sh.actions.package-cleanup/Worker.cs create mode 100644 sh.actions.package-cleanup/appsettings.json create mode 100644 sh.actions.package-cleanup/sh.actions.package-cleanup.csproj create mode 100644 sh.actions.sln.DotSettings.user delete mode 100644 sh.actions/sh.actions.package-cleanup/Models/GiteaConfig.cs delete mode 100644 sh.actions/sh.actions.package-cleanup/Program.cs delete mode 100644 sh.actions/sh.actions.package-cleanup/Service/GiteaPackageService.cs delete mode 100644 sh.actions/sh.actions.package-cleanup/sh.actions.package-cleanup.csproj diff --git a/.gitea/workflows/build dev.yaml b/.gitea/workflows/build dev.yaml index 1d59e87..22734e7 100644 --- a/.gitea/workflows/build dev.yaml +++ b/.gitea/workflows/build dev.yaml @@ -45,21 +45,3 @@ jobs: -H "Authorization: token ${{ secrets.CI_ACCESS_TOKEN }}" \ -T publish/linux-x64/sh.actions.package-cleanup \ "https://git.sh-edraft.de/api/packages/sh-edraft.de/generic/package-cleanup/$(cat ../../version.txt)/package-cleanup-linux-x64" - - test: - runs-on: [runner] - needs: build - container: git.sh-edraft.de/sh-edraft.de/act-runner:latest - steps: - - name: Download and test package-cleanup tool - shell: bash - run: | - curl -OJ https://git.sh-edraft.de/api/packages/sh-edraft.de/generic/package-cleanup/package-cleanup-linux-x64 - - # Make executable - chmod +x package-cleanup-linux-x64 - - - name: Run package-cleanup - shell: bash - run: | - ./package-cleanup-linux-x64 diff --git a/.gitignore b/.gitignore index b9585fb..dd3c5ba 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,11 @@ .code bin/ -obj/ \ No newline at end of file +obj/ + +# Environment Variables - DO NOT COMMIT +.env +.env.local +*.env.local + +**/appsettings.local.json diff --git a/sh.actions.package-cleanup.Tests/Service/PackageServiceDateVersionTests.cs b/sh.actions.package-cleanup.Tests/Service/PackageServiceDateVersionTests.cs new file mode 100644 index 0000000..8f45fcb --- /dev/null +++ b/sh.actions.package-cleanup.Tests/Service/PackageServiceDateVersionTests.cs @@ -0,0 +1,617 @@ +using Microsoft.Extensions.Configuration; +using Moq; +using sh.actions.package_cleanup.Models; +using sh.actions.package_cleanup.Service; +using Xunit; + +namespace sh.actions.package_cleanup.Tests.Service; + +public class PackageServiceDateVersionTests +{ + private Mock _configurationMock; + private Mock _giteaPackageServiceMock; + private IPackageService _packageService; + + private void SetupMocks() + { + _configurationMock = new Mock(); + _giteaPackageServiceMock = new Mock(); + _packageService = new PackageService(_configurationMock.Object, _giteaPackageServiceMock.Object); + } + + // ...existing code... + + #region Version Detection Tests + + [Theory] + [InlineData("2024.12.25.1")] + [InlineData("2025.2.14.1")] + [InlineData("2026.1.1.100")] + [InlineData("2023.12.31.999")] + public void FilterPackagesToDelete_WithDateVersions_ShouldIdentifyCorrectly(string version) + { + // Arrange + SetupMocks(); + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Single prod date version should not be deleted + Assert.NotNull(result); + Assert.Empty(result); + } + + [Theory] + [InlineData("2024.12.25.1-dev")] + [InlineData("2025.2.14.1-alpha")] + [InlineData("2026.1.1.100-beta")] + [InlineData("2023.12.31.999-rc")] + public void FilterPackagesToDelete_WithDateVersionsAndSuffixes_ShouldIdentifyCorrectly(string version) + { + // Arrange + SetupMocks(); + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Single prerelease/dev date version should be kept + Assert.NotNull(result); + Assert.Empty(result); + } + + #endregion + + #region Prod Release Tests - Date Versioning + + [Fact] + public void FilterPackagesToDeleteByDateVersion_WithOnlyProdReleases_ShouldReturnEmpty() + { + // Arrange - CLEAN_PROD_VERSIONS not set, defaults to false + SetupMocks(); + var packages = new List + { + new() + { + Id = 1, Name = "test", Version = "2025.12.20.1", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-30) + }, + new() + { + Id = 2, Name = "test", Version = "2025.12.25.1", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-20) + }, + new() + { + Id = 3, Name = "test", Version = "2026.1.5.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-10) + } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Prod releases should be protected by default (CLEAN_PROD_VERSIONS=false) + Assert.Empty(result); + } + + [Fact] + public void FilterPackagesToDeleteByDateVersion_WithProdReleasesAndCleaningEnabled_ShouldDeleteOldest() + { + // Arrange + SetupMocks(); + _configurationMock.Setup(c => c["CLEAN_PROD_VERSIONS"]).Returns("true"); + _configurationMock.Setup(c => c["KEEP_PROD_VERSIONS"]).Returns("2"); + + var packages = new List + { + new() + { + Id = 1, Name = "test", Version = "2025.1.1.1", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-365) + }, + new() + { + Id = 2, Name = "test", Version = "2025.6.15.1", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-200) + }, + new() + { + Id = 3, Name = "test", Version = "2026.1.1.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-45) + }, + new() { Id = 4, Name = "test", Version = "2026.2.14.1", Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Should delete oldest 2 (2025.1.1 and 2025.6.15), keep newest 2 + Assert.Equal(2, result.Count); + Assert.Contains(result, p => p.Id == 1); + Assert.Contains(result, p => p.Id == 2); + Assert.DoesNotContain(result, p => p.Id == 3 || p.Id == 4); + } + + [Fact] + public void FilterPackagesToDeleteByDateVersion_WithMultipleProdVersions_ShouldKeepNewest() + { + // Arrange + SetupMocks(); + var packages = new List + { + new() + { + Id = 1, Name = "test", Version = "2025.1.1.1", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-365) + }, + new() + { + Id = 2, Name = "test", Version = "2025.6.15.1", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-200) + }, + new() + { + Id = 3, Name = "test", Version = "2026.1.1.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-45) + }, + new() { Id = 4, Name = "test", Version = "2026.2.14.1", Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Default is CLEAN_PROD_VERSIONS=false, so nothing should be deleted + Assert.Empty(result); + } + + #endregion + + #region Prerelease Tests - Date Versioning + + [Fact] + public void FilterPackagesToDeleteByDateVersion_WithPrereleaseVersions_ShouldKeepLatest() + { + // Arrange - Default KEEP_PRERELEASE_VERSIONS=3 + SetupMocks(); + var packages = new List + { + new() + { + Id = 1, Name = "test", Version = "2026.2.10.1-alpha", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-5) + }, + new() + { + Id = 2, Name = "test", Version = "2026.2.11.1-beta", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-4) + }, + new() + { + Id = 3, Name = "test", Version = "2026.2.12.1-rc", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-3) + }, + new() + { + Id = 4, Name = "test", Version = "2026.2.13.1-preview", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-2) + } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Should delete oldest 1 (alpha), keep newest 3 + Assert.Single(result); + Assert.Contains(result, p => p.Id == 1); // alpha is oldest + Assert.DoesNotContain(result, p => p.Id == 2 || p.Id == 3 || p.Id == 4); + } + + [Fact] + public void FilterPackagesToDeleteByDateVersion_WithManyPrereleases_ShouldEnforceLimit() + { + // Arrange - Custom KEEP_PRERELEASE_VERSIONS=2 + SetupMocks(); + _configurationMock.Setup(c => c["KEEP_PRERELEASE_VERSIONS"]).Returns("2"); + + var packages = new List(); + for (int i = 0; i < 10; i++) + { + var date = DateTime.UtcNow.AddDays(-i); + packages.Add(new GiteaPackage + { + Id = i, + Name = "test", + Version = $"{date.Year}.{date.Month}.{date.Day}.{i}-beta", + Type = "docker", + CreatedAt = date + }); + } + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Should delete 8 oldest, keep only 2 newest (Id=0,1) + Assert.Equal(8, result.Count); + Assert.DoesNotContain(result, p => p.Id < 2); + Assert.Contains(result, p => p.Id >= 2); + } + + [Fact] + public void FilterPackagesToDeleteByDateVersion_WithMixedPrereleases_ShouldOrderByDate() + { + // Arrange + SetupMocks(); + var packages = new List + { + new() + { + Id = 1, Name = "test", Version = "2025.12.1.1-beta", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-60) + }, + new() + { + Id = 2, Name = "test", Version = "2026.1.15.1-beta", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-30) + }, + new() + { + Id = 3, Name = "test", Version = "2026.2.1.1-alpha", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-14) + } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Default keep 3, so all should be kept + Assert.Empty(result); + } + + #endregion + + #region Dev Release Tests - Date Versioning + + [Fact] + public void FilterPackagesToDeleteByDateVersion_WithDevVersions_ShouldKeepLatest() + { + // Arrange - Default KEEP_DEV_VERSIONS=5 + SetupMocks(); + var packages = new List + { + new() + { + Id = 1, Name = "test", Version = "2026.2.10.1-dev", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-5) + }, + new() + { + Id = 2, Name = "test", Version = "2026.2.11.2-dev", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-4) + }, + new() + { + Id = 3, Name = "test", Version = "2026.2.12.3-dev", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-3) + } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Default keep 5, so all 3 should be kept + Assert.Empty(result); + } + + [Fact] + public void FilterPackagesToDeleteByDateVersion_WithManyDailyDevReleases_ShouldEnforceLimit() + { + // Arrange - Custom KEEP_DEV_VERSIONS=3 + SetupMocks(); + _configurationMock.Setup(c => c["KEEP_DEV_VERSIONS"]).Returns("3"); + + var packages = new List(); + for (int i = 0; i < 30; i++) + { + var date = DateTime.UtcNow.AddDays(-i); + packages.Add(new GiteaPackage + { + Id = i, + Name = "test", + Version = $"{date.Year}.{date.Month}.{date.Day}.{i}-dev", + Type = "docker", + CreatedAt = date + }); + } + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Should delete 27 oldest, keep only 3 newest (Id=0,1,2) + Assert.Equal(27, result.Count); + Assert.DoesNotContain(result, p => p.Id < 3); + Assert.Contains(result, p => p.Id >= 3); + } + + [Fact] + public void FilterPackagesToDeleteByDateVersion_WithManyDailyDevReleasesDefaultLimit_ShouldIdentifyAll() + { + // Arrange + SetupMocks(); + var packages = new List(); + for (int i = 0; i < 30; i++) + { + var date = DateTime.UtcNow.AddDays(-i); + packages.Add(new GiteaPackage + { + Id = i, + Name = "test", + Version = $"{date.Year}.{date.Month}.{date.Day}.{i}-dev", + Type = "docker", + CreatedAt = date + }); + } + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Default keep 5, so 25 should be deleted + Assert.Equal(25, result.Count); + Assert.DoesNotContain(result, p => p.Id < 5); // Keep newest 5 (Id 0-4) + Assert.Contains(result, p => p.Id >= 5); + } + + #endregion + + #region Mixed Release Types - Date Versioning + + [Fact] + public void FilterPackagesToDeleteByDateVersion_WithMixedReleaseTypes_ShouldSeparateAndCleanCorrectly() + { + // Arrange + SetupMocks(); + _configurationMock.Setup(c => c["KEEP_PRERELEASE_VERSIONS"]).Returns("2"); + _configurationMock.Setup(c => c["KEEP_DEV_VERSIONS"]).Returns("2"); + + var packages = new List + { + // Prod releases (should not be deleted - CLEAN_PROD_VERSIONS=false) + new() + { + Id = 1, Name = "test", Version = "2025.12.1.1", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-80) + }, + new() + { + Id = 2, Name = "test", Version = "2026.1.1.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-45) + }, + // Prerelease (keep 2 newest) + new() + { + Id = 3, Name = "test", Version = "2026.1.25.1-alpha", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-20) + }, + new() + { + Id = 4, Name = "test", Version = "2026.2.1.1-beta", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-14) + }, + new() + { + Id = 5, Name = "test", Version = "2026.2.5.1-rc", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-10) + }, + // Dev (keep 2 newest) + new() + { + Id = 6, Name = "test", Version = "2026.2.10.1-dev", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-5) + }, + new() { Id = 7, Name = "test", Version = "2026.2.14.1-dev", Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert + Assert.Equal(1, result.Count); // Should delete 1 prerelease (alpha), 0 dev (keep both) + Assert.DoesNotContain(result, p => p.Id <= 2); // Prod not deleted + Assert.DoesNotContain(result, p => p.Id >= 6); // Keep 2 latest dev + } + + [Fact] + public void FilterPackagesToDeleteByDateVersion_WithLargeSet_ShouldHandleCorrectly() + { + // Arrange + SetupMocks(); + _configurationMock.Setup(c => c["CLEAN_PROD_VERSIONS"]).Returns("false"); + _configurationMock.Setup(c => c["KEEP_PRERELEASE_VERSIONS"]).Returns("3"); + _configurationMock.Setup(c => c["KEEP_DEV_VERSIONS"]).Returns("5"); + + var packages = new List(); + + // Add prod releases + for (int i = 0; i < 5; i++) + { + var date = DateTime.UtcNow.AddMonths(-(5 - i)); + packages.Add(new GiteaPackage + { + Id = i, + Name = "test", + Version = $"{date.Year}.{date.Month}.1.1", + Type = "docker", + CreatedAt = date + }); + } + + // Add prerelease + for (int i = 5; i < 15; i++) + { + var date = DateTime.UtcNow.AddDays(-(15 - i) * 7); + packages.Add(new GiteaPackage + { + Id = i, + Name = "test", + Version = $"{date.Year}.{date.Month}.{date.Day}.1-beta", + Type = "docker", + CreatedAt = date + }); + } + + // Add dev + for (int i = 15; i < 30; i++) + { + var date = DateTime.UtcNow.AddDays(-(30 - i)); + packages.Add(new GiteaPackage + { + Id = i, + Name = "test", + Version = $"{date.Year}.{date.Month}.{date.Day}.{(30 - i)}-dev", + Type = "docker", + CreatedAt = date + }); + } + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert + Assert.NotNull(result); + // Prod releases should never be deleted + Assert.DoesNotContain(result, p => p.Id < 5); + // Should delete 7 oldest prerelease (keep 3 newest) + var prereleaseDeleted = result.Where(p => p.Id >= 5 && p.Id < 15).ToList(); + Assert.Equal(7, prereleaseDeleted.Count); + // Should delete 10 oldest dev (keep 5 newest) + var devDeleted = result.Where(p => p.Id >= 15).ToList(); + Assert.Equal(10, devDeleted.Count); + } + + #endregion + + #region Edge Cases - Date Versioning + + [Theory] + [InlineData("1000.1.1.1")] // Minimum valid year + [InlineData("2999.12.31.999")] // Maximum valid year + [InlineData("2025.2.14.0")] // Build number 0 + public void FilterPackagesToDeleteByDateVersion_WithBoundaryVersions_ShouldHandleCorrectly(string version) + { + // Arrange + SetupMocks(); + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert + Assert.NotNull(result); + } + + [Theory] + [InlineData("999.12.31.1")] // Year too small + [InlineData("3000.1.1.1")] // Year too large + [InlineData("2025.13.1.1")] // Month out of range + [InlineData("2025.0.1.1")] // Month zero + [InlineData("2025.2.32.1")] // Day out of range + [InlineData("2025.2.0.1")] // Day zero + public void FilterPackagesToDelete_WithInvalidDateVersions_ShouldNotProcess(string version) + { + // Arrange + SetupMocks(); + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }, + new() { Id = 2, Name = "test", Version = "1.0.0.1", Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Invalid date version should be skipped, only semantic version processed + Assert.NotNull(result); + } + + [Fact] + public void FilterPackagesToDeleteByDateVersion_WithConsecutiveDates_ShouldOrderCorrectly() + { + // Arrange + SetupMocks(); + var packages = new List + { + new() + { + Id = 1, Name = "test", Version = "2025.2.10.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-5) + }, + new() + { + Id = 2, Name = "test", Version = "2025.2.11.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-4) + }, + new() + { + Id = 3, Name = "test", Version = "2025.2.12.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-3) + }, + new() + { + Id = 4, Name = "test", Version = "2025.2.13.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-2) + }, + new() { Id = 5, Name = "test", Version = "2025.2.14.1", Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert + Assert.NotNull(result); + } + + #endregion + + #region Mixed Versioning Variants + + [Fact] + public void FilterPackagesToDelete_WithMixedVersioningVariants_ShouldProcessBothTypes() + { + // Arrange + SetupMocks(); + var packages = new List + { + // Semantic versioning + new() + { + Id = 1, Name = "test", Version = "1.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-30) + }, + new() + { + Id = 2, Name = "test", Version = "1.1.0-dev", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-10) + }, + // Date versioning + new() + { + Id = 3, Name = "test", Version = "2025.1.1.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-45) + }, + new() + { + Id = 4, Name = "test", Version = "2025.2.10.1-dev", Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-5) + } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert + Assert.NotNull(result); + // Prod releases should not be in delete list + Assert.DoesNotContain(result, p => p.Id == 1 || p.Id == 3); + } + + #endregion +} \ No newline at end of file diff --git a/sh.actions.package-cleanup.Tests/Service/PackageServiceSemanticVersionTests.cs b/sh.actions.package-cleanup.Tests/Service/PackageServiceSemanticVersionTests.cs new file mode 100644 index 0000000..0bb03da --- /dev/null +++ b/sh.actions.package-cleanup.Tests/Service/PackageServiceSemanticVersionTests.cs @@ -0,0 +1,498 @@ +using Microsoft.Extensions.Configuration; +using Moq; +using sh.actions.package_cleanup.Models; +using sh.actions.package_cleanup.Service; +using Xunit; + +namespace sh.actions.package_cleanup.Tests.Service; + +public class PackageServiceSemanticVersionTests +{ + private Mock _configurationMock; + private Mock _giteaPackageServiceMock; + private IPackageService _packageService; + + private void SetupMocks() + { + _configurationMock = new Mock(); + _giteaPackageServiceMock = new Mock(); + _packageService = new PackageService(_configurationMock.Object, _giteaPackageServiceMock.Object); + } + + #region Version Detection Tests + + [Theory] + [InlineData("1.0.0.0")] + [InlineData("2.5.3.1")] + [InlineData("10.20.30.100")] + [InlineData("1.0.0")] + [InlineData("2.5")] + public void FilterPackagesToDelete_WithSemanticVersions_ShouldIdentifyCorrectly(string version) + { + // Arrange + SetupMocks(); + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Single prod version should not be deleted (CLEAN_PROD_VERSIONS=false by default) + Assert.NotNull(result); + Assert.Empty(result); + } + + [Theory] + [InlineData("1.0.0.0-dev")] + [InlineData("2.5.3.1-alpha")] + [InlineData("10.20.30.100-beta")] + [InlineData("1.0.0-rc1")] + [InlineData("2.5-preview")] + public void FilterPackagesToDelete_WithSemanticVersionsAndSuffixes_ShouldIdentifyCorrectly(string version) + { + // Arrange + SetupMocks(); + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Single prerelease/dev should be kept + Assert.NotNull(result); + Assert.Empty(result); + } + + #endregion + + #region Prod Release Tests - Semantic Versioning + + [Fact] + public void FilterPackagesToDeleteBySemanticVersion_WithOnlyProdReleases_ShouldReturnEmpty() + { + // Arrange - CLEAN_PROD_VERSIONS not set, defaults to false + SetupMocks(); + var packages = new List + { + new() { Id = 1, Name = "test", Version = "1.0.0.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-30) }, + new() { Id = 2, Name = "test", Version = "1.1.0.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-20) }, + new() { Id = 3, Name = "test", Version = "1.2.0.1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-10) } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Prod releases should be protected by default (CLEAN_PROD_VERSIONS=false) + Assert.Empty(result); + Assert.DoesNotContain(result, p => p.Id == 1 || p.Id == 2 || p.Id == 3); + } + + [Fact] + public void FilterPackagesToDeleteBySemanticVersion_WithProdReleasesAndCleaningEnabled_ShouldDeleteOldest() + { + // Arrange + SetupMocks(); + _configurationMock.Setup(c => c["CLEAN_PROD_VERSIONS"]).Returns("true"); + _configurationMock.Setup(c => c["KEEP_PROD_VERSIONS"]).Returns("2"); + + var packages = new List + { + new() { Id = 1, Name = "test", Version = "1.0.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-30) }, + new() { Id = 2, Name = "test", Version = "1.1.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-20) }, + new() { Id = 3, Name = "test", Version = "2.0.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-10) }, + new() { Id = 4, Name = "test", Version = "2.1.0.0", Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Should delete oldest 2 (1.0.0.0 and 1.1.0.0), keep newest 2 (2.0.0.0 and 2.1.0.0) + Assert.Equal(2, result.Count); + Assert.Contains(result, p => p.Id == 1); + Assert.Contains(result, p => p.Id == 2); + Assert.DoesNotContain(result, p => p.Id == 3 || p.Id == 4); + } + + [Fact] + public void FilterPackagesToDeleteBySemanticVersion_WithMultipleProdVersions_ShouldKeepNewest() + { + // Arrange + SetupMocks(); + var packages = new List + { + new() { Id = 1, Name = "test", Version = "1.0.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-30) }, + new() { Id = 2, Name = "test", Version = "1.0.1.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-20) }, + new() { Id = 3, Name = "test", Version = "2.0.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-10) }, + new() { Id = 4, Name = "test", Version = "2.1.0.0", Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Default is CLEAN_PROD_VERSIONS=false, so nothing should be deleted + Assert.Empty(result); + } + + #endregion + + #region Prerelease Tests - Semantic Versioning + + [Fact] + public void FilterPackagesToDeleteBySemanticVersion_WithPrereleaseVersions_ShouldKeepLatest() + { + // Arrange - Default KEEP_PRERELEASE_VERSIONS=3 + SetupMocks(); + var packages = new List + { + new() { Id = 1, Name = "test", Version = "1.0.0-alpha", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-5) }, + new() { Id = 2, Name = "test", Version = "1.0.0-beta", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-4) }, + new() { Id = 3, Name = "test", Version = "1.0.0-rc1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-3) }, + new() { Id = 4, Name = "test", Version = "1.0.0-preview", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-2) } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Should delete oldest 1 (alpha, id=1), keep newest 3 (beta, rc1, preview) + Assert.Single(result); + Assert.Contains(result, p => p.Id == 1); // alpha is oldest, should be deleted + Assert.DoesNotContain(result, p => p.Id == 2 || p.Id == 3 || p.Id == 4); // Keep newest 3 + } + + [Fact] + public void FilterPackagesToDeleteBySemanticVersion_WithManyPrereleases_ShouldEnforceLimit() + { + // Arrange - Custom KEEP_PRERELEASE_VERSIONS=2 + SetupMocks(); + _configurationMock.Setup(c => c["KEEP_PRERELEASE_VERSIONS"]).Returns("2"); + + var packages = new List(); + for (int i = 0; i < 10; i++) + { + packages.Add(new GiteaPackage + { + Id = i, + Name = "test", + Version = $"1.0.0-beta{i}", + Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-(10 - i)) + }); + } + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Should delete 8 oldest, keep only 2 newest (Id=8,9) + Assert.Equal(8, result.Count); + Assert.DoesNotContain(result, p => p.Id >= 8); + Assert.Contains(result, p => p.Id < 8); + } + + [Fact] + public void FilterPackagesToDeleteBySemanticVersion_WithMixedPrereleases_ShouldOrderByVersion() + { + // Arrange + SetupMocks(); + var packages = new List + { + new() { Id = 1, Name = "test", Version = "1.0.0-beta", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-10) }, + new() { Id = 2, Name = "test", Version = "2.0.0-beta", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-5) }, + new() { Id = 3, Name = "test", Version = "1.5.0-alpha", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-3) } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Default keep 3, so all should be kept + Assert.Empty(result); + } + + #endregion + + #region Dev Release Tests - Semantic Versioning + + [Fact] + public void FilterPackagesToDeleteBySemanticVersion_WithDevVersions_ShouldKeepLatest() + { + // Arrange - Default KEEP_DEV_VERSIONS=5 + SetupMocks(); + var packages = new List + { + new() { Id = 1, Name = "test", Version = "1.0.0-dev", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-5) }, + new() { Id = 2, Name = "test", Version = "1.0.1-dev", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-4) }, + new() { Id = 3, Name = "test", Version = "2.0.0-dev", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-3) } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Default keep 5, so all 3 should be kept + Assert.Empty(result); + } + + [Fact] + public void FilterPackagesToDeleteBySemanticVersion_WithManyDevReleases_ShouldEnforceLimit() + { + // Arrange - Custom KEEP_DEV_VERSIONS=3 + SetupMocks(); + _configurationMock.Setup(c => c["KEEP_DEV_VERSIONS"]).Returns("3"); + + var packages = new List(); + for (int i = 0; i < 10; i++) + { + packages.Add(new GiteaPackage + { + Id = i, + Name = "test", + Version = $"1.0.{i}-dev", + Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-i) + }); + } + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Should delete 7 oldest, keep only 3 newest (Id=7,8,9 are newest by CreatedAt) + Assert.Equal(7, result.Count); + Assert.DoesNotContain(result, p => p.Id >= 7); // Keep newest 3 + Assert.Contains(result, p => p.Id < 7); // Delete oldest 7 + } + + [Fact] + public void FilterPackagesToDeleteBySemanticVersion_WithManyDevReleases_ShouldIdentifyAll() + { + // Arrange + SetupMocks(); + var packages = new List(); + for (int i = 0; i < 20; i++) + { + packages.Add(new GiteaPackage + { + Id = i, + Name = "test", + Version = $"1.0.{i}-dev", + Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-i) + }); + } + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Default keep 5, so 15 should be deleted + Assert.Equal(15, result.Count); + Assert.DoesNotContain(result, p => p.Id >= 15); // Keep newest 5 (Id 15-19) + Assert.Contains(result, p => p.Id < 15); + } + + #endregion + + #region Mixed Release Types - Semantic Versioning + + [Fact] + public void FilterPackagesToDeleteBySemanticVersion_WithMixedReleaseTypes_ShouldSeparateAndCleanCorrectly() + { + // Arrange - CLEAN_PROD_VERSIONS=false, KEEP_PRERELEASE_VERSIONS=2, KEEP_DEV_VERSIONS=2 + SetupMocks(); + _configurationMock.Setup(c => c["KEEP_PRERELEASE_VERSIONS"]).Returns("2"); + _configurationMock.Setup(c => c["KEEP_DEV_VERSIONS"]).Returns("2"); + + var packages = new List + { + // Prod releases (should not be deleted - CLEAN_PROD_VERSIONS=false) + new() { Id = 1, Name = "test", Version = "1.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-30) }, + new() { Id = 2, Name = "test", Version = "1.1.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-25) }, + // Prerelease (keep 2 newest) + new() { Id = 3, Name = "test", Version = "1.2.0-alpha", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-20) }, + new() { Id = 4, Name = "test", Version = "1.2.0-beta", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-15) }, + new() { Id = 5, Name = "test", Version = "1.2.1-rc", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-10) }, + // Dev (keep 2 newest) + new() { Id = 6, Name = "test", Version = "1.2.1-dev", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-5) }, + new() { Id = 7, Name = "test", Version = "1.2.2-dev", Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert + Assert.Equal(1, result.Count); // Should delete 1 prerelease (alpha), 0 dev (keep both) + Assert.DoesNotContain(result, p => p.Id <= 2); // Prod not deleted + Assert.DoesNotContain(result, p => p.Id >= 6); // Keep 2 latest dev + } + + [Fact] + public void FilterPackagesToDeleteBySemanticVersion_WithLargeSet_ShouldHandleCorrectly() + { + // Arrange + SetupMocks(); + _configurationMock.Setup(c => c["CLEAN_PROD_VERSIONS"]).Returns("false"); + _configurationMock.Setup(c => c["KEEP_PRERELEASE_VERSIONS"]).Returns("3"); + _configurationMock.Setup(c => c["KEEP_DEV_VERSIONS"]).Returns("5"); + + var packages = new List(); + + // Add prod releases + for (int i = 0; i < 5; i++) + { + packages.Add(new GiteaPackage + { + Id = i, + Name = "test", + Version = $"1.{i}.0", + Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-(5 - i)) + }); + } + + // Add prerelease + for (int i = 5; i < 15; i++) + { + packages.Add(new GiteaPackage + { + Id = i, + Name = "test", + Version = $"2.0.{i - 5}-beta", + Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-(15 - i)) + }); + } + + // Add dev + for (int i = 15; i < 30; i++) + { + packages.Add(new GiteaPackage + { + Id = i, + Name = "test", + Version = $"2.1.{i - 15}-dev", + Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-(30 - i)) + }); + } + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert + Assert.NotNull(result); + // Prod releases should never be deleted + Assert.DoesNotContain(result, p => p.Id < 5); + // Should delete 7 oldest prerelease (keep 3 newest) + var prereleaseDeleted = result.Where(p => p.Id >= 5 && p.Id < 15).ToList(); + Assert.Equal(7, prereleaseDeleted.Count); + // Should delete 10 oldest dev (keep 5 newest) + var devDeleted = result.Where(p => p.Id >= 15).ToList(); + Assert.Equal(10, devDeleted.Count); + } + + #endregion + + #region Edge Cases - Semantic Versioning + + [Theory] + [InlineData("0.0.0.0")] + [InlineData("99.99.99.99")] + [InlineData("100.0.0.0")] // Boundary case - might be detected as date version + public void FilterPackagesToDeleteBySemanticVersion_WithBoundaryVersions_ShouldHandleCorrectly(string version) + { + // Arrange + SetupMocks(); + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Should not throw, and boundary versions should be handled properly + Assert.NotNull(result); + // Versions 0.0.0.0 and 99.99.99.99 are semantic, 100.0.0.0 is likely treated as date (will fail date validation) + } + + [Fact] + public void FilterPackagesToDeleteBySemanticVersion_WithVersionsHavingDifferentLengths_ShouldHandleCorrectly() + { + // Arrange + SetupMocks(); + var packages = new List + { + new() { Id = 1, Name = "test", Version = "1", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-5) }, + new() { Id = 2, Name = "test", Version = "1.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-4) }, + new() { Id = 3, Name = "test", Version = "1.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-3) }, + new() { Id = 4, Name = "test", Version = "1.0.0.0", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-2) } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Should not throw and should not delete any prod versions + Assert.NotNull(result); + // CLEAN_PROD_VERSIONS=false by default, so all versions should be kept + // Id=1 is invalid (only 1 component), ids 2-4 are valid but prod + var validVersions = new[] { 2, 3, 4 }; + foreach (var id in validVersions) + { + Assert.DoesNotContain(result, p => p.Id == id); + } + } + + [Fact] + public void FilterPackagesToDeleteBySemanticVersion_WithZeroKeepCount_ShouldKeepAll() + { + // Arrange - KEEP_DEV_VERSIONS=0 means keep all + SetupMocks(); + _configurationMock.Setup(c => c["KEEP_DEV_VERSIONS"]).Returns("0"); + + var packages = new List(); + for (int i = 0; i < 10; i++) + { + packages.Add(new GiteaPackage + { + Id = i, + Name = "test", + Version = $"1.0.{i}-dev", + Type = "docker", + CreatedAt = DateTime.UtcNow.AddDays(-i) + }); + } + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Should keep all (0 means infinite/keep all) + Assert.Empty(result); + Assert.DoesNotContain(result, p => p.Id >= 0); // No packages should be deleted + } + + [Fact] + public void FilterPackagesToDeleteBySemanticVersion_WithInvalidConfigValues_ShouldUseDefaults() + { + // Arrange - Set invalid config values + SetupMocks(); + _configurationMock.Setup(c => c["KEEP_DEV_VERSIONS"]).Returns("invalid"); + _configurationMock.Setup(c => c["CLEAN_PROD_VERSIONS"]).Returns("maybe"); + + var packages = new List + { + new() { Id = 1, Name = "test", Version = "1.0.0-dev", Type = "docker", CreatedAt = DateTime.UtcNow.AddDays(-10) }, + new() { Id = 2, Name = "test", Version = "1.0.1-dev", Type = "docker", CreatedAt = DateTime.UtcNow }, + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Should use defaults (KEEP_DEV_VERSIONS=5, CLEAN_PROD_VERSIONS=false) + // With 2 packages and keep 5, no deletion should happen + Assert.Empty(result); + Assert.DoesNotContain(result, p => p.Id == 1 || p.Id == 2); + } + + #endregion +} + diff --git a/sh.actions.package-cleanup.Tests/Service/PackageServiceVersionDetectionTests.cs b/sh.actions.package-cleanup.Tests/Service/PackageServiceVersionDetectionTests.cs new file mode 100644 index 0000000..f8eb400 --- /dev/null +++ b/sh.actions.package-cleanup.Tests/Service/PackageServiceVersionDetectionTests.cs @@ -0,0 +1,349 @@ +using Microsoft.Extensions.Configuration; +using Moq; +using sh.actions.package_cleanup.Models; +using sh.actions.package_cleanup.Service; +using Xunit; + +namespace sh.actions.package_cleanup.Tests.Service; + +public class PackageServiceVersionDetectionTests +{ + private readonly IPackageService _packageService; + private readonly Mock _configurationMock; + private readonly Mock _giteaPackageServiceMock; + + public PackageServiceVersionDetectionTests() + { + _configurationMock = new Mock(); + _giteaPackageServiceMock = new Mock(); + _packageService = new PackageService(_configurationMock.Object, _giteaPackageServiceMock.Object); + } + + #region Release Type Detection Tests + + [Theory] + [InlineData("1.0.0")] + [InlineData("2.5.3")] + [InlineData("10.20.30")] + [InlineData("2025.2.14.1")] + public void FilterPackagesToDelete_WithProdReleases_ShouldIdentifyAsNonDeletable(string version) + { + // Arrange + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Prod releases should not be in delete list (CLEAN_PROD_VERSIONS=false by default) + Assert.DoesNotContain(result, p => p.Id == 1); + Assert.Empty(result); // No packages should be deleted without CLEAN_PROD_VERSIONS=true + } + + [Theory] + [InlineData("1.0.0-alpha")] + [InlineData("1.0.0-ALPHA")] + [InlineData("2.5.3-alpha123")] + [InlineData("1.0.0-beta")] + [InlineData("1.0.0-BETA")] + [InlineData("1.0.0-rc")] + [InlineData("1.0.0-RC1")] + [InlineData("1.0.0-preview")] + [InlineData("1.0.0-PREVIEW")] + public void FilterPackagesToDelete_WithPrereleaseVersions_ShouldIdentifyCorrectly(string version) + { + // Arrange + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Prerelease should be kept (default KEEP_PRERELEASE_VERSIONS=3, so 1 package is kept) + Assert.NotNull(result); + Assert.Empty(result); // Single prerelease should be kept as it's within the limit + } + + [Theory] + [InlineData("1.0.0-dev")] + [InlineData("1.0.0-DEV")] + [InlineData("2.5.3-dev123")] + [InlineData("1.0.0-development")] + [InlineData("1.0.0-DEV")] + public void FilterPackagesToDelete_WithDevVersions_ShouldIdentifyCorrectly(string version) + { + // Arrange + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Dev version should be kept (default KEEP_DEV_VERSIONS=5, so 1 package is kept) + Assert.NotNull(result); + Assert.Empty(result); // Single dev version should be kept as it's within the limit + } + + #endregion + + #region Semantic Version Detection Tests + + [Theory] + [InlineData("1.0")] + [InlineData("1.0.0")] + [InlineData("1.0.0.0")] + [InlineData("2.5")] + [InlineData("2.5.3")] + [InlineData("2.5.3.100")] + [InlineData("0.0.0.0")] + [InlineData("99.99.99.99")] + public void FilterPackagesToDelete_WithValidSemanticVersions_ShouldProcess(string version) + { + // Arrange + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Should process without throwing and not delete prod releases + Assert.NotNull(result); + Assert.DoesNotContain(result, p => p.Id == 1); // Prod version should not be deleted + } + + [Theory] + [InlineData("1")] // Only 1 component + [InlineData("1.0.0.0.0")] // 5 components + [InlineData("1.a.0.0")] // Non-numeric + [InlineData("a.b.c.d")] // All non-numeric + public void FilterPackagesToDelete_WithInvalidSemanticVersions_ShouldSkip(string version) + { + // Arrange + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }, + new() { Id = 2, Name = "test", Version = "2.0.0.0", Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Invalid version should be skipped, only valid semantic version (id=2) is processed + Assert.NotNull(result); + // Should not crash and should only consider the valid version + Assert.DoesNotContain(result, p => p.Id == 2); // Valid prod should not be deleted + } + + [Theory] + [InlineData("101.0.0.0")] + [InlineData("200.5.3.100")] + [InlineData("1000.0.0.0")] + public void FilterPackagesToDelete_WithLargeMajorVersion_ShouldTreatAsDateVersion(string version) + { + // Arrange + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Large major versions (>100) are detected as date versions, not semantic + // These will fail date validation (invalid month/day) and be skipped + Assert.NotNull(result); + } + + #endregion + + #region Date Version Detection Tests + + [Theory] + [InlineData("2024.1.1.1")] + [InlineData("2025.2.14.1")] + [InlineData("2026.12.31.999")] + [InlineData("2000.6.15.100")] + [InlineData("1999.1.1.1")] + public void FilterPackagesToDelete_WithValidDateVersions_ShouldProcess(string version) + { + // Arrange + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Should process without throwing and not delete prod date versions + Assert.NotNull(result); + Assert.DoesNotContain(result, p => p.Id == 1); // Prod date version should not be deleted + } + + [Theory] + [InlineData("2025.1.1")] // 3 components (missing build) + [InlineData("2025.1.1.1.1")] // 5 components + [InlineData("2025.13.1.1")] // Month out of range + [InlineData("2025.0.1.1")] // Month zero + [InlineData("2025.2.32.1")] // Day out of range + [InlineData("2025.2.0.1")] // Day zero + [InlineData("999.2.14.1")] // Year too small + [InlineData("3000.2.14.1")] // Year too large + [InlineData("2025.a.14.1")] // Non-numeric month + public void FilterPackagesToDelete_WithInvalidDateVersions_ShouldSkip(string version) + { + // Arrange + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow }, + new() { Id = 2, Name = "test", Version = "1.0.0.0", Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Invalid date version should be skipped, valid semantic version (id=2) is processed as prod + Assert.NotNull(result); + Assert.DoesNotContain(result, p => p.Id == 2); // Valid semantic prod should not be deleted + } + + [Theory] + [InlineData("1000.1.1.1")] // Minimum valid year + [InlineData("2999.12.31.999")] // Maximum valid year + [InlineData("2025.1.1.1")] // January 1 + [InlineData("2025.12.31.1")] // December 31 + public void FilterPackagesToDelete_WithDateVersionBoundaries_ShouldProcess(string version) + { + // Arrange + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Boundary date versions should be recognized as valid + Assert.NotNull(result); + Assert.DoesNotContain(result, p => p.Id == 1); // Prod date version should not be deleted + } + + #endregion + + #region Case Sensitivity Tests + + [Theory] + [InlineData("1.0.0-alpha")] + [InlineData("1.0.0-ALPHA")] + [InlineData("1.0.0-Alpha")] + [InlineData("1.0.0-AlPhA")] + public void FilterPackagesToDelete_WithDifferentCasePrerelease_ShouldHandleCorrectly(string version) + { + // Arrange + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - All case variations should be recognized as prerelease + Assert.NotNull(result); + Assert.Empty(result); // Single prerelease should be kept + } + + [Theory] + [InlineData("1.0.0-dev")] + [InlineData("1.0.0-DEV")] + [InlineData("1.0.0-Dev")] + [InlineData("1.0.0-DeV")] + public void FilterPackagesToDelete_WithDifferentCaseDev_ShouldHandleCorrectly(string version) + { + // Arrange + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - All case variations should be recognized as dev version + Assert.NotNull(result); + Assert.Empty(result); // Single dev version should be kept + } + + #endregion + + #region Suffix/Identifier Tests + + [Theory] + [InlineData("1.0.0-alpha1")] + [InlineData("1.0.0-alpha.1")] + [InlineData("1.0.0-beta2")] + [InlineData("1.0.0-rc.1")] + [InlineData("2025.2.14.1-dev.1")] + [InlineData("2025.2.14.1-dev123")] + public void FilterPackagesToDelete_WithCompoundSuffixes_ShouldIdentifyCorrectly(string version) + { + // Arrange + var packages = new List + { + new() { Id = 1, Name = "test", Version = version, Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Compound suffixes should be recognized and not cause errors + Assert.NotNull(result); + Assert.Empty(result); // Single prerelease/dev should be kept + } + + #endregion + + #region Empty and Null Tests + + [Fact] + public void FilterPackagesToDelete_WithEmptyList_ShouldReturnEmpty() + { + // Arrange + var packages = new List(); + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Empty input should return empty result + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void FilterPackagesToDelete_WithNullableInputs_ShouldHandleGracefully() + { + // Arrange + var packages = new List + { + new() { Id = 1, Name = "test", Version = "1.0.0", Type = "docker", CreatedAt = DateTime.UtcNow } + }; + + // Act + var result = _packageService.FilterPackagesToDelete(packages); + + // Assert - Should handle without throwing and not delete prod + Assert.NotNull(result); + Assert.Empty(result); // Prod should not be deleted + } + + #endregion +} + diff --git a/sh.actions.package-cleanup.Tests/sh.actions.package-cleanup.Tests.csproj b/sh.actions.package-cleanup.Tests/sh.actions.package-cleanup.Tests.csproj new file mode 100644 index 0000000..717112f --- /dev/null +++ b/sh.actions.package-cleanup.Tests/sh.actions.package-cleanup.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + true + enable + enable + sh.actions.package_cleanup.Tests + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/sh.actions.package-cleanup/ConfigurationExtension.cs b/sh.actions.package-cleanup/ConfigurationExtension.cs new file mode 100644 index 0000000..d6ca7ca --- /dev/null +++ b/sh.actions.package-cleanup/ConfigurationExtension.cs @@ -0,0 +1,26 @@ +namespace sh.actions.package_cleanup; + +public static class ConfigurationExtension +{ + private static void EnsureVariable(IConfiguration configuration, string key) + { + var value = configuration[key]; + if (string.IsNullOrEmpty(value)) + { + throw new InvalidOperationException($"Configuration variable '{key}' is required but was not found."); + } + } + + public static IConfigurationBuilder EnsureGiteaConfig(this IConfigurationBuilder builder) + { + var configuration = builder.Build(); + + EnsureVariable(configuration, "URL"); + EnsureVariable(configuration, "OWNER"); + EnsureVariable(configuration, "TYPE"); + EnsureVariable(configuration, "NAME"); + EnsureVariable(configuration, "API_TOKEN"); + + return builder; + } +} \ No newline at end of file diff --git a/sh.actions/sh.actions.package-cleanup/Models/GiteaPackage.cs b/sh.actions.package-cleanup/Models/GiteaPackage.cs similarity index 100% rename from sh.actions/sh.actions.package-cleanup/Models/GiteaPackage.cs rename to sh.actions.package-cleanup/Models/GiteaPackage.cs diff --git a/sh.actions.package-cleanup/Program.cs b/sh.actions.package-cleanup/Program.cs new file mode 100644 index 0000000..be417e2 --- /dev/null +++ b/sh.actions.package-cleanup/Program.cs @@ -0,0 +1,21 @@ +using sh.actions.package_cleanup; +using sh.actions.package_cleanup.Service; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Configuration + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) + .AddJsonFile("appsettings.local.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .EnsureGiteaConfig(); + +builder.Services + .AddSingleton() + .AddScoped() + .AddHostedService() + .AddHttpClient(); + + +var host = builder.Build(); + +host.Run(); \ No newline at end of file diff --git a/sh.actions.package-cleanup/Properties/launchSettings.json b/sh.actions.package-cleanup/Properties/launchSettings.json new file mode 100644 index 0000000..f4179fd --- /dev/null +++ b/sh.actions.package-cleanup/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "sh.actions.package_cleanup": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/sh.actions.package-cleanup/Service/GiteaPackageService.cs b/sh.actions.package-cleanup/Service/GiteaPackageService.cs new file mode 100644 index 0000000..1efcff4 --- /dev/null +++ b/sh.actions.package-cleanup/Service/GiteaPackageService.cs @@ -0,0 +1,95 @@ +namespace sh.actions.package_cleanup.Service; + +using System.Net.Http.Json; +using Microsoft.Extensions.Logging; +using sh.actions.package_cleanup.Models; + +public class GiteaPackageService( + ILogger logger, + IConfiguration configuration, + HttpClient httpClient) + : IGiteaPackageService +{ + public async Task> GetPackagesByOwnerAsync(CancellationToken cancellationToken = default) + { + try + { + var baseUrl = + $"{configuration["URL"]?.TrimEnd('/')}/packages/{configuration["OWNER"]}/{configuration["TYPE"]}/{configuration["NAME"]}"; + var allPackages = new List(); + var page = 1; + + while (true) + { + var url = $"{baseUrl}?page={page}"; + + logger.LogInformation("Fetching packages from Gitea: {Url}", url); + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("Authorization", $"token {configuration["API_TOKEN"]}"); + + var response = await httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var packages = + await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken) + ?? []; + + if (packages.Count == 0) + { + break; + } + + allPackages.AddRange(packages); + logger.LogInformation("Fetched {Count} packages from page {Page}", packages.Count, page); + + page++; + } + + logger.LogInformation("Successfully fetched {Count} packages in total", allPackages.Count); + + return allPackages; + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Error fetching packages from Gitea"); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error fetching packages"); + throw; + } + } + + public async Task DeletePackage(GiteaPackage package, CancellationToken cancellationToken = default) + { + try + { + var url = + $"{configuration["URL"]?.TrimEnd('/')}/packages/{configuration["OWNER"]}/{package.Type}/{package.Name}/{package.Version}"; + logger.LogInformation("Deleting package {PackageName} (ID: {PackageId}) from Gitea: {Url}", package.Name, + package.Id, url); + + var request = new HttpRequestMessage(HttpMethod.Delete, url); + request.Headers.Add("Authorization", $"token {configuration["API_TOKEN"]}"); + + var response = await httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + logger.LogInformation("Successfully deleted package {PackageName} (ID: {PackageId})", package.Name, + package.Id); + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Error deleting package {PackageName} (ID: {PackageId}) from Gitea", package.Name, + package.Id); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error deleting package {PackageName} (ID: {PackageId})", package.Name, + package.Id); + throw; + } + } +} \ No newline at end of file diff --git a/sh.actions/sh.actions.package-cleanup/Service/IGiteaPackageService.cs b/sh.actions.package-cleanup/Service/IGiteaPackageService.cs similarity index 71% rename from sh.actions/sh.actions.package-cleanup/Service/IGiteaPackageService.cs rename to sh.actions.package-cleanup/Service/IGiteaPackageService.cs index bb3017e..7df007e 100644 --- a/sh.actions/sh.actions.package-cleanup/Service/IGiteaPackageService.cs +++ b/sh.actions.package-cleanup/Service/IGiteaPackageService.cs @@ -5,4 +5,5 @@ using sh.actions.package_cleanup.Models; public interface IGiteaPackageService { Task> GetPackagesByOwnerAsync(CancellationToken cancellationToken = default); + Task DeletePackage(GiteaPackage package, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/sh.actions.package-cleanup/Service/IPackageService.cs b/sh.actions.package-cleanup/Service/IPackageService.cs new file mode 100644 index 0000000..453c881 --- /dev/null +++ b/sh.actions.package-cleanup/Service/IPackageService.cs @@ -0,0 +1,9 @@ +using sh.actions.package_cleanup.Models; + +namespace sh.actions.package_cleanup.Service; + +public interface IPackageService +{ + Task> GetFilteredPackages(); + List FilterPackagesToDelete(List packages); +} \ No newline at end of file diff --git a/sh.actions.package-cleanup/Service/PackageService.cs b/sh.actions.package-cleanup/Service/PackageService.cs new file mode 100644 index 0000000..00df577 --- /dev/null +++ b/sh.actions.package-cleanup/Service/PackageService.cs @@ -0,0 +1,311 @@ +using sh.actions.package_cleanup.Models; + +namespace sh.actions.package_cleanup.Service; + +public class PackageService(IConfiguration configuration, IGiteaPackageService packageService) : IPackageService +{ + public async Task> GetFilteredPackages() + { + var packages = (await packageService.GetPackagesByOwnerAsync()).ToList(); + return packages; + } + + public List FilterPackagesToDelete(List packages) + { + var packagesToDelete = new List(); + + // Group packages by versioning variant + var semanticVersionPackages = packages.Where(p => IsSemanticVersion(p.Version)).ToList(); + var dateVersionPackages = packages.Where(p => IsDateVersion(p.Version)).ToList(); + + // Process each group with their respective variant logic + packagesToDelete.AddRange(FilterPackagesToDeleteBySemanticVersion(semanticVersionPackages)); + packagesToDelete.AddRange(FilterPackagesToDeleteByDateVersion(dateVersionPackages)); + + return packagesToDelete; + } + + private List FilterPackagesToDeleteBySemanticVersion(List packages) + { + var packagesToDelete = new List(); + + // Group packages by release type + // Note: Dev releases must be checked before prerelease since -dev is a suffix + var devPackages = packages.Where(p => IsDevRelease(p.Version)) + .OrderByDescending(p => ParseSemanticVersion(p.Version)) + .ThenByDescending(p => p.CreatedAt) + .ToList(); + var prereleasePackages = packages.Where(p => IsPrereleaseRelease(p.Version) && !IsDevRelease(p.Version)) + .OrderByDescending(p => ParseSemanticVersion(p.Version)) + .ThenByDescending(p => p.CreatedAt) + .ToList(); + var prodPackages = packages.Where(p => IsProdRelease(p.Version)) + .OrderByDescending(p => ParseSemanticVersion(p.Version)) + .ThenByDescending(p => p.CreatedAt) + .ToList(); + + // Apply cleanup rules for each type + packagesToDelete.AddRange(FilterProdReleases(prodPackages)); + packagesToDelete.AddRange(FilterPrereleaseReleases(prereleasePackages)); + packagesToDelete.AddRange(FilterDevReleases(devPackages)); + + return packagesToDelete; + } + + private List FilterPackagesToDeleteByDateVersion(List packages) + { + var packagesToDelete = new List(); + + // Group packages by release type + // Note: Dev releases must be checked before prerelease since -dev is a suffix + var devPackages = packages.Where(p => IsDevRelease(p.Version)) + .OrderByDescending(p => ParseDateVersion(p.Version)) + .ThenByDescending(p => p.CreatedAt) + .ToList(); + var prereleasePackages = packages.Where(p => IsPrereleaseRelease(p.Version) && !IsDevRelease(p.Version)) + .OrderByDescending(p => ParseDateVersion(p.Version)) + .ThenByDescending(p => p.CreatedAt) + .ToList(); + var prodPackages = packages.Where(p => IsProdRelease(p.Version)) + .OrderByDescending(p => ParseDateVersion(p.Version)) + .ThenByDescending(p => p.CreatedAt) + .ToList(); + + // Apply cleanup rules for each type + packagesToDelete.AddRange(FilterProdReleasesDateVersion(prodPackages)); + packagesToDelete.AddRange(FilterPrereleaseReleasesDateVersion(prereleasePackages)); + packagesToDelete.AddRange(FilterDevReleasesDateVersion(devPackages)); + + return packagesToDelete; + } + + // Prod release methods for semantic versioning + private List FilterProdReleases(List packages) + { + // Check if prod cleaning is enabled + if (!GetBoolConfigValue("CLEAN_PROD_VERSIONS", false)) + return new List(); + + // Get keep count + var keepCount = GetIntConfigValue("KEEP_PROD_VERSIONS", 5); + + // If keepCount <= 0, keep all + if (keepCount <= 0) + return new List(); + + // Packages are already sorted descending by version + // Keep top N, delete rest + return packages.Skip(keepCount).ToList(); + } + + // Prerelease methods for semantic versioning + private List FilterPrereleaseReleases(List packages) + { + // Get keep count + var keepCount = GetIntConfigValue("KEEP_PRERELEASE_VERSIONS", 3); + + // If keepCount <= 0, keep all + if (keepCount <= 0) + return new List(); + + // Packages are already sorted descending by version + // Keep top N, delete rest + return packages.Skip(keepCount).ToList(); + } + + // Dev release methods for semantic versioning + private List FilterDevReleases(List packages) + { + // Get keep count + var keepCount = GetIntConfigValue("KEEP_DEV_VERSIONS", 5); + + // If keepCount <= 0, keep all + if (keepCount <= 0) + return new List(); + + // Packages are already sorted descending by version + // Keep top N, delete rest + return packages.Skip(keepCount).ToList(); + } + + // Prod release methods for date-based versioning + private List FilterProdReleasesDateVersion(List packages) + { + // Check if prod cleaning is enabled + if (!GetBoolConfigValue("CLEAN_PROD_VERSIONS", false)) + return new List(); + + // Get keep count + var keepCount = GetIntConfigValue("KEEP_PROD_VERSIONS", 5); + + // If keepCount <= 0, keep all + if (keepCount <= 0) + return new List(); + + // Packages are already sorted descending by version + // Keep top N, delete rest + return packages.Skip(keepCount).ToList(); + } + + // Prerelease methods for date-based versioning + private List FilterPrereleaseReleasesDateVersion(List packages) + { + // Get keep count + var keepCount = GetIntConfigValue("KEEP_PRERELEASE_VERSIONS", 3); + + // If keepCount <= 0, keep all + if (keepCount <= 0) + return new List(); + + // Packages are already sorted descending by version + // Keep top N, delete rest + return packages.Skip(keepCount).ToList(); + } + + // Dev release methods for date-based versioning + private List FilterDevReleasesDateVersion(List packages) + { + // Get keep count + var keepCount = GetIntConfigValue("KEEP_DEV_VERSIONS", 5); + + // If keepCount <= 0, keep all + if (keepCount <= 0) + return new List(); + + // Packages are already sorted descending by version + // Keep top N, delete rest + return packages.Skip(keepCount).ToList(); + } + + // Helper methods to identify release types + private bool IsProdRelease(string version) + { + // Prod versions typically don't have pre-release identifiers (alpha, beta, rc, dev, etc.) + return !version.Contains("-", StringComparison.OrdinalIgnoreCase); + } + + private bool IsPrereleaseRelease(string version) + { + // Prerelease versions contain identifiers like -alpha, -beta, -rc, -preview + // But NOT -dev, which is a separate release type + var lowerVersion = version.ToLowerInvariant(); + return (lowerVersion.Contains("-alpha") || lowerVersion.Contains("-beta") || + lowerVersion.Contains("-rc") || lowerVersion.Contains("-preview")) && !lowerVersion.Contains("-dev"); + } + + private bool IsDevRelease(string version) + { + // Dev versions contain identifiers like -dev or similar + return version.ToLowerInvariant().Contains("-dev"); + } + + // Helper methods to identify versioning variant + private bool IsSemanticVersion(string version) + { + // Semantic versioning: major.minor.micro.build with typically smaller numbers + // Examples: 1.2.3.4, 2.0.1.100 + var versionPart = version.Split('-')[0]; + var parts = versionPart.Split('.').Where(p => !string.IsNullOrEmpty(p)).ToArray(); + + if (parts.Length < 2 || parts.Length > 4) + return false; + + // Try to parse all parts as numbers + if (!parts.All(p => int.TryParse(p, out var num))) + return false; + + // Semantic versioning typically has smaller major version numbers (0-20 range) + if (int.TryParse(parts[0], out var major) && major > 100) + return false; // Likely a date version if first number > 100 + + return true; + } + + private bool IsDateVersion(string version) + { + // Date-based versioning: year.month.day.build + // Examples: 2024.12.25.1, 2025.2.14.100 + var versionPart = version.Split('-')[0]; + var parts = versionPart.Split('.').Where(p => !string.IsNullOrEmpty(p)).ToArray(); + + if (parts.Length != 4) + return false; + + // Try to parse all parts as numbers + if (!parts.All(p => int.TryParse(p, out var num))) + return false; + + // Date version should have year > 1000 and < 3000 + if (!int.TryParse(parts[0], out var year) || year < 1000 || year > 3000) + return false; + + // Month should be 1-12 + if (!int.TryParse(parts[1], out var month) || month < 1 || month > 12) + return false; + + // Day should be 1-31 + if (!int.TryParse(parts[2], out var day) || day < 1 || day > 31) + return false; + + return true; + } + + // Version parsing methods + private (int major, int minor, int micro, int build) ParseSemanticVersion(string version) + { + // Remove suffix if present (e.g., "-alpha", "-dev") + var versionPart = version.Split('-')[0]; + var parts = versionPart.Split('.').Select(p => int.TryParse(p, out var num) ? num : 0).ToArray(); + + return ( + parts.Length > 0 ? parts[0] : 0, + parts.Length > 1 ? parts[1] : 0, + parts.Length > 2 ? parts[2] : 0, + parts.Length > 3 ? parts[3] : 0 + ); + } + + private (int year, int month, int day, int build) ParseDateVersion(string version) + { + // Remove suffix if present (e.g., "-alpha", "-dev") + var versionPart = version.Split('-')[0]; + var parts = versionPart.Split('.').Select(p => int.TryParse(p, out var num) ? num : 0).ToArray(); + + return ( + parts.Length > 0 ? parts[0] : 0, + parts.Length > 1 ? parts[1] : 0, + parts.Length > 2 ? parts[2] : 0, + parts.Length > 3 ? parts[3] : 0 + ); + } + + // Configuration helper methods + private int GetIntConfigValue(string key, int defaultValue) + { + var value = configuration[key]; + if (string.IsNullOrEmpty(value)) + return defaultValue; + + if (int.TryParse(value, out var result)) + return result; + + return defaultValue; + } + + private bool GetBoolConfigValue(string key, bool defaultValue) + { + var value = configuration[key]; + if (string.IsNullOrEmpty(value)) + return defaultValue; + + if (bool.TryParse(value, out var result)) + return result; + + // Handle common string representations + if (value.Equals("true", StringComparison.OrdinalIgnoreCase) || + value.Equals("1", StringComparison.OrdinalIgnoreCase)) + return true; + + return defaultValue; + } +} \ No newline at end of file diff --git a/sh.actions.package-cleanup/Worker.cs b/sh.actions.package-cleanup/Worker.cs new file mode 100644 index 0000000..d4d857f --- /dev/null +++ b/sh.actions.package-cleanup/Worker.cs @@ -0,0 +1,41 @@ +using sh.actions.package_cleanup.Models; +using sh.actions.package_cleanup.Service; + +namespace sh.actions.package_cleanup; + +public class Worker( + ILogger logger, + IConfiguration configuration, + IHostApplicationLifetime appLifetime, + IPackageService packageService, + IGiteaPackageService giteaPackageService +) : BackgroundService +{ + private async Task DeletePackages(List packages, CancellationToken cancellationToken = default) + { + if (configuration["DRY_RUN"]?.ToLower() == "true") + { + logger.LogInformation("Dry run enabled, not deleting packages"); + return; + } + + foreach (var giteaPackage in packages) + { + await giteaPackageService.DeletePackage(giteaPackage, cancellationToken); + } + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + var packages = await packageService.GetFilteredPackages(); + Console.WriteLine($"Found {packages.Count()} packages"); + + var packagesToDelete = packageService.FilterPackagesToDelete(packages); + Console.WriteLine($"Found {packagesToDelete.Count()} packages to delete"); + + await DeletePackages(packagesToDelete, cancellationToken); + + logger.LogInformation("Cleanup finished, stopping application"); + appLifetime.StopApplication(); + } +} \ No newline at end of file diff --git a/sh.actions.package-cleanup/appsettings.json b/sh.actions.package-cleanup/appsettings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/sh.actions.package-cleanup/appsettings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/sh.actions.package-cleanup/sh.actions.package-cleanup.csproj b/sh.actions.package-cleanup/sh.actions.package-cleanup.csproj new file mode 100644 index 0000000..09f4696 --- /dev/null +++ b/sh.actions.package-cleanup/sh.actions.package-cleanup.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + dotnet-sh.actions.package_cleanup-2b7a013f-ec22-4325-9832-0c9ca9b8ced9 + sh.actions.package_cleanup + + + + + + + diff --git a/sh.actions.sln b/sh.actions.sln index 2e8a884..af1b34c 100644 --- a/sh.actions.sln +++ b/sh.actions.sln @@ -1,16 +1,44 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "sh.actions.package-cleanup", "sh.actions/sh.actions.package-cleanup\sh.actions.package-cleanup.csproj", "{F4995B6A-2CA1-4CD4-AEAC-BE397AF1910A}" +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "sh.actions.package-cleanup", "sh.actions.package-cleanup\sh.actions.package-cleanup.csproj", "{2C97D0E2-CD3F-4468-B963-E1F0211C4D3A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "sh.actions.package-cleanup.Tests", "sh.actions.package-cleanup.Tests\sh.actions.package-cleanup.Tests.csproj", "{8F1A9B3E-5D2C-4A1F-B8E7-9C2D5F6A3B1C}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {F4995B6A-2CA1-4CD4-AEAC-BE397AF1910A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F4995B6A-2CA1-4CD4-AEAC-BE397AF1910A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F4995B6A-2CA1-4CD4-AEAC-BE397AF1910A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F4995B6A-2CA1-4CD4-AEAC-BE397AF1910A}.Release|Any CPU.Build.0 = Release|Any CPU + {2C97D0E2-CD3F-4468-B963-E1F0211C4D3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2C97D0E2-CD3F-4468-B963-E1F0211C4D3A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C97D0E2-CD3F-4468-B963-E1F0211C4D3A}.Debug|x64.ActiveCfg = Debug|Any CPU + {2C97D0E2-CD3F-4468-B963-E1F0211C4D3A}.Debug|x64.Build.0 = Debug|Any CPU + {2C97D0E2-CD3F-4468-B963-E1F0211C4D3A}.Debug|x86.ActiveCfg = Debug|Any CPU + {2C97D0E2-CD3F-4468-B963-E1F0211C4D3A}.Debug|x86.Build.0 = Debug|Any CPU + {2C97D0E2-CD3F-4468-B963-E1F0211C4D3A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2C97D0E2-CD3F-4468-B963-E1F0211C4D3A}.Release|Any CPU.Build.0 = Release|Any CPU + {2C97D0E2-CD3F-4468-B963-E1F0211C4D3A}.Release|x64.ActiveCfg = Release|Any CPU + {2C97D0E2-CD3F-4468-B963-E1F0211C4D3A}.Release|x64.Build.0 = Release|Any CPU + {2C97D0E2-CD3F-4468-B963-E1F0211C4D3A}.Release|x86.ActiveCfg = Release|Any CPU + {2C97D0E2-CD3F-4468-B963-E1F0211C4D3A}.Release|x86.Build.0 = Release|Any CPU + {8F1A9B3E-5D2C-4A1F-B8E7-9C2D5F6A3B1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F1A9B3E-5D2C-4A1F-B8E7-9C2D5F6A3B1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F1A9B3E-5D2C-4A1F-B8E7-9C2D5F6A3B1C}.Debug|x64.ActiveCfg = Debug|Any CPU + {8F1A9B3E-5D2C-4A1F-B8E7-9C2D5F6A3B1C}.Debug|x64.Build.0 = Debug|Any CPU + {8F1A9B3E-5D2C-4A1F-B8E7-9C2D5F6A3B1C}.Debug|x86.ActiveCfg = Debug|Any CPU + {8F1A9B3E-5D2C-4A1F-B8E7-9C2D5F6A3B1C}.Debug|x86.Build.0 = Debug|Any CPU + {8F1A9B3E-5D2C-4A1F-B8E7-9C2D5F6A3B1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F1A9B3E-5D2C-4A1F-B8E7-9C2D5F6A3B1C}.Release|Any CPU.Build.0 = Release|Any CPU + {8F1A9B3E-5D2C-4A1F-B8E7-9C2D5F6A3B1C}.Release|x64.ActiveCfg = Release|Any CPU + {8F1A9B3E-5D2C-4A1F-B8E7-9C2D5F6A3B1C}.Release|x64.Build.0 = Release|Any CPU + {8F1A9B3E-5D2C-4A1F-B8E7-9C2D5F6A3B1C}.Release|x86.ActiveCfg = Release|Any CPU + {8F1A9B3E-5D2C-4A1F-B8E7-9C2D5F6A3B1C}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal diff --git a/sh.actions.sln.DotSettings.user b/sh.actions.sln.DotSettings.user new file mode 100644 index 0000000..eb46bbe --- /dev/null +++ b/sh.actions.sln.DotSettings.user @@ -0,0 +1,7 @@ + + ForceIncluded + ForceIncluded + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> +</SessionState> + \ No newline at end of file diff --git a/sh.actions/sh.actions.package-cleanup/Models/GiteaConfig.cs b/sh.actions/sh.actions.package-cleanup/Models/GiteaConfig.cs deleted file mode 100644 index ae76675..0000000 --- a/sh.actions/sh.actions.package-cleanup/Models/GiteaConfig.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace sh.actions.package_cleanup.Models; - -/// -/// Konfiguration für Gitea-Verbindung -/// -public class GiteaConfig -{ - /// - /// Basis-URL der Gitea-Instanz (z.B. https://git.example.com) - /// - public string GiteaUrl { get; set; } = string.Empty; - - /// - /// API-Token für Authentifizierung - /// - public string ApiToken { get; set; } = string.Empty; - - /// - /// Owner/Username des Pakets - /// - public string Owner { get; set; } = string.Empty; -} - diff --git a/sh.actions/sh.actions.package-cleanup/Program.cs b/sh.actions/sh.actions.package-cleanup/Program.cs deleted file mode 100644 index 05d239c..0000000 --- a/sh.actions/sh.actions.package-cleanup/Program.cs +++ /dev/null @@ -1,65 +0,0 @@ -// See https://aka.ms/new-console-template for more information -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using sh.actions.package_cleanup.Models; -using sh.actions.package_cleanup.Service; - -Console.WriteLine("Hello, World!"); -// -// // Setup Dependency Injection -// var services = new ServiceCollection(); -// -// // Add Logging -// services.AddLogging(builder => -// builder.AddConsole() -// .SetMinimumLevel(LogLevel.Information) -// ); -// -// // Load configuration from environment variables or appsettings -// var giteaUrl = Environment.GetEnvironmentVariable("GITEA_URL") ?? "https://git.example.com"; -// var apiToken = Environment.GetEnvironmentVariable("GITEA_API_TOKEN") ?? string.Empty; -// var owner = Environment.GetEnvironmentVariable("GITEA_OWNER") ?? "sh-edraft"; -// -// var giteaConfig = new GiteaConfig -// { -// GiteaUrl = giteaUrl, -// ApiToken = apiToken, -// Owner = owner -// }; -// -// services.AddSingleton(giteaConfig); -// -// // Add HttpClient -// services.AddHttpClient(); -// -// // Build service provider -// var serviceProvider = services.BuildServiceProvider(); -// -// try -// { -// // Get the service and execute -// var packageService = serviceProvider.GetRequiredService(); -// var logger = serviceProvider.GetRequiredService>(); -// -// logger.LogInformation("Starting package cleanup tool"); -// logger.LogInformation("Gitea URL: {GiteaUrl}", giteaUrl); -// logger.LogInformation("Owner: {Owner}", owner); -// -// using var cts = new CancellationTokenSource(); -// var packages = await packageService.GetPackagesByOwnerAsync(cts.Token); -// -// logger.LogInformation("Retrieved {Count} packages", packages.Count()); -// -// foreach (var package in packages) -// { -// logger.LogInformation("Package: {Name} (Version: {Version})", package.Name, package.Version); -// } -// -// logger.LogInformation("Package cleanup tool completed successfully"); -// } -// catch (Exception ex) -// { -// var logger = serviceProvider.GetRequiredService>(); -// logger.LogError(ex, "Application error"); -// Environment.Exit(1); -// } diff --git a/sh.actions/sh.actions.package-cleanup/Service/GiteaPackageService.cs b/sh.actions/sh.actions.package-cleanup/Service/GiteaPackageService.cs deleted file mode 100644 index bd13abe..0000000 --- a/sh.actions/sh.actions.package-cleanup/Service/GiteaPackageService.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace sh.actions.package_cleanup.Service; - -using System.Net.Http.Json; -using Microsoft.Extensions.Logging; -using sh.actions.package_cleanup.Models; - -public class GiteaPackageService(HttpClient httpClient, GiteaConfig config, ILogger logger) - : IGiteaPackageService -{ - public async Task> GetPackagesByOwnerAsync(CancellationToken cancellationToken = default) - { - try - { - var url = $"{config.GiteaUrl.TrimEnd('/')}/api/v1/packages/{config.Owner}"; - - logger.LogInformation("Fetching packages from Gitea: {Url}", url); - - var request = new HttpRequestMessage(HttpMethod.Get, url); - - // Add authentication if token is provided - if (!string.IsNullOrEmpty(config.ApiToken)) - { - request.Headers.Add("Authorization", $"token {config.ApiToken}"); - } - - var response = await httpClient.SendAsync(request, cancellationToken); - response.EnsureSuccessStatusCode(); - - var packages = - await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken) - ?? new List(); - - logger.LogInformation("Successfully fetched {Count} packages", packages.Count); - - return packages; - } - catch (HttpRequestException ex) - { - logger.LogError(ex, "Error fetching packages from Gitea"); - throw; - } - catch (Exception ex) - { - logger.LogError(ex, "Unexpected error fetching packages"); - throw; - } - } -} \ No newline at end of file diff --git a/sh.actions/sh.actions.package-cleanup/sh.actions.package-cleanup.csproj b/sh.actions/sh.actions.package-cleanup/sh.actions.package-cleanup.csproj deleted file mode 100644 index 038a930..0000000 --- a/sh.actions/sh.actions.package-cleanup/sh.actions.package-cleanup.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - - Exe - net10.0 - sh.actions.package_cleanup - enable - enable - 0.0.1 - - - true - true - true - true - full - - - - - - - - - - - - - - -