From cea3b4a75549861c77c589c68b2344288629db9b Mon Sep 17 00:00:00 2001 From: Souleymene Rouchou Date: Thu, 25 Jun 2026 17:17:58 -0400 Subject: [PATCH 1/3] 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 82f4d028..8c503f08 100644 --- a/NGitLab/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -5278,3 +5278,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 408bbfbd..f9df4a0e 100644 --- a/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -5279,3 +5279,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 82f4d028..8c503f08 100644 --- a/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -5278,3 +5278,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 408bbfbd..f9df4a0e 100644 --- a/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -5279,3 +5279,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 0d79dabd4667be34b03971b6457b7edc83192605 Mon Sep 17 00:00:00 2001 From: Souleymene Rouchou Date: Fri, 26 Jun 2026 09:30:07 -0400 Subject: [PATCH 2/3] 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 bc5b29c4263fd202198fde1a9e7f64cc616c62a1 Mon Sep 17 00:00:00 2001 From: Souleymene Rouchou Date: Fri, 26 Jun 2026 10:52:42 -0400 Subject: [PATCH 3/3] 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