diff --git a/NGitLab.Mock.Tests/MergeRequestCommentsMockTests.cs b/NGitLab.Mock.Tests/MergeRequestCommentsMockTests.cs new file mode 100644 index 00000000..8cc66b3f --- /dev/null +++ b/NGitLab.Mock.Tests/MergeRequestCommentsMockTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Linq; +using System.Net; +using NGitLab.Models; +using NUnit.Framework; + +namespace NGitLab.Mock.Tests; + +public class MergeRequestCommentsMockTests +{ + [Test] + public void AddComment_CreatesIndividualNoteWithSyntheticDiscussionId() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + client.Comments(mr.Iid).Add(new MergeRequestCommentCreate { Body = "Plain note" }); + + var discussion = client.Comments(mr.Iid).Discussions.Single(d => string.Equals(d.Notes[0].Body, "Plain note", StringComparison.Ordinal)); + + Assert.That(discussion.IndividualNote, Is.True, "a comment added outside the discussions endpoint has no thread id and is reported as an individual note"); + Assert.That(discussion.Id, Is.Not.Null.And.Not.Empty); + Assert.That(discussion.Notes, Has.Length.EqualTo(1)); + } + } + + [Test] + public void EditComment_UpdatesBodyAndPersists() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + var comment = client.Comments(mr.Iid).Add(new MergeRequestCommentCreate { Body = "Original body" }); + var edited = client.Comments(mr.Iid).Edit(comment.Id, new MergeRequestCommentEdit { Body = "Edited body" }); + + Assert.That(edited.Body, Is.EqualTo("Edited body")); + + var reread = client.Comments(mr.Iid).All.Single(c => c.Id == comment.Id); + Assert.That(reread.Body, Is.EqualTo("Edited body")); + } + } + + [Test] + public void DeleteComment_RemovesCommentFromMergeRequest() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + var comment = client.Comments(mr.Iid).Add(new MergeRequestCommentCreate { Body = "To be deleted" }); + client.Comments(mr.Iid).Delete(comment.Id); + + Assert.That(client.Comments(mr.Iid).All.Any(c => c.Id == comment.Id), Is.False); + } + } + + [Test] + public void Reply_AppendsNoteToExistingDiscussionThread() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + var discussion = client.Discussions(mr.Iid).Add(new MergeRequestDiscussionCreate { Body = "Original" }); + client.Comments(mr.Iid).Add(discussion.Id, new MergeRequestCommentCreate { Body = "A reply" }); + + var reread = client.Discussions(mr.Iid).Get(discussion.Id); + Assert.That(reread.IndividualNote, Is.False); + Assert.That(reread.Notes.Select(n => n.Body), Is.EqualTo(new[] { "Original", "A reply" })); + + var discussions = client.Comments(mr.Iid).Discussions.ToArray(); + Assert.That(discussions, Has.Length.EqualTo(1), "the reply should join the existing thread instead of creating a new one"); + Assert.That(discussions[0].Notes, Has.Length.EqualTo(2)); + } + } + + [Test] + public void Reply_UnknownDiscussionId_ThrowsNotFound() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + Assert.That(Assert.Throws((Action)(() => client.Comments(mr.Iid).Add("unknown-id", new MergeRequestCommentCreate { Body = "x" }))).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + } + + [Test] + public void Comments_UnknownId_ThrowNotFound() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + Assert.That(Assert.Throws((Action)(() => client.Comments(mr.Iid).Edit(99999, new MergeRequestCommentEdit { Body = "x" }))).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That(Assert.Throws((Action)(() => client.Comments(mr.Iid).Delete(99999))).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + } + + [Test] + public void ArchivedProject_AddComment_ThrowsForbidden() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + project.Archived = true; + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + Assert.That(Assert.Throws((Action)(() => client.Comments(mr.Iid).Add(new MergeRequestCommentCreate { Body = "Should fail" }))).StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + } +} diff --git a/NGitLab.Mock.Tests/MergeRequestDiscussionsMockTests.cs b/NGitLab.Mock.Tests/MergeRequestDiscussionsMockTests.cs new file mode 100644 index 00000000..2df2532a --- /dev/null +++ b/NGitLab.Mock.Tests/MergeRequestDiscussionsMockTests.cs @@ -0,0 +1,188 @@ +using System; +using System.Linq; +using System.Net; +using NGitLab.Models; +using NUnit.Framework; + +namespace NGitLab.Mock.Tests; + +public class MergeRequestDiscussionsMockTests +{ + [Test] + public void AddDiscussion_GeneralComment_IsRetrievableThroughBothCommentsAndDiscussionsClients() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + var discussion = client.Discussions(mr.Iid).Add(new MergeRequestDiscussionCreate + { + Body = "General comment", + }); + + Assert.That(discussion.Notes[0].Body, Is.EqualTo("General comment")); + Assert.That(discussion.Notes[0].Position, Is.Null); + Assert.That(discussion.Id, Is.Not.Null.And.Not.Empty); + Assert.That(discussion.IndividualNote, Is.False, "a discussion created via the discussions endpoint is not an individual note, even with a single comment"); + + var comments = client.Comments(mr.Iid).All.ToArray(); + Assert.That(comments.Any(c => string.Equals(c.Body, "General comment", StringComparison.Ordinal)), Is.True); + + var rereadFromCommentsClient = client.Comments(mr.Iid).Discussions.Single(d => string.Equals(d.Notes[0].Body, "General comment", StringComparison.Ordinal)); + Assert.That(rereadFromCommentsClient.Id, Is.EqualTo(discussion.Id)); + Assert.That(rereadFromCommentsClient.IndividualNote, Is.False); + + var rereadById = client.Discussions(mr.Iid).Get(discussion.Id); + Assert.That(rereadById.Notes[0].Body, Is.EqualTo("General comment")); + } + } + + [Test] + public void AddDiscussion_InlineComment_RoundtripsPositionAndHeadSha() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + var version = client.GetVersionsAsync(mr.Iid).First(); + var position = MergeRequestMockTestHelper.CreateTextPosition(version); + + var discussion = client.Discussions(mr.Iid).Add(new MergeRequestDiscussionCreate + { + Body = "Inline comment", + Position = position, + }); + + Assert.That(discussion.Notes[0].Position, Is.Not.Null); + Assert.That(discussion.Notes[0].Position.NewPath, Is.EqualTo("file.txt")); + Assert.That(discussion.Notes[0].Position.NewLine, Is.EqualTo(1)); + Assert.That(discussion.Notes[0].Position.HeadSha.ToString(), Is.EqualTo(new Sha1(version.HeadCommitSha).ToString())); + + var discussions = client.Comments(mr.Iid).Discussions.ToArray(); + var reread = discussions.Single(d => string.Equals(d.Notes[0].Body, "Inline comment", StringComparison.Ordinal)); + Assert.That(reread.Notes[0].Position, Is.Not.Null); + Assert.That(reread.Notes[0].Position.NewPath, Is.EqualTo("file.txt")); + Assert.That(reread.Notes[0].Position.NewLine, Is.EqualTo(1)); + Assert.That(reread.Id, Is.EqualTo(discussion.Id)); + } + } + + [Test] + public void DeleteDiscussion_RemovesBothNoteAndDiscussion() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + var discussion = client.Discussions(mr.Iid).Add(new MergeRequestDiscussionCreate { Body = "To be deleted" }); + client.Discussions(mr.Iid).Delete(discussion.Id, discussion.Notes[0].Id); + + Assert.That(client.Comments(mr.Iid).All.Any(c => c.Id == discussion.Notes[0].Id), Is.False); + Assert.That(client.Discussions(mr.Iid).All.Any(d => string.Equals(d.Id, discussion.Id, StringComparison.Ordinal)), Is.False); + } + } + + [Test] + public void Resolve_PersistsResolvedAndUnresolvedState_OnReread() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + var discussion = client.Discussions(mr.Iid).Add(new MergeRequestDiscussionCreate { Body = "Resolvable comment" }); + Assert.That(discussion.Notes[0].Resolved, Is.False); + + var resolved = client.Discussions(mr.Iid).Resolve(new MergeRequestDiscussionResolve { Id = discussion.Id, Resolved = true }); + Assert.That(resolved.Notes[0].Resolved, Is.True, "Resolve() returns notes marked as resolved"); + + var rereadResolved = client.Discussions(mr.Iid).Get(discussion.Id); + Assert.That(rereadResolved.Notes[0].Resolved, Is.True, "Resolve() must persist resolution onto the stored comment"); + + var unresolved = client.Discussions(mr.Iid).Resolve(new MergeRequestDiscussionResolve { Id = discussion.Id, Resolved = false }); + Assert.That(unresolved.Notes[0].Resolved, Is.False); + + var rereadUnresolved = client.Discussions(mr.Iid).Get(discussion.Id); + Assert.That(rereadUnresolved.Notes[0].Resolved, Is.False, "Resolve() with Resolved=false must persist the unresolved state"); + } + } + + [Test] + public void AddDiscussion_MarksNoteAsResolvable() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + var discussion = client.Discussions(mr.Iid).Add(new MergeRequestDiscussionCreate { Body = "Resolvable comment" }); + + Assert.That(discussion.Notes[0].Resolvable, Is.True, "discussions created via the discussions endpoint are resolvable threads, matching real GitLab"); + } + } + + [Test] + public void Resolve_TogglesBlockingDiscussionsResolved() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + project.AllThreadsMustBeResolvedToMerge = true; + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + var discussion = client.Discussions(mr.Iid).Add(new MergeRequestDiscussionCreate { Body = "Blocking comment" }); + Assert.That(client[mr.Iid].BlockingDiscussionsResolved, Is.False, "an unresolved resolvable discussion should block merging"); + + client.Discussions(mr.Iid).Resolve(new MergeRequestDiscussionResolve { Id = discussion.Id, Resolved = true }); + Assert.That(client[mr.Iid].BlockingDiscussionsResolved, Is.True, "resolving the discussion should clear the block"); + } + } + + [Test] + public void AddDiscussion_CalledTwice_CreatesSeparateSingleNoteThreads() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + var first = client.Discussions(mr.Iid).Add(new MergeRequestDiscussionCreate { Body = "First reply" }); + var second = client.Discussions(mr.Iid).Add(new MergeRequestDiscussionCreate { Body = "Second reply" }); + + Assert.That(first.Id, Is.Not.EqualTo(second.Id), "every Discussions.Add() call starts a new thread; appending to an existing thread is done via Comments().Add(discussionId, ...)"); + + var discussions = client.Comments(mr.Iid).Discussions.ToArray(); + Assert.That(discussions, Has.Length.EqualTo(2)); + Assert.That(discussions.All(d => d.Notes.Length == 1), Is.True); + } + } + + [Test] + public void Discussions_UnknownId_ThrowNotFound() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + Assert.That(Assert.Throws((Action)(() => client.Discussions(mr.Iid).Get("unknown-id"))).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That(Assert.Throws((Action)(() => client.Discussions(mr.Iid).Delete("unknown-id", 1))).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That(Assert.Throws((Action)(() => client.Discussions(mr.Iid).Resolve(new MergeRequestDiscussionResolve { Id = "unknown-id", Resolved = true }))).StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + } + + [Test] + public void ArchivedProject_AddDiscussion_ThrowsForbidden() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + project.Archived = true; + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + Assert.That(Assert.Throws((Action)(() => client.Discussions(mr.Iid).Add(new MergeRequestDiscussionCreate { Body = "Should fail" }))).StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + } +} diff --git a/NGitLab.Mock.Tests/MergeRequestMockTestHelper.cs b/NGitLab.Mock.Tests/MergeRequestMockTestHelper.cs new file mode 100644 index 00000000..4ba88f9b --- /dev/null +++ b/NGitLab.Mock.Tests/MergeRequestMockTestHelper.cs @@ -0,0 +1,38 @@ +using NGitLab.Models; + +namespace NGitLab.Mock.Tests; + +internal static class MergeRequestMockTestHelper +{ + public static Position CreateTextPosition(MergeRequestVersion version) + { + return new Position + { + NewPath = "file.txt", + NewLine = 1, + PositionType = new DynamicEnum(PositionType.Text), + BaseSha = new Sha1(version.BaseCommitSha), + StartSha = new Sha1(version.StartCommitSha), + HeadSha = new Sha1(version.HeadCommitSha), + }; + } + + public static (GitLabServer Server, Project Project, MergeRequest MergeRequest, User User) CreateProjectWithMergeRequest() + { + var server = new GitLabServer(); + var user = server.Users.AddNew("maintainer"); + var group = new Group("TestGroup"); + server.Groups.Add(group); + var project = new Project("Test") { Visibility = VisibilityLevel.Internal }; + group.Projects.Add(project); + project.Permissions.Add(new Permission(user, AccessLevel.Maintainer)); + + project.Repository.Commit(user, "Initial commit"); + project.Repository.CreateAndCheckoutBranch("feature"); + project.Repository.Commit(user, "add file", new[] { File.CreateFromText("file.txt", "new content") }); + + var mr = project.CreateMergeRequest(user, "A title", "A description", project.DefaultBranch, "feature"); + + return (server, project, mr, user); + } +} diff --git a/NGitLab.Mock.Tests/MergeRequestVersionsMockTests.cs b/NGitLab.Mock.Tests/MergeRequestVersionsMockTests.cs new file mode 100644 index 00000000..3cc4c37c --- /dev/null +++ b/NGitLab.Mock.Tests/MergeRequestVersionsMockTests.cs @@ -0,0 +1,118 @@ +using System; +using System.Linq; +using System.Net; +using NUnit.Framework; + +namespace NGitLab.Mock.Tests; + +public class MergeRequestVersionsMockTests +{ + [Test] + public void GetVersionsAsync_ReturnsVersionMatchingCurrentShas() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + var versions = client.GetVersionsAsync(mr.Iid).ToArray(); + + Assert.That(versions, Is.Not.Empty); + Assert.That(versions[0].HeadCommitSha, Is.EqualTo(mr.HeadSha)); + Assert.That(versions[0].BaseCommitSha, Is.EqualTo(mr.BaseSha)); + Assert.That(versions[0].StartCommitSha, Is.EqualTo(mr.StartSha)); + } + } + + [Test] + public void GetVersionsAsync_SourceBranchPush_AddsNewestVersionFirstWithUnchangedBase() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + var firstVersion = client.GetVersionsAsync(mr.Iid).Single(); + + project.Repository.Commit(user, "second change", "feature", new[] { File.CreateFromText("file2.txt", "more content") }); + + var versions = client.GetVersionsAsync(mr.Iid).ToArray(); + + Assert.That(versions, Has.Length.EqualTo(2)); + Assert.That(versions[0].HeadCommitSha, Is.EqualTo(mr.HeadSha), "newest version is returned first, matching current HEAD"); + Assert.That(versions[1].HeadCommitSha, Is.EqualTo(firstVersion.HeadCommitSha)); + Assert.That(versions[0].HeadCommitSha, Is.Not.EqualTo(versions[1].HeadCommitSha)); + + Assert.That(versions[0].BaseCommitSha, Is.EqualTo(firstVersion.BaseCommitSha), "merge base with the target branch is unchanged since only the source branch moved"); + Assert.That(versions[0].StartCommitSha, Is.EqualTo(firstVersion.StartCommitSha), "target branch tip is unchanged since only the source branch moved"); + Assert.That(versions[0].BaseCommitSha, Is.EqualTo(mr.BaseSha)); + Assert.That(versions[0].StartCommitSha, Is.EqualTo(mr.StartSha)); + } + } + + [Test] + public void GetVersionsAsync_TargetBranchPush_AddsNewestVersionFirstWithUnchangedHead() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + var firstVersion = client.GetVersionsAsync(mr.Iid).Single(); + + project.Repository.Commit(user, "target branch change", project.DefaultBranch, new[] { File.CreateFromText("target-file.txt", "target content") }); + + var versions = client.GetVersionsAsync(mr.Iid).ToArray(); + + Assert.That(versions, Has.Length.EqualTo(2)); + Assert.That(versions[0].StartCommitSha, Is.EqualTo(mr.StartSha), "newest version reflects the new target branch tip"); + Assert.That(versions[0].StartCommitSha, Is.Not.EqualTo(firstVersion.StartCommitSha)); + + Assert.That(versions[0].HeadCommitSha, Is.EqualTo(firstVersion.HeadCommitSha), "source branch tip is unchanged since only the target branch moved"); + Assert.That(versions[0].BaseCommitSha, Is.EqualTo(firstVersion.BaseCommitSha), "merge base is unchanged since the previous target tip remains an ancestor of both branches"); + Assert.That(versions[0].HeadCommitSha, Is.EqualTo(mr.HeadSha)); + } + } + + [Test] + public void GetVersionsAsync_Rebase_AddsVersionWithNewHeadAndBaseAdvancedToTargetTip() + { + var (server, project, mr, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + var firstVersion = client.GetVersionsAsync(mr.Iid).Single(); + + project.Repository.Commit(user, "target branch change", project.DefaultBranch, new[] { File.CreateFromText("target-file.txt", "target content") }); + var versionAfterTargetPush = client.GetVersionsAsync(mr.Iid).First(); + + client.Rebase(mr.Iid); + + var versions = client.GetVersionsAsync(mr.Iid).ToArray(); + + Assert.That(versions, Has.Length.EqualTo(3)); + var newestVersion = versions[0]; + + Assert.That(newestVersion.HeadCommitSha, Is.Not.EqualTo(versionAfterTargetPush.HeadCommitSha), "rebasing rewrites the source branch tip onto the target branch tip"); + Assert.That(newestVersion.HeadCommitSha, Is.EqualTo(mr.HeadSha)); + + Assert.That(newestVersion.BaseCommitSha, Is.Not.EqualTo(firstVersion.BaseCommitSha), "unlike a plain push, rebasing advances the merge base to the target tip the source was rebased onto"); + Assert.That(newestVersion.BaseCommitSha, Is.EqualTo(newestVersion.StartCommitSha)); + Assert.That(newestVersion.StartCommitSha, Is.EqualTo(mr.StartSha)); + } + } + + [Test] + public void GetVersionsAsync_UnknownMergeRequest_ThrowsNotFound() + { + var (server, project, _, user) = MergeRequestMockTestHelper.CreateProjectWithMergeRequest(); + using (server) + { + var client = server.CreateClient(user).GetMergeRequest(project.Id); + + var exception = Assert.Throws((Action)(() => client.GetVersionsAsync(99999).ToArray())); + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + } +} diff --git a/NGitLab.Mock/Clients/MergeRequestClient.cs b/NGitLab.Mock/Clients/MergeRequestClient.cs index ae5a0c62..9e662619 100644 --- a/NGitLab.Mock/Clients/MergeRequestClient.cs +++ b/NGitLab.Mock/Clients/MergeRequestClient.cs @@ -728,7 +728,15 @@ public Models.MergeRequest Update(long mergeRequestIid, MergeRequestUpdate merge public GitLabCollectionResponse GetVersionsAsync(long mergeRequestIid) { - throw new NotImplementedException(); + AssertProjectId(); + + using (Context.BeginOperationScope()) + { + var mergeRequest = GetMergeRequest(_projectId.GetValueOrDefault(), mergeRequestIid); + + // GitLab returns versions ordered from most recent to oldest. + return GitLabCollectionResponse.Create(mergeRequest.Versions.Reverse()); + } } public GitLabCollectionResponse GetDiffsAsync(long mergeRequestIid) diff --git a/NGitLab.Mock/Clients/MergeRequestCommentClient.cs b/NGitLab.Mock/Clients/MergeRequestCommentClient.cs index fd17fc64..9340c4e9 100644 --- a/NGitLab.Mock/Clients/MergeRequestCommentClient.cs +++ b/NGitLab.Mock/Clients/MergeRequestCommentClient.cs @@ -81,13 +81,18 @@ public Models.MergeRequestComment Add(string discussionId, MergeRequestCommentCr if (project.Archived) throw GitLabException.Forbidden(); + var mergeRequest = GetMergeRequest(); + if (!mergeRequest.Comments.Any(c => string.Equals(c.ThreadId, discussionId, StringComparison.Ordinal))) + throw GitLabException.NotFound(); + var comment = new MergeRequestComment { Author = Context.User, Body = commentCreate.Body, + ThreadId = discussionId, }; - GetMergeRequest().Comments.Add(comment); + mergeRequest.Comments.Add(comment); return comment.ToMergeRequestCommentClient(); } } diff --git a/NGitLab.Mock/Clients/MergeRequestDiscussionClient.cs b/NGitLab.Mock/Clients/MergeRequestDiscussionClient.cs index 2ee4cd11..c599649d 100644 --- a/NGitLab.Mock/Clients/MergeRequestDiscussionClient.cs +++ b/NGitLab.Mock/Clients/MergeRequestDiscussionClient.cs @@ -53,6 +53,7 @@ public MergeRequestDiscussion Add(Models.MergeRequestComment comment) { Body = comment.Body, CreatedAt = null, + Position = comment.Position, }); } @@ -70,6 +71,9 @@ public MergeRequestDiscussion Add(MergeRequestDiscussionCreate commentCreate) { Author = Context.User, Body = commentCreate.Body, + Position = commentCreate.Position, + ThreadId = Guid.NewGuid().ToString("N"), + Resolvable = true, }; GetMergeRequest().Comments.Add(comment); @@ -87,17 +91,23 @@ public MergeRequestDiscussion Resolve(MergeRequestDiscussionResolve resolve) { using (Context.BeginOperationScope()) { - var discussions = GetMergeRequest().GetDiscussions(); + var mergeRequest = GetMergeRequest(); + var discussions = mergeRequest.GetDiscussions(); var discussion = discussions.FirstOrDefault(x => string.Equals(x.Id, resolve.Id, StringComparison.Ordinal)); if (discussion == null) throw GitLabException.NotFound(); - foreach (var note in discussion.Notes) + var allComments = mergeRequest.Comments; + foreach (var discussionNote in discussion.Notes) { - note.Resolved = true; + var note = allComments.FirstOrDefault(x => x.Id == discussionNote.Id); + if (note != null) + { + note.Resolved = resolve.Resolved; + } } - return discussion; + return mergeRequest.GetDiscussions().First(x => string.Equals(x.Id, resolve.Id, StringComparison.Ordinal)); } } diff --git a/NGitLab.Mock/MergeRequest.cs b/NGitLab.Mock/MergeRequest.cs index a2273d02..351ccdb5 100644 --- a/NGitLab.Mock/MergeRequest.cs +++ b/NGitLab.Mock/MergeRequest.cs @@ -22,6 +22,7 @@ public sealed class MergeRequest : GitLabObject private string _baseSha; private bool _hasConflicts; private int? _divergedCommitsCount; + private readonly List _versions = []; public MergeRequest() { @@ -108,6 +109,15 @@ public int? DivergedCommitsCount } } + public IReadOnlyList Versions + { + get + { + RefreshInternalState(); + return _versions; + } + } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset? MergedAt { get; set; } @@ -400,5 +410,16 @@ private void RefreshInternalState() { _hasConflicts = true; } + + _versions.Add(new Models.MergeRequestVersion + { + Id = _versions.Count + 1, + MergeRequestId = Id, + BaseCommitSha = _baseSha, + StartCommitSha = _startSha, + HeadCommitSha = _headSha, + CreatedAt = DateTime.UtcNow, + State = State.ToString(), + }); } } diff --git a/NGitLab.Mock/MergeRequestComment.cs b/NGitLab.Mock/MergeRequestComment.cs index 95109a58..2a2015a9 100644 --- a/NGitLab.Mock/MergeRequestComment.cs +++ b/NGitLab.Mock/MergeRequestComment.cs @@ -10,6 +10,8 @@ public sealed class MergeRequestComment : Note public override long NoticableIid => Parent.Iid; + public Models.Position Position { get; set; } + internal Models.MergeRequestComment ToMergeRequestCommentClient() { return new Models.MergeRequestComment @@ -23,6 +25,7 @@ internal Models.MergeRequestComment ToMergeRequestCommentClient() System = System, Type = NoteableType, Author = Author.ToUserClient(), + Position = Position, }; } } diff --git a/NGitLab.Mock/PublicAPI.Unshipped.txt b/NGitLab.Mock/PublicAPI.Unshipped.txt index 743881ac..17a0a8ef 100644 --- a/NGitLab.Mock/PublicAPI.Unshipped.txt +++ b/NGitLab.Mock/PublicAPI.Unshipped.txt @@ -700,6 +700,7 @@ NGitLab.Mock.MergeRequest.Title.get -> string NGitLab.Mock.MergeRequest.Title.set -> void NGitLab.Mock.MergeRequest.UpdatedAt.get -> System.DateTimeOffset NGitLab.Mock.MergeRequest.UpdatedAt.set -> void +NGitLab.Mock.MergeRequest.Versions.get -> System.Collections.Generic.IReadOnlyList NGitLab.Mock.MergeRequest.WebUrl.get -> string NGitLab.Mock.MergeRequest.WorkInProgress.get -> bool NGitLab.Mock.MergeRequestChangeCollection @@ -715,6 +716,8 @@ NGitLab.Mock.MergeRequestCollection.MergeRequestCollection(NGitLab.Mock.GitLabOb NGitLab.Mock.MergeRequestComment NGitLab.Mock.MergeRequestComment.MergeRequestComment() -> void NGitLab.Mock.MergeRequestComment.Parent.get -> NGitLab.Mock.MergeRequest +NGitLab.Mock.MergeRequestComment.Position.get -> NGitLab.Models.Position +NGitLab.Mock.MergeRequestComment.Position.set -> void NGitLab.Mock.Milestone NGitLab.Mock.Milestone.ClosedAt.get -> System.DateTimeOffset? NGitLab.Mock.Milestone.ClosedAt.set -> void @@ -1381,7 +1384,6 @@ static NGitLab.Mock.TemporaryDirectory.DeleteDirectory(string path) -> void static NGitLab.Mock.TemporaryDirectory.DeleteFile(string path) -> void static NGitLab.Mock.UserRef.implicit operator NGitLab.Mock.UserRef(NGitLab.Mock.User user) -> NGitLab.Mock.UserRef virtual NGitLab.Mock.Collection.Add(T item) -> void - NGitLab.Mock.ContainerRepository NGitLab.Mock.ContainerRepository.ContainerRepository() -> void NGitLab.Mock.ContainerRepository.Id.get -> long @@ -1399,7 +1401,6 @@ NGitLab.Mock.ContainerRegistryTagEntry.ToClientContainerRegistryTag() -> NGitLab NGitLab.Mock.ContainerRepositoryCollection NGitLab.Mock.ContainerRepositoryCollection.ContainerRepositoryCollection(NGitLab.Mock.GitLabObject parent) -> void NGitLab.Mock.Project.ContainerRepositories.get -> NGitLab.Mock.ContainerRepositoryCollection - NGitLab.Mock.Config.GitLabContainerRepository NGitLab.Mock.Config.GitLabContainerRepository.GitLabContainerRepository() -> void NGitLab.Mock.Config.GitLabContainerRepository.Name.get -> string diff --git a/NGitLab.Tests/Docker/GitLabTestContext.cs b/NGitLab.Tests/Docker/GitLabTestContext.cs index cc030b4e..0bb96a13 100644 --- a/NGitLab.Tests/Docker/GitLabTestContext.cs +++ b/NGitLab.Tests/Docker/GitLabTestContext.cs @@ -244,14 +244,10 @@ public Group CreateSubgroup(long parentGroupId, string slug, string name = null, var project = CreateProject(configureProject, initializeWithCommits: true); const string BranchForMRName = "branch-for-mr"; - s_gitlabRetryPolicy.Execute(() => client.GetRepository(project.Id).Files.Create(new FileUpsert { Branch = project.DefaultBranch, CommitMessage = "test", Content = "test", Path = "test.md" })); - s_gitlabRetryPolicy.Execute(() => client.GetRepository(project.Id).Branches.Create(new BranchCreate { Name = BranchForMRName, Ref = project.DefaultBranch })); + var defaultBranchBeforeCreation = project.DefaultBranch; - // Restore the default branch because sometimes GitLab change the default branch to "branch-for-mr" - project = client.Projects.Update(project.Id.ToString(CultureInfo.InvariantCulture), new ProjectUpdate - { - DefaultBranch = project.DefaultBranch, - }); + s_gitlabRetryPolicy.Execute(() => client.GetRepository(project.Id).Branches.Create(new BranchCreate { Name = BranchForMRName, Ref = project.DefaultBranch })); + Assert.That(project.DefaultBranch.Equals(defaultBranchBeforeCreation), "Default branch should not change after new branch creation.."); var branch = client.GetRepository(project.Id).Branches.All.FirstOrDefault(b => string.Equals(b.Name, project.DefaultBranch, StringComparison.Ordinal)); Assert.That(branch, Is.Not.Null, $"Branch '{project.DefaultBranch}' should exist"); @@ -261,7 +257,7 @@ public Group CreateSubgroup(long parentGroupId, string slug, string name = null, Assert.That(branch, Is.Not.Null, $"Branch '{BranchForMRName}' should exist"); Assert.That(branch.Protected, Is.False, $"Branch '{BranchForMRName}' should not be protected"); - s_gitlabRetryPolicy.Execute(() => client.GetRepository(project.Id).Files.Update(new FileUpsert { Branch = BranchForMRName, CommitMessage = "test", Content = "test2", Path = "test.md" })); + s_gitlabRetryPolicy.Execute(() => client.GetRepository(project.Id).Files.Update(new FileUpsert { Branch = BranchForMRName, CommitMessage = "test", Content = "test2", Path = "TestFile0.txt" })); var mergeRequestCreate = new MergeRequestCreate { @@ -278,9 +274,7 @@ public Group CreateSubgroup(long parentGroupId, string slug, string name = null, 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, + result => result.DetailedMergeStatus == DetailedMergeStatus.Mergeable, TimeSpan.FromSeconds(60)).ConfigureAwait(false); return (project, mr); diff --git a/NGitLab.Tests/MergeRequest/MergeRequestChangesClientTests.cs b/NGitLab.Tests/MergeRequest/MergeRequestChangesClientTests.cs index 484ae116..74955986 100644 --- a/NGitLab.Tests/MergeRequest/MergeRequestChangesClientTests.cs +++ b/NGitLab.Tests/MergeRequest/MergeRequestChangesClientTests.cs @@ -27,6 +27,6 @@ public async Task GetChangesOnMergeRequest() Assert.That(changes[0].DeletedFile, Is.False); Assert.That(changes[0].NewFile, Is.False); Assert.That(changes[0].RenamedFile, Is.False); - Assert.That(changes[0].Diff, Is.EqualTo("@@ -1 +1 @@\n-test\n\\ No newline at end of file\n+test2\n\\ No newline at end of file\n")); + Assert.That(changes[0].Diff, Is.EqualTo("@@ -1 +1 @@\n-this project should only live during the unit tests, you can delete if you find some\n\\ No newline at end of file\n+test2\n\\ No newline at end of file\n")); } } diff --git a/NGitLab.Tests/MergeRequest/MergeRequestClientTests.cs b/NGitLab.Tests/MergeRequest/MergeRequestClientTests.cs index 5ca19c31..ec9a050b 100644 --- a/NGitLab.Tests/MergeRequest/MergeRequestClientTests.cs +++ b/NGitLab.Tests/MergeRequest/MergeRequestClientTests.cs @@ -282,24 +282,6 @@ public async Task Test_cancel_merge_when_pipeline_succeeds() AcceptAndCancelMergeRequest(mergeRequestClient, mergeRequest); } - [Test] - [NGitLabRetry] - public async Task Test_merge_request_versions() - { - using var context = await GitLabTestContext.CreateAsync(); - var (project, mergeRequest) = await context.CreateMergeRequestAsync(); - var mergeRequestClient = context.Client.GetMergeRequest(project.Id); - - var versions = await GitLabTestContext.RetryUntilAsync( - () => mergeRequestClient.GetVersionsAsync(mergeRequest.Iid), - versions => versions.Any(), - TimeSpan.FromSeconds(10)); - - var version = versions.First(); - - Assert.That(version.HeadCommitSha, Is.EqualTo(mergeRequest.Sha)); - } - [Test] [NGitLabRetry] public async Task Test_merge_request_head_pipeline() @@ -367,6 +349,38 @@ private static void Test_can_update_labels_with_delta(IMergeRequestClient mergeR Assert.That(updated.Labels, Is.EqualTo(new[] { "a", "c", "d" }).AsCollection); } + [Test] + [NGitLabRetry] + public async Task Test_merge_request_versions_match_diff_refs() + { + using var context = await GitLabTestContext.CreateAsync(); + var (project, mergeRequest) = await context.CreateMergeRequestAsync(); + var mergeRequestClient = context.Client.GetMergeRequest(project.Id); + + var versions = await GitLabTestContext.RetryUntilAsync( + () => mergeRequestClient.GetVersionsAsync(mergeRequest.Iid), + versions => versions.Any(), + TimeSpan.FromSeconds(10)); + + var version = versions.First(); + + Assert.That(version.HeadCommitSha, Is.EqualTo(mergeRequest.Sha)); + Assert.That(version.BaseCommitSha, Is.EqualTo(mergeRequest.DiffRefs.BaseSha)); + Assert.That(version.StartCommitSha, Is.EqualTo(mergeRequest.DiffRefs.StartSha)); + } + + [Test] + [NGitLabRetry] + public async Task Test_merge_request_versions_throws_for_unknown_merge_request() + { + using var context = await GitLabTestContext.CreateAsync(); + var (project, mergeRequest) = await context.CreateMergeRequestAsync(); + var mergeRequestClient = context.Client.GetMergeRequest(project.Id); + + var ex = Assert.Throws((Action)(() => mergeRequestClient.GetVersionsAsync(99999).ToArray())); + Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + public static void AcceptMergeRequest(IMergeRequestClient mergeRequestClient, MergeRequest request) { Policy diff --git a/NGitLab.Tests/MergeRequest/MergeRequestDiscussionsClientTests.cs b/NGitLab.Tests/MergeRequest/MergeRequestDiscussionsClientTests.cs index ef15bbb8..bde73148 100644 --- a/NGitLab.Tests/MergeRequest/MergeRequestDiscussionsClientTests.cs +++ b/NGitLab.Tests/MergeRequest/MergeRequestDiscussionsClientTests.cs @@ -34,6 +34,53 @@ public async Task AddDiscussionToMergeRequest_DiscussionCreated() Assert.That(discussions, Is.Not.Empty); } + [Test] + [NGitLabRetry] + public async Task AddInlineDiscussionToMergeRequest_PositionRoundtrips() + { + using var context = await GitLabTestContext.CreateAsync(); + var (project, mergeRequest) = await context.CreateMergeRequestAsync(); + var mergeRequestClient = context.Client.GetMergeRequest(project.Id); + var mergeRequestDiscussions = mergeRequestClient.Discussions(mergeRequest.Iid); + + var versions = await GitLabTestContext.RetryUntilAsync( + () => mergeRequestClient.GetVersionsAsync(mergeRequest.Iid), + versions => versions.Any(), + TimeSpan.FromSeconds(10)); + var version = versions.First(); + + const string discussionMessage = "Inline comment"; + var position = new Position + { + NewPath = "TestFile0.txt", + NewLine = 1, + PositionType = new DynamicEnum(PositionType.Text), + BaseSha = new Sha1(version.BaseCommitSha), + StartSha = new Sha1(version.StartCommitSha), + HeadSha = new Sha1(version.HeadCommitSha), + }; + + var discussion = mergeRequestDiscussions.Add(new MergeRequestDiscussionCreate + { + Body = discussionMessage, + Position = position, + }); + + Assert.That(discussion.Notes[0].Position, Is.Not.Null); + Assert.That(discussion.Notes[0].Position.NewPath, Is.EqualTo("TestFile0.txt")); + Assert.That(discussion.Notes[0].Position.NewLine, Is.EqualTo(1)); + Assert.That(discussion.Notes[0].Position.HeadSha.ToString(), Is.EqualTo(new Sha1(version.HeadCommitSha).ToString())); + Assert.That(discussion.IndividualNote, Is.False); + + var rereadDiscussions = mergeRequestClient.Comments(mergeRequest.Iid).Discussions.ToArray(); + var rereadDiscussion = rereadDiscussions.Single(d => string.Equals(d.Notes[0].Body, discussionMessage, StringComparison.Ordinal)); + Assert.That(rereadDiscussion.Notes[0].Position, Is.Not.Null); + Assert.That(rereadDiscussion.Notes[0].Position.NewPath, Is.EqualTo("TestFile0.txt")); + Assert.That(rereadDiscussion.Notes[0].Position.NewLine, Is.EqualTo(1)); + Assert.That(rereadDiscussion.Id, Is.EqualTo(discussion.Id)); + Assert.That(rereadDiscussion.IndividualNote, Is.False); + } + [Test] [NGitLabRetry] public async Task GetDiscussion_DiscussionFound()