Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions NGitLab.Mock/Clients/MergeRequestApprovalClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public MergeRequestApprovals ApproveMergeRequest(MergeRequestApproveRequest requ
throw new NotImplementedException();
}

public void UnapproveMergeRequest()
{
throw new NotImplementedException();
Comment thread
louis-z marked this conversation as resolved.
}

public void ResetApprovals()
{
throw new NotImplementedException();
Expand Down
12 changes: 12 additions & 0 deletions NGitLab.Mock/Clients/MergeRequestClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,13 @@ public IMergeRequestApprovalClient ApprovalClient(long mergeRequestIid)
return new MergeRequestApprovalClient(Context, _projectId.GetValueOrDefault(), mergeRequestIid);
}

public IMergeRequestDraftNoteClient DraftNotes(long mergeRequestIid)
{
AssertProjectId();

return new MergeRequestDraftNoteClient(Context);
}

public Models.MergeRequest Close(long mergeRequestIid)
{
AssertProjectId();
Expand Down Expand Up @@ -729,6 +736,11 @@ public GitLabCollectionResponse<Diff> GetDiffsAsync(long mergeRequestIid)
throw new NotImplementedException();
}

public GitLabCollectionResponse<Diff> GetDiffsAsync(long mergeRequestIid, MergeRequestDiffQuery query)
{
throw new NotImplementedException();
}

