From 3d441aa0879618cee87573a753f7d365a9054b2d Mon Sep 17 00:00:00 2001 From: Souleymene Rouchou Date: Thu, 25 Jun 2026 17:17:58 -0400 Subject: [PATCH 1/4] Add permanently_remove support to project delete Exposes the permanently_remove and full_path query parameters on IProjectClient.DeleteAsync, matching the GitLab API behavior where v18+ soft-deletes by default and requires an explicit flag to bypass the grace period. https://docs.gitlab.com/api/projects/#delete-a-project --- NGitLab.Mock.Tests/ProjectsMockTests.cs | 83 ++++++++++++++++++- NGitLab.Mock/Clients/ProjectClient.cs | 30 ++++++- NGitLab.Mock/Project.cs | 3 + NGitLab.Mock/PublicAPI.Unshipped.txt | 2 + NGitLab.Tests/ProjectsTests.cs | 28 +++++++ NGitLab/IProjectClient.cs | 5 ++ NGitLab/Impl/ProjectClient.cs | 17 +++- NGitLab/Models/ProjectDelete.cs | 8 ++ .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 8 ++ .../PublicAPI/net472/PublicAPI.Unshipped.txt | 8 ++ .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 8 ++ .../netstandard2.0/PublicAPI.Unshipped.txt | 8 ++ 12 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 NGitLab/Models/ProjectDelete.cs diff --git a/NGitLab.Mock.Tests/ProjectsMockTests.cs b/NGitLab.Mock.Tests/ProjectsMockTests.cs index 126e3454..fcab489d 100644 --- a/NGitLab.Mock.Tests/ProjectsMockTests.cs +++ b/NGitLab.Mock.Tests/ProjectsMockTests.cs @@ -442,9 +442,9 @@ public void UpdateAsync_WhenProjectNotFound_ItThrows() } [Test] - public async Task DeleteAsync_WhenProjectExists_ItIsDeleted() + public async Task DeleteAsync_WhenProjectExists_ItIsMarkedForDeletion() { - var projectFullPath = $"Test/{nameof(DeleteAsync_WhenProjectExists_ItIsDeleted)}"; + var projectFullPath = $"Test/{nameof(DeleteAsync_WhenProjectExists_ItIsMarkedForDeletion)}"; using var server = new GitLabConfig() .WithUser("Test", isDefault: true) .WithProjectOfFullPath(projectFullPath) @@ -455,8 +455,9 @@ public async Task DeleteAsync_WhenProjectExists_ItIsDeleted() // Act await projectClient.DeleteAsync(projectFullPath); - // Assert - Assert.CatchAsync((Func)(() => projectClient.GetAsync(projectFullPath))); + // Assert: project is still accessible but marked for deletion + var markedProject = await projectClient.GetAsync(projectFullPath); + Assert.That(markedProject.MarkedForDeletionOn, Is.Not.Null); } [Test] @@ -475,6 +476,80 @@ public void DeleteAsync_WhenProjectNotFound_ItThrows() Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); } + [Test] + public async Task DeleteAsync_WithoutPermanentlyRemove_MarksProjectForDeletion() + { + var projectFullPath = $"Test/{nameof(DeleteAsync_WithoutPermanentlyRemove_MarksProjectForDeletion)}"; + using var server = new GitLabConfig() + .WithUser("Test", isDefault: true) + .WithProjectOfFullPath(projectFullPath) + .BuildServer(); + + var projectClient = server.CreateClient().Projects; + var project = await projectClient.GetAsync(projectFullPath); + + // Act + await projectClient.DeleteAsync(project.Id); + + // Assert + var markedProject = await projectClient.GetAsync(project.Id); + Assert.That(markedProject.MarkedForDeletionOn, Is.Not.Null); + Assert.That(markedProject.MarkedForDeletionOn!.Value.Date, Is.EqualTo(DateTime.UtcNow.Date)); + } + + [Test] + public async Task DeleteAsync_WithPermanentlyRemove_AndMatchingFullPath_HardDeletes() + { + var projectFullPath = $"Test/{nameof(DeleteAsync_WithPermanentlyRemove_AndMatchingFullPath_HardDeletes)}"; + using var server = new GitLabConfig() + .WithUser("Test", isDefault: true) + .WithProjectOfFullPath(projectFullPath) + .BuildServer(); + + var projectClient = server.CreateClient().Projects; + var project = await projectClient.GetAsync(projectFullPath); + + // First call: soft-mark + await projectClient.DeleteAsync(project.Id); + + // Second call: permanently remove + await projectClient.DeleteAsync(project.Id, new ProjectDelete + { + PermanentlyRemove = true, + FullPath = projectFullPath, + }); + + // Assert: project is now gone + Assert.CatchAsync((Func)(() => projectClient.GetAsync(project.Id))); + } + + [Test] + public async Task DeleteAsync_WithPermanentlyRemove_AndMismatchedFullPath_Throws() + { + var projectFullPath = $"Test/{nameof(DeleteAsync_WithPermanentlyRemove_AndMismatchedFullPath_Throws)}"; + using var server = new GitLabConfig() + .WithUser("Test", isDefault: true) + .WithProjectOfFullPath(projectFullPath) + .BuildServer(); + + var projectClient = server.CreateClient().Projects; + var project = await projectClient.GetAsync(projectFullPath); + + // Soft-mark first + await projectClient.DeleteAsync(project.Id); + + // Act: wrong full_path + var ex = Assert.CatchAsync((Func)(() => + projectClient.DeleteAsync(project.Id, new ProjectDelete + { + PermanentlyRemove = true, + FullPath = "wrong/path", + }))); + + // Assert + Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + [Test] public async Task GetAndSetProjectJobTokenScope() { diff --git a/NGitLab.Mock/Clients/ProjectClient.cs b/NGitLab.Mock/Clients/ProjectClient.cs index 3c77ca73..b286860d 100644 --- a/NGitLab.Mock/Clients/ProjectClient.cs +++ b/NGitLab.Mock/Clients/ProjectClient.cs @@ -139,7 +139,7 @@ public void Delete(long id) using (Context.BeginOperationScope()) { var project = GetProject(id, ProjectPermission.Delete); - project.Remove(); + DeleteCore(project, options: null); } } @@ -149,8 +149,36 @@ public async Task DeleteAsync(ProjectId projectId, CancellationToken cancellatio using (Context.BeginOperationScope()) { var project = GetProject(projectId, ProjectPermission.Delete); + DeleteCore(project, options: null); + } + } + + public async Task DeleteAsync(ProjectId projectId, ProjectDelete options, CancellationToken cancellationToken = default) + { + await Task.Yield(); + using (Context.BeginOperationScope()) + { + var project = GetProject(projectId, ProjectPermission.Delete); + DeleteCore(project, options); + } + } + + private static void DeleteCore(Project project, ProjectDelete options) + { + if (options?.PermanentlyRemove == true) + { + if (!string.Equals(options.FullPath, project.PathWithNamespace, StringComparison.Ordinal)) + throw GitLabException.BadRequest("Project full_path does not match"); + + if (project.MarkedForDeletionOn is null) + throw GitLabException.BadRequest("Project is not marked for deletion"); + project.Remove(); } + else + { + project.MarkedForDeletionOn ??= DateTime.UtcNow; + } } public void Archive(long id) diff --git a/NGitLab.Mock/Project.cs b/NGitLab.Mock/Project.cs index 4813cba4..4abe69a3 100644 --- a/NGitLab.Mock/Project.cs +++ b/NGitLab.Mock/Project.cs @@ -170,6 +170,8 @@ public string[] Tags public SquashOption SquashOption { get; set; } + public DateTime? MarkedForDeletionOn { get; set; } + public void Remove() { Group.Projects.Remove(this); @@ -495,6 +497,7 @@ public Models.Project ToClientProject(User currentUser) OnlyMirrorProtectedBranch = OnlyMirrorProtectedBranch, MirrorOverwritesDivergedBranches = MirrorOverwritesDivergedBranches, Permissions = GetProjectPermissions(currentUser), + MarkedForDeletionOn = MarkedForDeletionOn, }; #pragma warning restore CS0618 // Type or member is obsolete } diff --git a/NGitLab.Mock/PublicAPI.Unshipped.txt b/NGitLab.Mock/PublicAPI.Unshipped.txt index a66180f3..743881ac 100644 --- a/NGitLab.Mock/PublicAPI.Unshipped.txt +++ b/NGitLab.Mock/PublicAPI.Unshipped.txt @@ -1408,3 +1408,5 @@ NGitLab.Mock.Config.GitLabContainerRepository.Tags.get -> System.Collections.Gen NGitLab.Mock.Config.GitLabContainerRepositoriesCollection NGitLab.Mock.Config.GitLabProject.ContainerRepositories.get -> NGitLab.Mock.Config.GitLabContainerRepositoriesCollection static NGitLab.Mock.Config.GitLabHelpers.WithContainerRepository(this NGitLab.Mock.Config.GitLabProject project, string name, System.Collections.Generic.IEnumerable tags = null) -> NGitLab.Mock.Config.GitLabProject +NGitLab.Mock.Project.MarkedForDeletionOn.get -> System.DateTime? +NGitLab.Mock.Project.MarkedForDeletionOn.set -> void diff --git a/NGitLab.Tests/ProjectsTests.cs b/NGitLab.Tests/ProjectsTests.cs index d01e68c1..b9161351 100644 --- a/NGitLab.Tests/ProjectsTests.cs +++ b/NGitLab.Tests/ProjectsTests.cs @@ -594,6 +594,34 @@ public async Task DeleteAsync_WhenProjectNotFound_ItThrows() Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); } + [Test] + [NGitLabRetry] + public async Task DeleteAsync_WhenPermanentlyRemoveOnMarkedProject_ItIsDeleted() + { + using var context = await GitLabTestContext.CreateAsync(); + context.IgnoreTestIfGitLabVersionOutOfRange(VersionRange.Parse("[18.0,)")); + + var group = context.CreateGroup(); + var project = context.CreateProject(group.Id); + var projectClient = context.Client.Projects; + + // Soft-mark first + await projectClient.DeleteAsync(project.Id); + + var markedProject = await projectClient.GetAsync(project.Id); + Assert.That(markedProject.MarkedForDeletionOn, Is.Not.Null, "Project should be marked for deletion before permanently removing"); + + // Act: permanently remove + await projectClient.DeleteAsync(project.Id, new ProjectDelete + { + PermanentlyRemove = true, + FullPath = project.PathWithNamespace, + }); + + // Assert: project no longer accessible + Assert.ThrowsAsync((Func)(() => projectClient.GetAsync(project.Id))); + } + // No owner level (50) for project! See https://docs.gitlab.com/ee/api/members.html [TestCase(AccessLevel.Guest)] [TestCase(AccessLevel.Reporter)] diff --git a/NGitLab/IProjectClient.cs b/NGitLab/IProjectClient.cs index 5d186e21..1e8506a0 100644 --- a/NGitLab/IProjectClient.cs +++ b/NGitLab/IProjectClient.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using NGitLab.Models; @@ -48,8 +49,12 @@ public interface IProjectClient void Delete(long id); + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Internal requirement to have the CancellationToken optional")] Task DeleteAsync(ProjectId projectId, CancellationToken cancellationToken = default); + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Internal requirement to have the CancellationToken optional")] + Task DeleteAsync(ProjectId projectId, ProjectDelete options, CancellationToken cancellationToken = default); + void Archive(long id); void Unarchive(long id); diff --git a/NGitLab/Impl/ProjectClient.cs b/NGitLab/Impl/ProjectClient.cs index 25223b3f..3335c7cc 100644 --- a/NGitLab/Impl/ProjectClient.cs +++ b/NGitLab/Impl/ProjectClient.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Net; @@ -39,8 +40,22 @@ public Task CreateAsync(ProjectCreate project, CancellationToken cancel public void Delete(long id) => _api.Delete().Execute($"{Project.Url}/{id.ToString(CultureInfo.InvariantCulture)}"); + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Internal requirement to have the CancellationToken optional")] public Task DeleteAsync(ProjectId projectId, CancellationToken cancellationToken = default) => - _api.Delete().ExecuteAsync($"{Project.Url}/{projectId.ValueAsUriParameter()}", cancellationToken); + DeleteAsync(projectId, options: null, cancellationToken); + + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Internal requirement to have the CancellationToken optional")] + public Task DeleteAsync(ProjectId projectId, ProjectDelete options, CancellationToken cancellationToken = default) + { + var url = $"{Project.Url}/{projectId.ValueAsUriParameter()}"; + if (options is not null) + { + url = Utils.AddParameter(url, "permanently_remove", options.PermanentlyRemove); + url = Utils.AddParameter(url, "full_path", options.FullPath); + } + + return _api.Delete().ExecuteAsync(url, cancellationToken); + } public void Archive(long id) => _api.Post().Execute($"{Project.Url}/{id.ToString(CultureInfo.InvariantCulture)}/archive"); diff --git a/NGitLab/Models/ProjectDelete.cs b/NGitLab/Models/ProjectDelete.cs new file mode 100644 index 00000000..de31cce5 --- /dev/null +++ b/NGitLab/Models/ProjectDelete.cs @@ -0,0 +1,8 @@ +namespace NGitLab.Models; + +public sealed class ProjectDelete +{ + public bool? PermanentlyRemove { get; set; } + + public string FullPath { get; set; } +} diff --git a/NGitLab/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/net10.0/PublicAPI.Unshipped.txt index cc092230..dec2ea18 100644 --- a/NGitLab/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -5297,3 +5297,11 @@ NGitLab.Models.ContainerRegistryTag.ShortRevision.get -> string NGitLab.Models.ContainerRegistryTag.ShortRevision.set -> void NGitLab.Models.ContainerRegistryTag.TotalSize.get -> long NGitLab.Models.ContainerRegistryTag.TotalSize.set -> void +NGitLab.IProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Models.ProjectDelete +NGitLab.Models.ProjectDelete.FullPath.get -> string +NGitLab.Models.ProjectDelete.FullPath.set -> void +NGitLab.Models.ProjectDelete.PermanentlyRemove.get -> bool? +NGitLab.Models.ProjectDelete.PermanentlyRemove.set -> void +NGitLab.Models.ProjectDelete.ProjectDelete() -> void +NGitLab.Impl.ProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task diff --git a/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt index 94b454b6..46fe8ff5 100644 --- a/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -5298,3 +5298,11 @@ NGitLab.Models.ContainerRegistryTag.ShortRevision.get -> string NGitLab.Models.ContainerRegistryTag.ShortRevision.set -> void NGitLab.Models.ContainerRegistryTag.TotalSize.get -> long NGitLab.Models.ContainerRegistryTag.TotalSize.set -> void +NGitLab.IProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Models.ProjectDelete +NGitLab.Models.ProjectDelete.FullPath.get -> string +NGitLab.Models.ProjectDelete.FullPath.set -> void +NGitLab.Models.ProjectDelete.PermanentlyRemove.get -> bool? +NGitLab.Models.ProjectDelete.PermanentlyRemove.set -> void +NGitLab.Models.ProjectDelete.ProjectDelete() -> void +NGitLab.Impl.ProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task diff --git a/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt index cc092230..dec2ea18 100644 --- a/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -5297,3 +5297,11 @@ NGitLab.Models.ContainerRegistryTag.ShortRevision.get -> string NGitLab.Models.ContainerRegistryTag.ShortRevision.set -> void NGitLab.Models.ContainerRegistryTag.TotalSize.get -> long NGitLab.Models.ContainerRegistryTag.TotalSize.set -> void +NGitLab.IProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Models.ProjectDelete +NGitLab.Models.ProjectDelete.FullPath.get -> string +NGitLab.Models.ProjectDelete.FullPath.set -> void +NGitLab.Models.ProjectDelete.PermanentlyRemove.get -> bool? +NGitLab.Models.ProjectDelete.PermanentlyRemove.set -> void +NGitLab.Models.ProjectDelete.ProjectDelete() -> void +NGitLab.Impl.ProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task diff --git a/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 94b454b6..46fe8ff5 100644 --- a/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -5298,3 +5298,11 @@ NGitLab.Models.ContainerRegistryTag.ShortRevision.get -> string NGitLab.Models.ContainerRegistryTag.ShortRevision.set -> void NGitLab.Models.ContainerRegistryTag.TotalSize.get -> long NGitLab.Models.ContainerRegistryTag.TotalSize.set -> void +NGitLab.IProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Models.ProjectDelete +NGitLab.Models.ProjectDelete.FullPath.get -> string +NGitLab.Models.ProjectDelete.FullPath.set -> void +NGitLab.Models.ProjectDelete.PermanentlyRemove.get -> bool? +NGitLab.Models.ProjectDelete.PermanentlyRemove.set -> void +NGitLab.Models.ProjectDelete.ProjectDelete() -> void +NGitLab.Impl.ProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task From f8aa62319be4b6f42fd85176b14f1ea1b1d76777 Mon Sep 17 00:00:00 2001 From: Souleymene Rouchou Date: Fri, 26 Jun 2026 09:30:07 -0400 Subject: [PATCH 2/4] Fix full_path URL encoding in project permanent delete --- NGitLab/Impl/ProjectClient.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/NGitLab/Impl/ProjectClient.cs b/NGitLab/Impl/ProjectClient.cs index 3335c7cc..81288f76 100644 --- a/NGitLab/Impl/ProjectClient.cs +++ b/NGitLab/Impl/ProjectClient.cs @@ -51,7 +51,11 @@ public Task DeleteAsync(ProjectId projectId, ProjectDelete options, Cancellation if (options is not null) { url = Utils.AddParameter(url, "permanently_remove", options.PermanentlyRemove); - url = Utils.AddParameter(url, "full_path", options.FullPath); + if (options.FullPath is not null) + { + var @operator = url.Contains('?') ? "&" : "?"; + url += $"{@operator}full_path={options.FullPath}"; + } } return _api.Delete().ExecuteAsync(url, cancellationToken); From 065b10c02625d04c2e8b568f3dc952e68c59f452 Mon Sep 17 00:00:00 2001 From: Souleymene Rouchou Date: Fri, 26 Jun 2026 10:52:42 -0400 Subject: [PATCH 3/4] Use post-deletion path_with_namespace for permanently_remove full_path --- NGitLab.Tests/ProjectsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NGitLab.Tests/ProjectsTests.cs b/NGitLab.Tests/ProjectsTests.cs index b9161351..70256e44 100644 --- a/NGitLab.Tests/ProjectsTests.cs +++ b/NGitLab.Tests/ProjectsTests.cs @@ -615,7 +615,7 @@ public async Task DeleteAsync_WhenPermanentlyRemoveOnMarkedProject_ItIsDeleted() await projectClient.DeleteAsync(project.Id, new ProjectDelete { PermanentlyRemove = true, - FullPath = project.PathWithNamespace, + FullPath = markedProject.PathWithNamespace, }); // Assert: project no longer accessible From 16235fd32a4b3ebda090c7497e7d69752097a7ad Mon Sep 17 00:00:00 2001 From: Souleymene Rouchou Date: Thu, 2 Jul 2026 10:23:54 -0400 Subject: [PATCH 4/4] Address review comments and add PermanentlyDeleteAsync wrapper - Rename test method to DeleteAsync_WhenProjectExists_MarksProjectForDeletion - Add comment explaining the mock sets MarkedForDeletionOn to UtcNow - Clarify soft-delete setup steps in tests (required by GitLab before permanently_remove) - Use Utils.AddParameter for full_path query param (consistent with permanently_remove) - Add PermanentlyDeleteAsync wrapper that handles the two-step sequence internally, re-fetching PathWithNamespace after soft-delete to account for GitLab renaming the project Co-Authored-By: Claude --- NGitLab.Mock.Tests/ProjectsMockTests.cs | 28 ++++++++++++++++--- NGitLab.Mock/Clients/ProjectClient.cs | 7 +++++ NGitLab/IProjectClient.cs | 7 +++++ NGitLab/Impl/ProjectClient.cs | 12 ++++++-- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 2 ++ .../PublicAPI/net472/PublicAPI.Unshipped.txt | 2 ++ .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 2 ++ .../netstandard2.0/PublicAPI.Unshipped.txt | 2 ++ 8 files changed, 56 insertions(+), 6 deletions(-) diff --git a/NGitLab.Mock.Tests/ProjectsMockTests.cs b/NGitLab.Mock.Tests/ProjectsMockTests.cs index fcab489d..00a2a929 100644 --- a/NGitLab.Mock.Tests/ProjectsMockTests.cs +++ b/NGitLab.Mock.Tests/ProjectsMockTests.cs @@ -442,9 +442,9 @@ public void UpdateAsync_WhenProjectNotFound_ItThrows() } [Test] - public async Task DeleteAsync_WhenProjectExists_ItIsMarkedForDeletion() + public async Task DeleteAsync_WhenProjectExists_MarksProjectForDeletion() { - var projectFullPath = $"Test/{nameof(DeleteAsync_WhenProjectExists_ItIsMarkedForDeletion)}"; + var projectFullPath = $"Test/{nameof(DeleteAsync_WhenProjectExists_MarksProjectForDeletion)}"; using var server = new GitLabConfig() .WithUser("Test", isDefault: true) .WithProjectOfFullPath(projectFullPath) @@ -494,6 +494,7 @@ public async Task DeleteAsync_WithoutPermanentlyRemove_MarksProjectForDeletion() // Assert var markedProject = await projectClient.GetAsync(project.Id); Assert.That(markedProject.MarkedForDeletionOn, Is.Not.Null); + // The mock sets MarkedForDeletionOn to UtcNow at the time of soft-deletion. Assert.That(markedProject.MarkedForDeletionOn!.Value.Date, Is.EqualTo(DateTime.UtcNow.Date)); } @@ -509,7 +510,7 @@ public async Task DeleteAsync_WithPermanentlyRemove_AndMatchingFullPath_HardDele var projectClient = server.CreateClient().Projects; var project = await projectClient.GetAsync(projectFullPath); - // First call: soft-mark + // GitLab requires the project to be soft-deleted before permanently_remove can be used. await projectClient.DeleteAsync(project.Id); // Second call: permanently remove @@ -535,7 +536,7 @@ public async Task DeleteAsync_WithPermanentlyRemove_AndMismatchedFullPath_Throws var projectClient = server.CreateClient().Projects; var project = await projectClient.GetAsync(projectFullPath); - // Soft-mark first + // GitLab requires the project to be soft-deleted before permanently_remove can be used. await projectClient.DeleteAsync(project.Id); // Act: wrong full_path @@ -550,6 +551,25 @@ public async Task DeleteAsync_WithPermanentlyRemove_AndMismatchedFullPath_Throws Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); } + [Test] + public async Task PermanentlyDeleteAsync_HardDeletesProjectInOneCall() + { + var projectFullPath = $"Test/{nameof(PermanentlyDeleteAsync_HardDeletesProjectInOneCall)}"; + using var server = new GitLabConfig() + .WithUser("Test", isDefault: true) + .WithProjectOfFullPath(projectFullPath) + .BuildServer(); + + var projectClient = server.CreateClient().Projects; + var project = await projectClient.GetAsync(projectFullPath); + + // Act + await projectClient.PermanentlyDeleteAsync(project.Id); + + // Assert: project is gone + Assert.CatchAsync((Func)(() => projectClient.GetAsync(project.Id))); + } + [Test] public async Task GetAndSetProjectJobTokenScope() { diff --git a/NGitLab.Mock/Clients/ProjectClient.cs b/NGitLab.Mock/Clients/ProjectClient.cs index b286860d..2bfdf2c3 100644 --- a/NGitLab.Mock/Clients/ProjectClient.cs +++ b/NGitLab.Mock/Clients/ProjectClient.cs @@ -163,6 +163,13 @@ public async Task DeleteAsync(ProjectId projectId, ProjectDelete options, Cancel } } + public async Task PermanentlyDeleteAsync(ProjectId projectId, CancellationToken cancellationToken = default) + { + await DeleteAsync(projectId, cancellationToken).ConfigureAwait(false); + var markedProject = await GetAsync(projectId, cancellationToken: cancellationToken).ConfigureAwait(false); + await DeleteAsync(projectId, new ProjectDelete { PermanentlyRemove = true, FullPath = markedProject.PathWithNamespace, }, cancellationToken).ConfigureAwait(false); + } + private static void DeleteCore(Project project, ProjectDelete options) { if (options?.PermanentlyRemove == true) diff --git a/NGitLab/IProjectClient.cs b/NGitLab/IProjectClient.cs index 1e8506a0..ea8c3d05 100644 --- a/NGitLab/IProjectClient.cs +++ b/NGitLab/IProjectClient.cs @@ -55,6 +55,13 @@ public interface IProjectClient [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Internal requirement to have the CancellationToken optional")] Task DeleteAsync(ProjectId projectId, ProjectDelete options, CancellationToken cancellationToken = default); + /// + /// Permanently deletes a project. Soft-deletes it first (if not already marked for deletion), + /// then fetches the post-deletion path (GitLab renames the project on soft-delete) and issues + /// the permanent removal. + /// + Task PermanentlyDeleteAsync(ProjectId projectId, CancellationToken cancellationToken = default); + void Archive(long id); void Unarchive(long id); diff --git a/NGitLab/Impl/ProjectClient.cs b/NGitLab/Impl/ProjectClient.cs index 81288f76..d6fdf474 100644 --- a/NGitLab/Impl/ProjectClient.cs +++ b/NGitLab/Impl/ProjectClient.cs @@ -53,14 +53,22 @@ public Task DeleteAsync(ProjectId projectId, ProjectDelete options, Cancellation url = Utils.AddParameter(url, "permanently_remove", options.PermanentlyRemove); if (options.FullPath is not null) { - var @operator = url.Contains('?') ? "&" : "?"; - url += $"{@operator}full_path={options.FullPath}"; + url = Utils.AddParameter(url, "full_path", options.FullPath); } } return _api.Delete().ExecuteAsync(url, cancellationToken); } + public async Task PermanentlyDeleteAsync(ProjectId projectId, CancellationToken cancellationToken = default) + { + await DeleteAsync(projectId, cancellationToken).ConfigureAwait(false); + // After soft-delete GitLab renames the project (appends a deletion-schedule suffix), + // so we must re-fetch PathWithNamespace rather than using the pre-deletion path. + var markedProject = await GetAsync(projectId, cancellationToken: cancellationToken).ConfigureAwait(false); + await DeleteAsync(projectId, new ProjectDelete { PermanentlyRemove = true, FullPath = markedProject.PathWithNamespace, }, cancellationToken).ConfigureAwait(false); + } + public void Archive(long id) => _api.Post().Execute($"{Project.Url}/{id.ToString(CultureInfo.InvariantCulture)}/archive"); public void Unarchive(long id) => _api.Post().Execute($"{Project.Url}/{id.ToString(CultureInfo.InvariantCulture)}/unarchive"); diff --git a/NGitLab/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/net10.0/PublicAPI.Unshipped.txt index dec2ea18..ce3c6c09 100644 --- a/NGitLab/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -5298,6 +5298,8 @@ NGitLab.Models.ContainerRegistryTag.ShortRevision.set -> void NGitLab.Models.ContainerRegistryTag.TotalSize.get -> long NGitLab.Models.ContainerRegistryTag.TotalSize.set -> void NGitLab.IProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.IProjectClient.PermanentlyDeleteAsync(NGitLab.Models.ProjectId projectId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Impl.ProjectClient.PermanentlyDeleteAsync(NGitLab.Models.ProjectId projectId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.Models.ProjectDelete NGitLab.Models.ProjectDelete.FullPath.get -> string NGitLab.Models.ProjectDelete.FullPath.set -> void diff --git a/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt index 46fe8ff5..f9ee7e11 100644 --- a/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -5299,6 +5299,8 @@ NGitLab.Models.ContainerRegistryTag.ShortRevision.set -> void NGitLab.Models.ContainerRegistryTag.TotalSize.get -> long NGitLab.Models.ContainerRegistryTag.TotalSize.set -> void NGitLab.IProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.IProjectClient.PermanentlyDeleteAsync(NGitLab.Models.ProjectId projectId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Impl.ProjectClient.PermanentlyDeleteAsync(NGitLab.Models.ProjectId projectId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.Models.ProjectDelete NGitLab.Models.ProjectDelete.FullPath.get -> string NGitLab.Models.ProjectDelete.FullPath.set -> void diff --git a/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt index dec2ea18..ce3c6c09 100644 --- a/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -5298,6 +5298,8 @@ NGitLab.Models.ContainerRegistryTag.ShortRevision.set -> void NGitLab.Models.ContainerRegistryTag.TotalSize.get -> long NGitLab.Models.ContainerRegistryTag.TotalSize.set -> void NGitLab.IProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.IProjectClient.PermanentlyDeleteAsync(NGitLab.Models.ProjectId projectId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Impl.ProjectClient.PermanentlyDeleteAsync(NGitLab.Models.ProjectId projectId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.Models.ProjectDelete NGitLab.Models.ProjectDelete.FullPath.get -> string NGitLab.Models.ProjectDelete.FullPath.set -> void diff --git a/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 46fe8ff5..f9ee7e11 100644 --- a/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -5299,6 +5299,8 @@ NGitLab.Models.ContainerRegistryTag.ShortRevision.set -> void NGitLab.Models.ContainerRegistryTag.TotalSize.get -> long NGitLab.Models.ContainerRegistryTag.TotalSize.set -> void NGitLab.IProjectClient.DeleteAsync(NGitLab.Models.ProjectId projectId, NGitLab.Models.ProjectDelete options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.IProjectClient.PermanentlyDeleteAsync(NGitLab.Models.ProjectId projectId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +NGitLab.Impl.ProjectClient.PermanentlyDeleteAsync(NGitLab.Models.ProjectId projectId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task NGitLab.Models.ProjectDelete NGitLab.Models.ProjectDelete.FullPath.get -> string NGitLab.Models.ProjectDelete.FullPath.set -> void