diff --git a/.gitea/workflows/build dev.yaml b/.gitea/workflows/build dev.yaml new file mode 100644 index 0000000..0f1dbb4 --- /dev/null +++ b/.gitea/workflows/build dev.yaml @@ -0,0 +1,47 @@ +name: Build on push +run-name: Build on push +on: + push: + branches: + - dev + +jobs: + prepare: + runs-on: [runner] + container: git.sh-edraft.de/sh-edraft.de/act-runner:latest + steps: + - uses: https://git.sh-edraft.de/sh-edraft.de/actions/set-version@dev + with: + version_suffix: dev + env: + CI_ACCESS_TOKEN: ${{ secrets.CI_ACCESS_TOKEN }} + + build: + runs-on: [runner] + needs: prepare + container: git.sh-edraft.de/sh-edraft.de/act-runner:latest + steps: + - name: Clone Repository + uses: https://github.com/actions/checkout@v3 + with: + token: ${{ secrets.CI_ACCESS_TOKEN }} + + - name: Download build version artifact + uses: actions/download-artifact@v3 + with: + name: version + + - name: Build single file executables + run: | + cd sh.actions.package-cleanup + + # Build for Linux x64 + dotnet publish -c Release -r linux-x64 -p:Version=$(cat ../version.txt) -o publish/linux-x64 + + - name: Upload to Gitea Generic Package Registry + run: | + cd sh.actions.package-cleanup + curl -X PUT \ + -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" diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..4efcc23 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,45 @@ +name: Build on push +run-name: Build on push +on: + push: + branches: + - master + +jobs: + prepare: + runs-on: [runner] + container: git.sh-edraft.de/sh-edraft.de/act-runner:latest + steps: + - uses: https://git.sh-edraft.de/sh-edraft.de/actions/set-version@master + env: + CI_ACCESS_TOKEN: ${{ secrets.CI_ACCESS_TOKEN }} + + build: + runs-on: [runner] + needs: prepare + container: git.sh-edraft.de/sh-edraft.de/act-runner:latest + steps: + - name: Clone Repository + uses: https://github.com/actions/checkout@v3 + with: + token: ${{ secrets.CI_ACCESS_TOKEN }} + + - name: Download build version artifact + uses: actions/download-artifact@v3 + with: + name: version + + - name: Build single file executables + run: | + cd sh.actions.package-cleanup + + # Build for Linux x64 + dotnet publish -c Release -r linux-x64 -p:Version=$(cat ../version.txt) -o publish/linux-x64 + + - name: Upload to Gitea Generic Package Registry + run: | + cd sh.actions.package-cleanup + curl -X PUT \ + -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fa7b9e --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.idea +.code + +bin/ +obj/ +publish/ + +# Environment Variables - DO NOT COMMIT +.env +.env.local +*.env.local + +**/appsettings.local.json diff --git a/package-cleanup/action.yaml b/package-cleanup/action.yaml new file mode 100644 index 0000000..d35717e --- /dev/null +++ b/package-cleanup/action.yaml @@ -0,0 +1,56 @@ +name: "package cleanup" +description: "Cleans up old packages and versions" + +inputs: + url: + description: "Server URL" + required: true + names: + description: "Names of packages" + required: true + owner: + description: "Owner of the package" + required: true + types: + description: "Types of packages (e.g. Container, PyPi, NuGet)" + required: false + default: "container,pypi,nuget,npm" + api_token: + description: "API token for authentication" + required: true + dry_run: + description: "Execute without deleting packages" + required: false + default: "false" + +runs: + using: "composite" + steps: + - name: Download and test package-cleanup tool + shell: bash + run: | + latest_path=$(curl -sI https://git.sh-edraft.de/sh-edraft.de/-/packages/generic/package-cleanup/ \ + | awk -F ' ' '/Location:/ {print $2}' \ + | tr -d '\r') + + version=$(basename "$latest_path") + echo "Downloading package-cleanup version $version..." + curl -OJ https://git.sh-edraft.de/api/packages/sh-edraft.de/generic/package-cleanup/$version/package-cleanup-linux-x64 + + # Make executable + chmod +x package-cleanup-linux-x64 + + - name: Run package-cleanup + id: cleanup + shell: bash + env: + URL: ${{ inputs.url }} + OWNER: ${{ inputs.owner }} + TYPES: ${{ inputs.types }} + NAMES: ${{ inputs.names }} + API_TOKEN: ${{ inputs.api_token }} + DRY_RUN: ${{ inputs.dry_run }} + GITHUB_OUTPUT: ${{ env.GITHUB_OUTPUT }} + run: | + ./package-cleanup-linux-x64 + diff --git a/sh.actions.package-cleanup.Tests/Service/PackageServiceTests.cs b/sh.actions.package-cleanup.Tests/Service/PackageServiceTests.cs new file mode 100644 index 0000000..5e2490e --- /dev/null +++ b/sh.actions.package-cleanup.Tests/Service/PackageServiceTests.cs @@ -0,0 +1,190 @@ +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 PackageFilterTests +{ + private readonly IPackageService _packageService; + private readonly Mock _giteaPackageServiceMock = new Mock(); + private readonly List _packages; + + + public PackageFilterTests() + { + _packageService = new PackageService(_giteaPackageServiceMock.Object); + _packages = _versions.Select(v => new GiteaPackage + { + Version = v, + Name = $"test-{v}" + }).ToList(); + } + + [Fact] + public void TestPackageNameParsing() + { + var inputString = + "@sh-edraft.de/core, sh-edraft.core.api, sh-edraft.core.api.auth, sh-edraft.core.api.configuration, sh-edraft.core.api.db, sh-edraft.core.api.graphql, sh-edraft.core.api.service, sh-edraft.core.utils"; + List expected = [ + "@sh-edraft.de/core", + "sh-edraft.core.api", + "sh-edraft.core.api.auth", + "sh-edraft.core.api.configuration", + "sh-edraft.core.api.db", + "sh-edraft.core.api.graphql", + "sh-edraft.core.api.service", + "sh-edraft.core.utils" + ]; + + var actual = inputString + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + Assert.Equal(expected.Count, actual.Length); + Assert.Equal(expected, actual); + } + + [Fact] + public void TestFilterPackagesToDelete() + { + var toDelete = _packageService.FilterPackagesToDelete(_packages); + var toDeleteVersions = toDelete + .Select(x => x.Version) + .OrderByDescending(x => x) + .ToList(); + var expected = _expectDeleted.OrderByDescending(x => x).ToList(); + Assert.Equal(expected.Count, toDeleteVersions.Count); + Assert.Equal(expected, toDeleteVersions); + } + + private readonly List _versions = + [ + "0.1.0", + "0.1.1", + "0.1.2", + "1.0", + "1.1", + "1.2", + "2024.8.6.0", + "2024.8.7.0", + "2024.8.8.0", + "2024.8.9.0", + "2024.8.10.0", + "2024.8.11.0", + "latest", + // dev + "0-dev", + "1-dev", + "2-dev", + "1.0-dev", + "1.1-dev", + "1.2-dev", + "0.1.0-dev", + "0.1.1-dev", + "0.1.2-dev", + "2024.8.6.0-dev", + "2024.8.6.1-dev", + "2024.8.7.0-dev", + "2024.8.7.1-dev", + "2024.8.8.0-dev", + "2024.8.8.1-dev", + "2024.8.9.0-dev", + "2024.8.9.1-dev", + "2024.8.10.0-dev", + "2024.8.10.1-dev", + "2024.8.11.0-dev", + "2024.8.11.1-dev", + // test + "0.1.0-test", + "0.1.1-test", + "0.1.2-test", + "2024.8.6.0-test", + "2024.8.6.1-test", + "2024.8.7.0-test", + "2024.8.7.1-test", + "2024.8.8.0-test", + "2024.8.8.1-test", + "2024.8.9.0-test", + "2024.8.9.1-test", + "2024.8.10.0-test", + "2024.8.10.1-test", + "2024.8.11.0-test", + // exp + "0.1.0-exp", + "0.1.1-exp", + "0.1.2-exp", + "2024.8.6.0-exp", + "2024.8.6.1-exp", + "2024.8.7.0-exp", + "2024.8.7.1-exp", + "2024.8.8.0-exp", + "2024.8.8.1-exp", + "2024.8.9.0-exp", + "2024.8.9.1-exp", + "2024.8.10.0-exp", + "2024.8.10.1-exp", + "2024.8.11.0-exp", + "2024.8.11.2-exp", + "2026.2.21.27-dev", + "2026.2.21.26-dev", + "2026.2.21.25-dev", + "2026.2.21.24-dev", + "2026.2.21.23-dev", + "2026.2.21.22-dev", + "2026.2.21.21-dev", + "2026.2.21.20-dev", + "2026.2.21.19-dev", + "2026.2.21.18-dev", + "2026.2.21.17-dev", + "2026.2.21.15-dev", + "2026.2.21.14-dev", + "2026.2.21.13-dev", + "2026.2.21.12-dev", + "2026.2.21.11-dev", + "2026.2.21.10-dev", + "2026.2.21.9-dev", + "2026.2.21.8-dev", + ]; + + private readonly List _versionsToHold = + [ + // prod (no suffix) + "0.1.0", + "0.1.1", + "0.1.2", + "1.0", + "1.1", + "1.2", + "2024.8.6.0", + "2024.8.7.0", + "2024.8.8.0", + "2024.8.9.0", + "2024.8.10.0", + "2024.8.11.0", + "latest", + // dev + "1-dev", + "2-dev", + "1.1-dev", + "1.2-dev", + "0.1.1-dev", + "0.1.2-dev", + "2024.8.11.0-dev", + "2024.8.11.1-dev", + // test + "0.1.1-test", + "0.1.2-test", + "2024.8.10.1-test", + "2024.8.11.0-test", + // exp + "0.1.1-exp", + "0.1.2-exp", + "2024.8.11.0-exp", + "2024.8.11.2-exp", + "2026.2.21.27-dev", + "2026.2.21.26-dev", + ]; + + private List _expectDeleted => _versions.Except(_versionsToHold).ToList(); +} \ No newline at end of file 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..c650f7d --- /dev/null +++ b/sh.actions.package-cleanup/ConfigurationExtension.cs @@ -0,0 +1,27 @@ +namespace sh.actions.package_cleanup; + +public static class ConfigurationExtension +{ + private const string MissingConfigurationError = "Configuration variable '{0}' is required but was not found."; + + private static void ValidateConfigurationVariable(IConfiguration configuration, string key) + { + if (string.IsNullOrWhiteSpace(configuration[key])) + { + throw new InvalidOperationException(string.Format(MissingConfigurationError, key)); + } + } + + public static IConfigurationBuilder EnsureGiteaConfig(this IConfigurationBuilder builder) + { + var configuration = builder.Build(); + var requiredKeys = new[] { "URL", "OWNER", "TYPES", "NAMES", "API_TOKEN" }; + + foreach (var key in requiredKeys) + { + ValidateConfigurationVariable(configuration, key); + } + + return builder; + } +} \ No newline at end of file diff --git a/sh.actions.package-cleanup/Models/GiteaPackage.cs b/sh.actions.package-cleanup/Models/GiteaPackage.cs new file mode 100644 index 0000000..d72720f --- /dev/null +++ b/sh.actions.package-cleanup/Models/GiteaPackage.cs @@ -0,0 +1,10 @@ +namespace sh.actions.package_cleanup.Models; + +public class GiteaPackage +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } +} \ No newline at end of file diff --git a/sh.actions.package-cleanup/Models/SoftwareVersion.cs b/sh.actions.package-cleanup/Models/SoftwareVersion.cs new file mode 100644 index 0000000..6186eab --- /dev/null +++ b/sh.actions.package-cleanup/Models/SoftwareVersion.cs @@ -0,0 +1,97 @@ +using System.Text.RegularExpressions; + +namespace sh.actions.package_cleanup.Models; + +public class SoftwareVersion( + int major, + int minor, + int patch = 0, + int build = 0, + string? suffix = null, + string? input = null, + bool isLatest = false) +{ + public int Major { get; set; } = major; + public int Minor { get; set; } = minor; + public int Patch { get; set; } = patch; + public int Build { get; set; } = build; + public string? Suffix { get; set; } = suffix; + public string? Input { get; set; } = input; + public bool IsLatest { get; set; } = isLatest; + + public override string ToString() + { + var versionStr = $"{Major}.{Minor}.{Patch}.{Build}"; + if (!string.IsNullOrEmpty(Suffix)) + { + versionStr += $"-{Suffix}"; + } + + return versionStr; + } + + public override bool Equals(object? obj) + { + return obj is SoftwareVersion version && ToString() == version.ToString(); + } + + public override int GetHashCode() + { + return ToString().GetHashCode(); + } + + public static SoftwareVersion Parse(string versionString) + { + // Try matching format: X.X.X.X or X.X.X.X-suffix + var match = Regex.Match(versionString, @"(\d+)\.(\d+)\.(\d+)\.(\d+)(?:-(.*))?"); + if (match.Success) + { + var major = int.Parse(match.Groups[1].Value); + var minor = int.Parse(match.Groups[2].Value); + var patch = int.Parse(match.Groups[3].Value); + var build = int.Parse(match.Groups[4].Value); + var suffix = match.Groups[5].Value; + return new SoftwareVersion(major, minor, patch, build, string.IsNullOrEmpty(suffix) ? null : suffix, + versionString); + } + + // Try matching format: X.X.X or X.X.X-suffix + match = Regex.Match(versionString, @"(\d+)\.(\d+)\.(\d+)(?:-(.*))?"); + if (match.Success) + { + var major = int.Parse(match.Groups[1].Value); + var minor = int.Parse(match.Groups[2].Value); + var patch = int.Parse(match.Groups[3].Value); + var suffix = match.Groups[4].Value; + return new SoftwareVersion(major, minor, patch, 0, string.IsNullOrEmpty(suffix) ? null : suffix, + versionString); + } + + // Try matching format: X.X or X.X-suffix + match = Regex.Match(versionString, @"(\d+)\.(\d+)(?:-(.*))?"); + if (match.Success) + { + var major = int.Parse(match.Groups[1].Value); + var minor = int.Parse(match.Groups[2].Value); + var suffix = match.Groups[3].Value; + return new SoftwareVersion(major, minor, 0, 0, string.IsNullOrEmpty(suffix) ? null : suffix, versionString); + } + + // Try matching format: X or X-suffix + match = Regex.Match(versionString, @"(\d+)(?:-(.*))?"); + if (match.Success) + { + var major = int.Parse(match.Groups[1].Value); + var suffix = match.Groups[2].Value; + return new SoftwareVersion(major, 0, 0, 0, string.IsNullOrEmpty(suffix) ? null : suffix, versionString); + } + + // Special case for "latest" + if (versionString == "latest") + { + return new SoftwareVersion(1, 0, 0, 0, null, versionString, isLatest: true); + } + + throw new ArgumentException($"Invalid version string: {versionString}"); + } +} \ No newline at end of file diff --git a/sh.actions.package-cleanup/Program.cs b/sh.actions.package-cleanup/Program.cs new file mode 100644 index 0000000..a6753f8 --- /dev/null +++ b/sh.actions.package-cleanup/Program.cs @@ -0,0 +1,30 @@ +using sh.actions.package_cleanup; +using sh.actions.package_cleanup.Service; + +var builder = Host.CreateApplicationBuilder(args); + +// Check for --dry-run argument +var isDryRun = args.Contains("--dry-run"); +if (isDryRun) +{ + Environment.SetEnvironmentVariable("DRY_RUN", "true"); +} + +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(); + +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(); +builder.Logging.SetMinimumLevel(LogLevel.Debug); + +var host = builder.Build(); +await host.RunAsync(); 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..789c8b4 --- /dev/null +++ b/sh.actions.package-cleanup/Service/GiteaPackageService.cs @@ -0,0 +1,155 @@ +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 +{ + private string GetBaseUrl() => + $"{configuration["URL"]?.TrimEnd('/')}/packages/{configuration["OWNER"]}"; + + private void AddAuthorizationHeader(HttpRequestMessage request) + { + var token = configuration["API_TOKEN"]; + if (!string.IsNullOrEmpty(token)) + { + request.Headers.Add("Authorization", $"token {token}"); + } + } + + public async Task> GetPackagesByNameAsync(string name, CancellationToken cancellationToken = default) + { + try + { + var baseUrl = GetBaseUrl(); + var packages = new List(); + + // Parse comma-separated types + var types = (configuration["TYPES"] ?? "") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (types.Length == 0) + { + logger.LogWarning("No package types configured"); + return packages; + } + + foreach (var type in types) + { + var page = 1; + + while (true) + { + var url = $"{baseUrl}/{type}/{name}?page={page}"; + logger.LogInformation("Fetching packages from Gitea: {Url}", url); + + var request = new HttpRequestMessage(HttpMethod.Get, url); + AddAuthorizationHeader(request); + + var response = await httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var fetchedPackages = + await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken) + ?? []; + + if (fetchedPackages.Count == 0) + { + break; + } + + packages.AddRange(fetchedPackages); + logger.LogInformation("Fetched {Count} packages from page {Page}", fetchedPackages.Count, page); + + page++; + } + } + + logger.LogInformation("Successfully fetched {Count} packages for name '{Name}'", packages.Count, name); + return packages; + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Error fetching packages from Gitea for name '{Name}'", name); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error fetching packages for name '{Name}'", name); + throw; + } + } + + public async Task> GetPackagesByOwnerAsync(CancellationToken cancellationToken = default) + { + try + { + var allPackages = new List(); + + var names = (configuration["NAMES"] ?? "") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (names.Length == 0) + { + logger.LogWarning("No package names configured"); + return allPackages; + } + + foreach (var name in names) + { + var packages = await GetPackagesByNameAsync(name, cancellationToken); + allPackages.AddRange(packages); + } + + 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 baseUrl = GetBaseUrl(); + var url = $"{baseUrl}/{package.Type}/{package.Name}/{package.Version}"; + logger.LogInformation("Deleting package {PackageName} (ID: {PackageId}) from Gitea", + package.Name, package.Id); + + var request = new HttpRequestMessage(HttpMethod.Delete, url); + AddAuthorizationHeader(request); + + 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.package-cleanup/Service/IGiteaPackageService.cs b/sh.actions.package-cleanup/Service/IGiteaPackageService.cs new file mode 100644 index 0000000..188ffde --- /dev/null +++ b/sh.actions.package-cleanup/Service/IGiteaPackageService.cs @@ -0,0 +1,10 @@ +namespace sh.actions.package_cleanup.Service; + +using sh.actions.package_cleanup.Models; + +public interface IGiteaPackageService +{ + Task> GetPackagesByNameAsync(string name, CancellationToken cancellationToken = default); + 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..6fead71 --- /dev/null +++ b/sh.actions.package-cleanup/Service/PackageService.cs @@ -0,0 +1,50 @@ +using sh.actions.package_cleanup.Models; + +namespace sh.actions.package_cleanup.Service; + +public class PackageService(IGiteaPackageService packageService) : IPackageService +{ + public async Task> GetFilteredPackages() + { + return (await packageService.GetPackagesByOwnerAsync()).ToList(); + } + + public List FilterPackagesToDelete(List packages) + { + var versionMap = packages.ToDictionary(p => p.Version, p => SoftwareVersion.Parse(p.Version)); + var versionsToKeep = new HashSet(); + + foreach (var package in packages + .Where(p => string.IsNullOrEmpty(versionMap[p.Version].Suffix)) + .GroupBy(p => versionMap[p.Version].ToString()) + .Select(g => g.OrderBy(p => p.Version.Length).First())) + { + versionsToKeep.Add(package.Version); + } + + var latestVersion = packages.FirstOrDefault(p => versionMap[p.Version].IsLatest); + if (latestVersion != null) + { + versionsToKeep.Add(latestVersion.Version); + } + + var suffixedVersionsToKeep = packages + .Where(p => !string.IsNullOrEmpty(versionMap[p.Version].Suffix) && + !(versionMap[p.Version].Major == 0 && versionMap[p.Version].Minor == 0 && + versionMap[p.Version].Patch == 0)) + .GroupBy(p => (versionMap[p.Version].Suffix, versionMap[p.Version].Major, versionMap[p.Version].Minor)) + .SelectMany(g => g + .OrderByDescending(p => versionMap[p.Version].Patch) + .ThenByDescending(p => versionMap[p.Version].Build) + .Take(2) + .GroupBy(p => versionMap[p.Version].ToString()) + .Select(group => group.OrderBy(p => p.Version.Length).First())); + + foreach (var package in suffixedVersionsToKeep) + { + versionsToKeep.Add(package.Version); + } + + return packages.Where(p => !versionsToKeep.Contains(p.Version)).ToList(); + } +} \ 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..2f5b937 --- /dev/null +++ b/sh.actions.package-cleanup/Worker.cs @@ -0,0 +1,97 @@ +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) + { + var dryRun = configuration["DRY_RUN"]?.ToLower() == "true"; + if (dryRun) + { + logger.LogInformation("Dry run enabled, not deleting {Count} packages", packages.Count); + logger.LogInformation("Would delete packages: {versions}", + string.Join(", ", packages.Select(p => p.Version))); + } + + foreach (var giteaPackage in packages) + { + try + { + await giteaPackageService.DeletePackage(giteaPackage, cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete package {PackageName} version {Version}", + giteaPackage.Name, giteaPackage.Version); + } + } + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + try + { + var dryRun = configuration["DRY_RUN"]?.ToLower() == "true"; + if (dryRun) + { + logger.LogInformation("DRY RUN MODE ENABLED - No packages will be deleted"); + } + + // Parse comma-separated names + var names = (configuration["NAMES"] ?? "") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (names.Length == 0) + { + logger.LogWarning("No package names configured"); + appLifetime.StopApplication(); + return; + } + + logger.LogInformation("Deleting {count} packages: {names}", names.Length, string.Join(", ", names)); + + // Process each name separately: collect -> filter -> delete + foreach (var name in names) + { + try + { + logger.LogInformation("Processing packages for name '{Name}'", name); + + var packages = (await giteaPackageService.GetPackagesByNameAsync(name, cancellationToken)).ToList(); + logger.LogInformation("Found {Count} packages for name '{Name}'", packages.Count, name); + + var packagesToDelete = packageService.FilterPackagesToDelete(packages); + logger.LogInformation("Found {Count} packages to delete for name '{Name}'", packagesToDelete.Count, + name); + + await DeletePackages(packagesToDelete, cancellationToken); + logger.LogInformation("Deleted {Count} packages for name '{Name}'", packagesToDelete.Count, name); + + logger.LogInformation("Cleanup finished for name '{Name}'", name); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete package {PackageName}", name); + } + } + + logger.LogInformation("All package names processed successfully"); + } + catch (Exception ex) + { + logger.LogError(ex, "Cleanup failed with an error"); + } + finally + { + 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..8f06e7d --- /dev/null +++ b/sh.actions.package-cleanup/sh.actions.package-cleanup.csproj @@ -0,0 +1,46 @@ + + + + net10.0 + enable + enable + dotnet-sh.actions.package_cleanup-2b7a013f-ec22-4325-9832-0c9ca9b8ced9 + sh.actions.package_cleanup + true + true + + + + + + + + + <_ContentIncludedByDefault Remove="publish\linux-x64\appsettings.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\appsettings.local.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\appsettings.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\appsettings.local.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.local.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.local.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.local.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.local.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.local.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\appsettings.local.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\sh.actions.package-cleanup.deps.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\sh.actions.package-cleanup.runtimeconfig.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\sh.actions.package-cleanup.deps.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\sh.actions.package-cleanup.runtimeconfig.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\sh.actions.package-cleanup.deps.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\publish\linux-x64\sh.actions.package-cleanup.runtimeconfig.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\sh.actions.package-cleanup.deps.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\publish\linux-x64\sh.actions.package-cleanup.runtimeconfig.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\sh.actions.package-cleanup.deps.json" /> + <_ContentIncludedByDefault Remove="publish\linux-x64\sh.actions.package-cleanup.runtimeconfig.json" /> + + diff --git a/sh.actions.sln b/sh.actions.sln new file mode 100644 index 0000000..af1b34c --- /dev/null +++ b/sh.actions.sln @@ -0,0 +1,44 @@ +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 + {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..be8a2cb --- /dev/null +++ b/sh.actions.sln.DotSettings.user @@ -0,0 +1,4 @@ + + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from &lt;sh.actions.package-cleanup.Tests&gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Project Location="/home/sven/dev/git_sh-edraft_de/actions/sh.actions.package-cleanup.Tests" Presentation="&lt;sh.actions.package-cleanup.Tests&gt;" /> +</SessionState> \ No newline at end of file