public Task<TimeStats> TimeStatsAsync(long mergeRequestIid, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
Expand Down
23 changes: 23 additions & 0 deletions NGitLab.Mock/Clients/MergeRequestDraftNoteClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NGitLab.Models;

namespace NGitLab.Mock.Clients;

internal sealed class MergeRequestDraftNoteClient : ClientBase, IMergeRequestDraftNoteClient
{
public MergeRequestDraftNoteClient(ClientContext context)
: base(context)
{
}

public IEnumerable<DraftNote> All => throw new NotImplementedException();

public Task<DraftNote> CreateAsync(DraftNoteCreate draftNote, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();

public Task PublishAllAsync(CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
}
15 changes: 15 additions & 0 deletions NGitLab.Tests/Docker/GitLabTestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,11 @@ public Group CreateSubgroup(long parentGroupId, string slug, string name = null,
});

public (Project Project, MergeRequest MergeRequest) CreateMergeRequest(Action<MergeRequestCreate> configure = null, Action<ProjectCreate> configureProject = null)
{
return CreateMergeRequestAsync(configure, configureProject).GetAwaiter().GetResult();
}

public async Task<(Project Project, MergeRequest MergeRequest)> CreateMergeRequestAsync(Action<MergeRequestCreate> configure = null, Action<ProjectCreate> configureProject = null)
{
var client = Client;
var project = CreateProject(configureProject, initializeWithCommits: true);
Expand Down Expand Up @@ -273,6 +278,16 @@ public Group CreateSubgroup(long parentGroupId, string slug, string name = null,
configure?.Invoke(mergeRequestCreate);
var mr = client.GetMergeRequest(project.Id).Create(mergeRequestCreate);

// GitLab computes diff data and merge status asynchronously after MR creation.
// Wait until the transient states resolve so callers can immediately query diffs or approvals.
var mrClient = client.GetMergeRequest(project.Id);
mr = await RetryUntilAsync(
() => mrClient[mr.Iid],
result => result.DetailedMergeStatus != DetailedMergeStatus.Checking &&
result.DetailedMergeStatus != DetailedMergeStatus.Unchecked &&
result.DetailedMergeStatus != DetailedMergeStatus.Preparing,
TimeSpan.FromSeconds(60)).ConfigureAwait(false);

return (project, mr);
}

Expand Down
45 changes: 45 additions & 0 deletions NGitLab.Tests/MergeRequest/MergeRequestApprovalClientTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System;
using System.Threading.Tasks;
using NGitLab.Tests.Docker;
using NUnit.Framework;

namespace NGitLab.Tests;

public class MergeRequestApprovalClientTests
{
[Test]
[NGitLabRetry]
public async Task ApproveMergeRequest_and_UnapproveMergeRequest_roundtrip()
{
using var context = await GitLabTestContext.CreateAsync();
var (project, mergeRequest) = context.CreateMergeRequest();
var mrClient = context.Client.GetMergeRequest(project.Id);
var approvalClient = mrClient.ApprovalClient(mergeRequest.Iid);

// Approve
var approvals = approvalClient.ApproveMergeRequest();
Assert.That(approvals, Is.Not.Null);
Assert.That(approvals.Approved, Is.True, "MR should be marked as approved after ApproveMergeRequest");

// Unapprove — should not throw
Assert.DoesNotThrow((Action)(() => approvalClient.UnapproveMergeRequest()));

// After unapproval the approval state should show no approved-by entries
var state = approvalClient.Approvals;
Assert.That(state.ApprovedBy, Is.Empty.Or.Null, "No approvers should remain after unapproving");
}

[Test]
[NGitLabRetry]
public async Task UnapproveMergeRequest_on_unapproved_mr_throws()
{
// Arrange
using var context = await GitLabTestContext.CreateAsync();
var (project, mergeRequest) = context.CreateMergeRequest();
var approvalClient = context.Client.GetMergeRequest(project.Id).ApprovalClient(mergeRequest.Iid);

// Act/Assert
Assert.That((Action)(() => approvalClient.UnapproveMergeRequest()), Throws.TypeOf<GitLabException>(),
"Unapproving an unapproved MR should throw a GitLabException");
}
}
60 changes: 60 additions & 0 deletions NGitLab.Tests/MergeRequest/MergeRequestDiffsClientTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Linq;
using System.Threading.Tasks;
using NGitLab.Models;
using NGitLab.Tests.Docker;
using NUnit.Framework;

namespace NGitLab.Tests;

public class MergeRequestDiffsClientTests
{
[Test]
[NGitLabRetry]
public async Task GetDiffsAsync_returns_changed_files()
{
using var context = await GitLabTestContext.CreateAsync();
var (project, mergeRequest) = context.CreateMergeRequest();
var mrClient = context.Client.GetMergeRequest(project.Id);

var diffs = await mrClient.GetDiffsAsync(mergeRequest.Iid, query: null).ToListAsync();

Assert.That(diffs, Has.Count.GreaterThan(0), "At least one diff should be returned");
Assert.That(diffs[0].OldPath, Is.Not.Null.And.Not.Empty);
Assert.That(diffs[0].NewPath, Is.Not.Null.And.Not.Empty);
}

[Test]
[NGitLabRetry]
public async Task GetDiffsAsync_with_unidiff_returns_changed_files()
{
using var context = await GitLabTestContext.CreateAsync();
var (project, mergeRequest) = context.CreateMergeRequest();
var mrClient = context.Client.GetMergeRequest(project.Id);

var query = new MergeRequestDiffQuery { Unidiff = true };
var diffs = await mrClient.GetDiffsAsync(mergeRequest.Iid, query).ToListAsync();

Assert.That(diffs, Has.Count.GreaterThan(0), "At least one diff should be returned with unidiff=true");
Assert.That(diffs[0].OldPath, Is.Not.Null.And.Not.Empty);
Assert.That(diffs[0].NewPath, Is.Not.Null.And.Not.Empty);
}

[Test]
[NGitLabRetry]
public async Task GetDiffsAsync_unidiff_format_starts_with_unified_diff_header()
{
using var context = await GitLabTestContext.CreateAsync();
var (project, mergeRequest) = context.CreateMergeRequest();
var mrClient = context.Client.GetMergeRequest(project.Id);

var plainDiffs = await mrClient.GetDiffsAsync(mergeRequest.Iid, query: null).ToListAsync();
var unifiedDiffs = await mrClient.GetDiffsAsync(mergeRequest.Iid, new MergeRequestDiffQuery { Unidiff = true }).ToListAsync();

Assert.That(plainDiffs, Has.Count.EqualTo(unifiedDiffs.Count), "Both queries should return the same number of files");

// Unified diff format uses "--- a/..." / "+++ b/..." headers; the plain GitLab format does not.
var firstUnified = unifiedDiffs[0].Difference;
Assert.That(firstUnified, Does.StartWith("---").Or.StartWith("diff --git"),
"Unified diff content should start with standard unified-diff markers");
}
}
56 changes: 56 additions & 0 deletions NGitLab.Tests/MergeRequest/MergeRequestDraftNoteClientTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Linq;
using System.Threading.Tasks;
using NGitLab.Models;
using NGitLab.Tests.Docker;
using NUnit.Framework;

namespace NGitLab.Tests;

public class MergeRequestDraftNoteClientTests
{
[Test]
[NGitLabRetry]
public async Task CreateAsync_adds_draft_note_visible_in_All()
{
using var context = await GitLabTestContext.CreateAsync();
var (project, mergeRequest) = context.CreateMergeRequest();
var draftNoteClient = context.Client.GetMergeRequest(project.Id).DraftNotes(mergeRequest.Iid);

var created = await draftNoteClient.CreateAsync(new DraftNoteCreate { Note = "Draft review comment" });

Assert.That(created, Is.Not.Null);
Assert.That(created.Note, Is.EqualTo("Draft review comment"));
Assert.That(created.Id, Is.GreaterThan(0));

var all = draftNoteClient.All.ToList();
Assert.That(all, Has.Count.EqualTo(1), "The created draft note should appear in All");
Assert.That(all[0].Id, Is.EqualTo(created.Id));
}

[Test]
[NGitLabRetry]
public async Task PublishAllAsync_publishes_all_draft_notes()
{
using var context = await GitLabTestContext.CreateAsync();
var (project, mergeRequest) = context.CreateMergeRequest();
var mrClient = context.Client.GetMergeRequest(project.Id);
var draftNoteClient = mrClient.DraftNotes(mergeRequest.Iid);

await draftNoteClient.CreateAsync(new DraftNoteCreate { Note = "First draft" });
await draftNoteClient.CreateAsync(new DraftNoteCreate { Note = "Second draft" });

Assert.That(draftNoteClient.All.Count(), Is.EqualTo(2), "Two draft notes should exist before publishing");

await draftNoteClient.PublishAllAsync();

// After publishing, draft notes should be cleared
var remaining = draftNoteClient.All.ToList();
Assert.That(remaining, Is.Empty, "Draft notes should be gone after PublishAllAsync");

// The notes should now appear as regular MR notes/discussions
var notes = mrClient.Comments(mergeRequest.Iid).All.ToList();
Assert.That(notes.Any(n => string.Equals(n.Body, "First draft", System.StringComparison.Ordinal)
|| string.Equals(n.Body, "Second draft", System.StringComparison.Ordinal)), Is.True,
"Published draft notes should appear as regular comments");
}
}
2 changes: 2 additions & 0 deletions NGitLab/IMergeRequestApprovalClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ public interface IMergeRequestApprovalClient

MergeRequestApprovals ApproveMergeRequest(MergeRequestApproveRequest request = null);

void UnapproveMergeRequest();

/// <summary>
/// Available only for bot users based on project or group tokens.
/// </summary>
Expand Down
4 changes: 4 additions & 0 deletions NGitLab/IMergeRequestClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ public interface IMergeRequestClient

GitLabCollectionResponse<Diff> GetDiffsAsync(long mergeRequestIid);

GitLabCollectionResponse<Diff> GetDiffsAsync(long mergeRequestIid, MergeRequestDiffQuery query);

IMergeRequestCommentClient Comments(long mergeRequestIid);

IMergeRequestDiscussionClient Discussions(long mergeRequestIid);
Expand All @@ -61,6 +63,8 @@ public interface IMergeRequestClient

IMergeRequestApprovalClient ApprovalClient(long mergeRequestIid);

IMergeRequestDraftNoteClient DraftNotes(long mergeRequestIid);

IEnumerable<Issue> ClosesIssues(long mergeRequestIid);

/// <summary>
Expand Down
23 changes: 23 additions & 0 deletions NGitLab/IMergeRequestDraftNoteClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NGitLab.Models;

namespace NGitLab;

/// <summary>
/// Client for the GitLab draft notes API:
/// <c>GET/POST /projects/:id/merge_requests/:iid/draft_notes</c> and
/// <c>POST /projects/:id/merge_requests/:iid/draft_notes/bulk_publish</c>.
/// </summary>
public interface IMergeRequestDraftNoteClient
{
IEnumerable<DraftNote> All { get; }

Task<DraftNote> CreateAsync(DraftNoteCreate draftNote, CancellationToken cancellationToken = default);

/// <summary>
/// Publishes all draft notes for the merge request in a single request.
/// </summary>
Task PublishAllAsync(CancellationToken cancellationToken = default);
}
4 changes: 4 additions & 0 deletions NGitLab/Impl/MergeRequestApprovalClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public class MergeRequestApprovalClient : IMergeRequestApprovalClient
private readonly string _approvalsPath;
private readonly string _approversPath;
private readonly string _approvePath;
private readonly string _unapprovePath;
private readonly string _resetApprovalsPath;
private readonly string _approvalStatePath;

Expand All @@ -23,6 +24,7 @@ public MergeRequestApprovalClient(API api, string projectPath, long mergeRequest
_approvalsPath = projectPath + "/merge_requests/" + iid + "/approvals";
_approversPath = projectPath + "/merge_requests/" + iid + "/approvers";
_approvePath = projectPath + "/merge_requests/" + iid + "/approve";
_unapprovePath = projectPath + "/merge_requests/" + iid + "/unapprove";
_resetApprovalsPath = projectPath + "/merge_requests/" + iid + "/reset_approvals";
_approvalStatePath = projectPath + "/merge_requests/" + iid + "/approval_state";
}
Expand All @@ -32,6 +34,8 @@ public MergeRequestApprovalClient(API api, string projectPath, long mergeRequest
public MergeRequestApprovals ApproveMergeRequest(MergeRequestApproveRequest request = null)
=> _api.Post().With(request ?? new MergeRequestApproveRequest()).To<MergeRequestApprovals>(_approvePath);

public void UnapproveMergeRequest() => _api.Post().Execute(_unapprovePath);

public void ChangeApprovers(MergeRequestApproversChange approversChange) => _api.Put().With(approversChange).To<MergeRequestApproversChange>(_approversPath);

public void ResetApprovals() => _api.Put().Execute(_resetApprovalsPath);
Expand Down
13 changes: 13 additions & 0 deletions NGitLab/Impl/MergeRequestClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,17 @@ public GitLabCollectionResponse<Diff> GetDiffsAsync(long mergeRequestIid)
return _api.Get().GetAllAsync<Diff>(_path + "/merge_requests/" + mergeRequestIid.ToString(CultureInfo.InvariantCulture) + "/diffs");
}

public GitLabCollectionResponse<Diff> GetDiffsAsync(long mergeRequestIid, MergeRequestDiffQuery query)
{
var url = _path + "/merge_requests/" + mergeRequestIid.ToString(CultureInfo.InvariantCulture) + "/diffs";
if (query?.Unidiff == true)
{
url = Utils.AddParameter(url, "unidiff", "true");
}

return _api.Get().GetAllAsync<Diff>(url);
}

public Task<TimeStats> TimeStatsAsync(long mergeRequestIid, CancellationToken cancellationToken = default)
{
return _api.Get().ToAsync<TimeStats>(_path + "/merge_requests/" + mergeRequestIid.ToString(CultureInfo.InvariantCulture) + "/time_stats", cancellationToken);
Expand All @@ -184,6 +195,8 @@ public Task<TimeStats> TimeStatsAsync(long mergeRequestIid, CancellationToken ca

public IMergeRequestApprovalClient ApprovalClient(long mergeRequestIid) => new MergeRequestApprovalClient(_api, _path, mergeRequestIid);

public IMergeRequestDraftNoteClient DraftNotes(long mergeRequestIid) => new MergeRequestDraftNoteClient(_api, _path, mergeRequestIid);

public IMergeRequestChangeClient Changes(long mergeRequestIid) => new MergeRequestChangeClient(_api, _path, mergeRequestIid);

public GitLabCollectionResponse<ResourceLabelEvent> ResourceLabelEventsAsync(long projectId, long mergeRequestIid)
Expand Down
30 changes: 30 additions & 0 deletions NGitLab/Impl/MergeRequestDraftNoteClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using NGitLab.Models;

namespace NGitLab.Impl;

public class MergeRequestDraftNoteClient : IMergeRequestDraftNoteClient
{
private readonly API _api;
private readonly string _draftNotesPath;
private readonly string _bulkPublishPath;

public MergeRequestDraftNoteClient(API api, string projectPath, long mergeRequestIid)
{
_api = api;
var iid = mergeRequestIid.ToString(CultureInfo.InvariantCulture);
_draftNotesPath = projectPath + "/merge_requests/" + iid + "/draft_notes";
_bulkPublishPath = _draftNotesPath + "/bulk_publish";
}

public IEnumerable<DraftNote> All => _api.Get().GetAll<DraftNote>(_draftNotesPath);

public Task<DraftNote> CreateAsync(DraftNoteCreate draftNote, CancellationToken cancellationToken = default)
=> _api.Post().With(draftNote).ToAsync<DraftNote>(_draftNotesPath, cancellationToken);

public Task PublishAllAsync(CancellationToken cancellationToken = default)
=> _api.Post().ExecuteAsync(_bulkPublishPath, cancellationToken);
}
Loading
Loading