From e5de814e6216c94e6557216986f02f05c0a8c0b0 Mon Sep 17 00:00:00 2001 From: Gary Ewan Park Date: Thu, 31 Aug 2023 13:01:36 +0100 Subject: [PATCH] (#146) Add GitLab VCS Provider This commit introduces a new GitLabProvider class, and implements the necessary methods to allow GRM to create release notes on GitLab. This is made possible by using the NGitLab library. A new option has been added to the base command, which allows settings of the --provider at the command line. The default value for this option is GitHub, so everything continues to work as it is expected to. --- src/GitReleaseManager.Cli/Program.cs | 27 +- .../Extensions/MilestoneExtensions.cs | 26 +- .../GitReleaseManager.Core.csproj | 1 + .../MappingProfiles/GitLabProfile.cs | 48 ++ .../Model/VcsProvider.cs | 8 + .../Options/BaseVcsSubOptions.cs | 4 + .../Provider/GitLabProvider.cs | 421 ++++++++++++++++++ .../GitReleaseManager.Tool.csproj | 1 + 8 files changed, 530 insertions(+), 6 deletions(-) create mode 100644 src/GitReleaseManager.Core/MappingProfiles/GitLabProfile.cs create mode 100644 src/GitReleaseManager.Core/Model/VcsProvider.cs create mode 100644 src/GitReleaseManager.Core/Provider/GitLabProvider.cs diff --git a/src/GitReleaseManager.Cli/Program.cs b/src/GitReleaseManager.Cli/Program.cs index fdc36e1a..f89fc72d 100644 --- a/src/GitReleaseManager.Cli/Program.cs +++ b/src/GitReleaseManager.Cli/Program.cs @@ -8,11 +8,13 @@ using GitReleaseManager.Core.Commands; using GitReleaseManager.Core.Configuration; using GitReleaseManager.Core.Helpers; +using GitReleaseManager.Core.Model; using GitReleaseManager.Core.Options; using GitReleaseManager.Core.Provider; using GitReleaseManager.Core.ReleaseNotes; using GitReleaseManager.Core.Templates; using Microsoft.Extensions.DependencyInjection; +using NGitLab; using Octokit; using Serilog; @@ -96,7 +98,6 @@ private static void RegisterServices(BaseSubOptions options) .AddSingleton(fileSystem) .AddSingleton() .AddSingleton() - .AddSingleton() .AddSingleton(); if (options is BaseVcsOptions vcsOptions) @@ -106,9 +107,7 @@ private static void RegisterServices(BaseSubOptions options) throw new Exception("The token option is not defined"); } - var gitHubClient = new GitHubClient(new ProductHeaderValue("GitReleaseManager")) { Credentials = new Credentials(vcsOptions.Token) }; - serviceCollection = serviceCollection - .AddSingleton(gitHubClient); + RegisterVcsProvider(vcsOptions, serviceCollection); } serviceCollection = serviceCollection @@ -197,5 +196,25 @@ private static Task ExecuteCommand(TOptions options) private static void LogOptions(BaseSubOptions options) => Log.Debug("{@Options}", options); + + private static void RegisterVcsProvider(BaseVcsOptions vcsOptions, IServiceCollection serviceCollection) + { + Log.Information("Using {Provider} as VCS Provider", vcsOptions.Provider); + if (vcsOptions.Provider == VcsProvider.GitLab) + { + var gitlabClient = new GitLabClient("https://gitlab.com", vcsOptions.Token); + serviceCollection + .AddSingleton() + .AddSingleton(gitlabClient); + } + else + { + // default to Github + var gitHubClient = new GitHubClient(new ProductHeaderValue("GitReleaseManager")) { Credentials = new Credentials(vcsOptions.Token) }; + serviceCollection + .AddSingleton(gitHubClient) + .AddSingleton(); + } + } } } \ No newline at end of file diff --git a/src/GitReleaseManager.Core/Extensions/MilestoneExtensions.cs b/src/GitReleaseManager.Core/Extensions/MilestoneExtensions.cs index 41578201..19e29e6c 100644 --- a/src/GitReleaseManager.Core/Extensions/MilestoneExtensions.cs +++ b/src/GitReleaseManager.Core/Extensions/MilestoneExtensions.cs @@ -1,5 +1,4 @@ using System; -using Octokit; using Serilog; namespace GitReleaseManager.Core.Extensions @@ -8,7 +7,30 @@ public static class MilestoneExtensions { public static readonly ILogger _logger = Log.ForContext(typeof(MilestoneExtensions)); - public static Version Version(this Milestone ver) + public static Version Version(this Octokit.Milestone ver) + { + if (ver is null) + { + throw new ArgumentNullException(nameof(ver)); + } + + var nameWithoutPrerelease = ver.Title.Split('-')[0]; + if (nameWithoutPrerelease.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { + _logger.Debug("Removing version prefix from {Name}", ver.Title); + nameWithoutPrerelease = nameWithoutPrerelease.Remove(0, 1); + } + + if (!System.Version.TryParse(nameWithoutPrerelease, out Version parsedVersion)) + { + _logger.Warning("No valid version was found on {Title}", ver.Title); + return new Version(0, 0); + } + + return parsedVersion; + } + + public static Version Version(this NGitLab.Models.Milestone ver) { if (ver is null) { diff --git a/src/GitReleaseManager.Core/GitReleaseManager.Core.csproj b/src/GitReleaseManager.Core/GitReleaseManager.Core.csproj index f9737c58..75b9a6c0 100644 --- a/src/GitReleaseManager.Core/GitReleaseManager.Core.csproj +++ b/src/GitReleaseManager.Core/GitReleaseManager.Core.csproj @@ -23,6 +23,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/src/GitReleaseManager.Core/MappingProfiles/GitLabProfile.cs b/src/GitReleaseManager.Core/MappingProfiles/GitLabProfile.cs new file mode 100644 index 00000000..c209654d --- /dev/null +++ b/src/GitReleaseManager.Core/MappingProfiles/GitLabProfile.cs @@ -0,0 +1,48 @@ +namespace GitReleaseManager.Core.MappingProfiles +{ + using System; + using AutoMapper; + using GitReleaseManager.Core.Extensions; + + public class GitLabProfile : Profile + { + public GitLabProfile() + { + CreateMap() + .ForMember(dest => dest.PublicNumber, act => act.MapFrom(src => src.Iid)) + .ForMember(dest => dest.InternalNumber, act => act.MapFrom(src => src.Id)) + .AfterMap((src, dest) => dest.Version = src.Version()); + CreateMap() + .ForMember(dest => dest.Draft, act => act.MapFrom(src => src.ReleasedAt > DateTime.UtcNow)) + .ForMember(dest => dest.Body, act => act.MapFrom(src => src.Description)) + .ForMember(dest => dest.Assets, act => act.MapFrom(src => src.Assets.Links)) + .ReverseMap(); + CreateMap().ReverseMap(); + CreateMap() + .ForMember(dest => dest.InternalNumber, act => act.MapFrom(src => src.Id)) + .ForMember(dest => dest.PublicNumber, act => act.MapFrom(src => src.IssueId)) + .ForMember(dest => dest.HtmlUrl, act => act.MapFrom(src => src.WebUrl)) + .ForMember(dest => dest.IsPullRequest, act => act.MapFrom(src => false)) + .ReverseMap(); + CreateMap() + .ForMember(dest => dest.InternalNumber, act => act.MapFrom(src => src.Id)) + .ForMember(dest => dest.PublicNumber, act => act.MapFrom(src => src.Iid)) + .ForMember(dest => dest.HtmlUrl, act => act.MapFrom(src => src.WebUrl)) + .ForMember(dest => dest.IsPullRequest, act => act.MapFrom(src => true)) + .ReverseMap(); + CreateMap().ForMember(dest => dest.Name, act => act.MapFrom(src => src)); + CreateMap() + .ForMember(dest => dest.Description, act => act.MapFrom(src => src.Body)) + .ForMember(dest => dest.Ref, act => act.MapFrom(src => src.TargetCommitish)) + .ForMember(dest => dest.Milestones, act => act.MapFrom(src => new string[] { src.TagName })) + .ForMember(dest => dest.ReleasedAt, act => act.MapFrom(src => src.Draft ? DateTime.UtcNow.AddYears(1) : DateTime.UtcNow)) + .ForMember(dest => dest.Assets, act => act.Ignore()) + .ReverseMap(); + CreateMap() + .ForMember(dest => dest.Id, act => act.MapFrom(src => src.NoteId)) + .ReverseMap(); + CreateMap() + .ReverseMap(); + } + } +} diff --git a/src/GitReleaseManager.Core/Model/VcsProvider.cs b/src/GitReleaseManager.Core/Model/VcsProvider.cs new file mode 100644 index 00000000..565b218e --- /dev/null +++ b/src/GitReleaseManager.Core/Model/VcsProvider.cs @@ -0,0 +1,8 @@ +namespace GitReleaseManager.Core.Model +{ + public enum VcsProvider + { + GitHub = 0, + GitLab = 1, + } +} diff --git a/src/GitReleaseManager.Core/Options/BaseVcsSubOptions.cs b/src/GitReleaseManager.Core/Options/BaseVcsSubOptions.cs index 35b994bd..90faca2a 100644 --- a/src/GitReleaseManager.Core/Options/BaseVcsSubOptions.cs +++ b/src/GitReleaseManager.Core/Options/BaseVcsSubOptions.cs @@ -1,5 +1,6 @@ using CommandLine; using Destructurama.Attributed; +using GitReleaseManager.Core.Model; namespace GitReleaseManager.Core.Options { @@ -14,5 +15,8 @@ public abstract class BaseVcsOptions : BaseSubOptions [Option('r', "repository", HelpText = "The name of the repository.", Required = true)] public string RepositoryName { get; set; } + + [Option("provider", HelpText = "Version Control System provider", Default = VcsProvider.GitHub)] + public VcsProvider Provider { get; set; } } } \ No newline at end of file diff --git a/src/GitReleaseManager.Core/Provider/GitLabProvider.cs b/src/GitReleaseManager.Core/Provider/GitLabProvider.cs new file mode 100644 index 00000000..d89943de --- /dev/null +++ b/src/GitReleaseManager.Core/Provider/GitLabProvider.cs @@ -0,0 +1,421 @@ +namespace GitReleaseManager.Core.Provider +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; + using System.Threading.Tasks; + using AutoMapper; + using NGitLab; + using NGitLab.Models; + using Serilog; + using ApiException = GitReleaseManager.Core.Exceptions.ApiException; + using Issue = GitReleaseManager.Core.Model.Issue; + using IssueComment = GitReleaseManager.Core.Model.IssueComment; + using ItemState = GitReleaseManager.Core.Model.ItemState; + using ItemStateFilter = GitReleaseManager.Core.Model.ItemStateFilter; + using Label = GitReleaseManager.Core.Model.Label; + using Milestone = GitReleaseManager.Core.Model.Milestone; + using NotFoundException = GitReleaseManager.Core.Exceptions.NotFoundException; + using RateLimit = GitReleaseManager.Core.Model.RateLimit; + using Release = GitReleaseManager.Core.Model.Release; + using ReleaseAsset = GitReleaseManager.Core.Model.ReleaseAsset; + using ReleaseAssetUpload = GitReleaseManager.Core.Model.ReleaseAssetUpload; + + public class GitLabProvider : IVcsProvider + { + private const string NOT_FOUND_MESSGAE = "NotFound"; + + private readonly IGitLabClient _gitLabClient; + private readonly IMapper _mapper; + private readonly ILogger _logger; + + public GitLabProvider(IGitLabClient gitLabClient, IMapper mapper, ILogger logger) + { + _gitLabClient = gitLabClient; + _mapper = mapper; + _logger = logger; + } + + public Task DeleteAssetAsync(string owner, string repository, ReleaseAsset asset) + { + // TODO: This is a discussion here: + // https://github.com/ubisoft/NGitLab/discussions/511 + // about what is necessary to implement the required functionality in NGitLab + throw new NotImplementedException(); + } + + public Task UploadAssetAsync(Release release, ReleaseAssetUpload releaseAssetUpload) + { + return ExecuteAsync(async () => + { + _logger.Warning("Uploading of assets is not currently supported when targetting GitLab."); + }); + } + + public Task GetCommitsCountAsync(string owner, string repository, string @base, string head) + { + return ExecuteAsync(async () => + { + // TODO: This is waiting on a PR being merged... + // https://github.com/ubisoft/NGitLab/pull/444 + // Once it is, we might be able to implement what is necessary here. + return 0; + }); + } + + public string GetCommitsUrl(string owner, string repository, string head, string @base = null) + { + Ensure.IsNotNullOrWhiteSpace(owner, nameof(owner)); + Ensure.IsNotNullOrWhiteSpace(repository, nameof(repository)); + Ensure.IsNotNullOrWhiteSpace(head, nameof(head)); + + return string.IsNullOrWhiteSpace(@base) + ? string.Format(CultureInfo.InvariantCulture, "https://gitlab.com/{0}/{1}/-/commits/{2}", owner, repository, head) + : string.Format(CultureInfo.InvariantCulture, "https://gitlab.com/{0}/{1}/-/compare/{2}...{3}", owner, repository, @base, head); + } + + public Task CreateIssueCommentAsync(string owner, string repository, Issue issue, string comment) + { + return ExecuteAsync(async () => + { + if (issue.IsPullRequest) + { + var mergeRequestClient = _gitLabClient.GetMergeRequest(GetGitLabProjectId(owner, repository)); + var commentsClient = mergeRequestClient.Comments(issue.PublicNumber); + var mergeRequestComment = new MergeRequestCommentCreate + { + Body = comment, + }; + + commentsClient.Add(mergeRequestComment); + } + else + { + var issueNotesClient = _gitLabClient.GetProjectIssueNoteClient(GetGitLabProjectId(owner, repository)); + var issueComment = new ProjectIssueNoteCreate + { + IssueId = issue.PublicNumber, + Body = comment, + }; + + issueNotesClient.Create(issueComment); + } + }); + } + + public Task> GetIssuesAsync(string owner, string repository, Milestone milestone, ItemStateFilter itemStateFilter = ItemStateFilter.All) + { + return ExecuteAsync(async () => + { + var issuesClient = _gitLabClient.Issues; + + var query = new IssueQuery(); + query.Milestone = milestone.Title; + + if (itemStateFilter == ItemStateFilter.Open) + { + query.State = IssueState.opened; + } + else if (itemStateFilter == ItemStateFilter.Closed) + { + query.State = IssueState.closed; + } + + var issues = issuesClient.GetAsync(GetGitLabProjectId(owner, repository), query); + + var mergeRequestsClient = _gitLabClient.GetMergeRequest(GetGitLabProjectId(owner, repository)); + + var mergeRequestQuery = new MergeRequestQuery(); + mergeRequestQuery.Milestone = milestone.Title; + + if (itemStateFilter == ItemStateFilter.Open) + { + mergeRequestQuery.State = MergeRequestState.opened; + } + else if (itemStateFilter == ItemStateFilter.Closed) + { + mergeRequestQuery.State = MergeRequestState.merged; + } + + var mergeRequests = mergeRequestsClient.Get(mergeRequestQuery); + + var issuesAndMergeRequests = new List(); + issuesAndMergeRequests.AddRange(_mapper.Map>(issues)); + issuesAndMergeRequests.AddRange(_mapper.Map>(mergeRequests)); + + return issuesAndMergeRequests.AsEnumerable(); + }); + } + + public Task> GetIssueCommentsAsync(string owner, string repository, Issue issue) + { + return ExecuteAsync(async () => + { + IEnumerable issueComments = Enumerable.Empty(); + + if (issue.IsPullRequest) + { + var mergeRequestClient = _gitLabClient.GetMergeRequest(GetGitLabProjectId(owner, repository)); + var commentsClient = mergeRequestClient.Comments(issue.PublicNumber); + var comments = commentsClient.All; + issueComments = _mapper.Map>(comments); + } + else + { + var issueNotesClient = _gitLabClient.GetProjectIssueNoteClient(GetGitLabProjectId(owner, repository)); + var comments = issueNotesClient.ForIssue(issue.PublicNumber); + issueComments = _mapper.Map>(comments); + } + + return issueComments; + }); + } + + public Task CreateLabelAsync(string owner, string repository, Label label) + { + // The label functionality in GitLab already provides more than + // what is possible in GRM. As such, the decision was taken to not + // implement the creation of labels for the GitLab provider. + throw new NotImplementedException(); + } + + public Task DeleteLabelAsync(string owner, string repository, Label label) + { + // The label functionality in GitLab already provides more than + // what is possible in GRM. As such, the decision was taken to not + // implement the deletion of labels for the GitLab provider. + throw new NotImplementedException(); + } + + public Task> GetLabelsAsync(string owner, string repository) + { + // The label functionality in GitLab already provides more than + // what is possible in GRM. As such, the decision was taken to not + // implement the fetching of labels for the GitLab provider. + throw new NotImplementedException(); + } + + public Task GetMilestoneAsync(string owner, string repository, string milestoneTitle, ItemStateFilter itemStateFilter = ItemStateFilter.All) + { + return ExecuteAsync(async () => + { + var milestones = await GetMilestonesAsync(owner, repository, itemStateFilter).ConfigureAwait(false); + var foundMilestone = milestones.FirstOrDefault(m => m.Title == milestoneTitle); + + if (foundMilestone is null) + { + throw new NotFoundException(NOT_FOUND_MESSGAE); + } + + return foundMilestone; + }); + } + + public Task> GetMilestonesAsync(string owner, string repository, ItemStateFilter itemStateFilter = ItemStateFilter.All) + { + return ExecuteAsync(async () => + { + var query = new MilestoneQuery(); + + if (itemStateFilter == ItemStateFilter.Open) + { + query.State = MilestoneState.active; + } + else if (itemStateFilter == ItemStateFilter.Closed) + { + query.State = MilestoneState.closed; + } + + var mileStoneClient = _gitLabClient.GetMilestone(GetGitLabProjectId(owner, repository)); + + var milestones = mileStoneClient.Get(query); + var mappedMilestones = _mapper.Map>(milestones); + + foreach (var mappedMilestone in mappedMilestones) + { + mappedMilestone.HtmlUrl = string.Format(CultureInfo.InvariantCulture, "https://gitlab.com/{0}/{1}/-/milestones/{2}#tab-issues", owner, repository, mappedMilestone.PublicNumber); + } + + return mappedMilestones; + }); + } + + public Task SetMilestoneStateAsync(string owner, string repository, Milestone milestone, ItemState itemState) + { + return ExecuteAsync(async () => + { + var mileStoneClient = _gitLabClient.GetMilestone(GetGitLabProjectId(owner, repository)); + + if (itemState == ItemState.Open) + { + mileStoneClient.Activate(milestone.InternalNumber); + } + else if (itemState == ItemState.Closed) + { + mileStoneClient.Close(milestone.InternalNumber); + } + }); + } + + public Task CreateReleaseAsync(string owner, string repository, Release release) + { + return ExecuteAsync(async () => + { + var releaseClient = _gitLabClient.GetReleases(GetGitLabProjectId(owner, repository)); + + var newRelease = _mapper.Map(release); + var nGitLabRelease = releaseClient.Create(newRelease); + var mappedRelease = _mapper.Map(nGitLabRelease); + + if (mappedRelease != null) + { + mappedRelease.HtmlUrl = string.Format(CultureInfo.InvariantCulture, "https://gitlab.com/{0}/{1}/-/releases/{2}", owner, repository, release.TagName); + } + + return mappedRelease; + }); + } + + public Task DeleteReleaseAsync(string owner, string repository, Release release) + { + return ExecuteAsync(async () => + { + var releaseClient = _gitLabClient.GetReleases(GetGitLabProjectId(owner, repository)); + + releaseClient.Delete(release.TagName); + }); + } + + public Task GetReleaseAsync(string owner, string repository, string tagName) + { + return ExecuteAsync(async () => + { + var releaseClient = _gitLabClient.GetReleases(GetGitLabProjectId(owner, repository)); + + var releases = releaseClient.GetAsync(); + + var release = releases.FirstOrDefault(r => r.TagName == tagName); + var mappedRelease = _mapper.Map(release); + + if (mappedRelease != null) + { + mappedRelease.HtmlUrl = string.Format(CultureInfo.InvariantCulture, "https://gitlab.com/{0}/{1}/-/releases/{2}", owner, repository, tagName); + } + + return mappedRelease; + }); + } + + public Task> GetReleasesAsync(string owner, string repository, bool skipPrereleases) + { + return ExecuteAsync(async () => + { + var releaseClient = _gitLabClient.GetReleases(GetGitLabProjectId(owner, repository)); + + return _mapper.Map>(releaseClient.GetAsync()); + }); + } + + public Task PublishReleaseAsync(string owner, string repository, string tagName, Release release) + { + return ExecuteAsync(async () => + { + var releaseClient = _gitLabClient.GetReleases(GetGitLabProjectId(owner, repository)); + + var update = new ReleaseUpdate + { + ReleasedAt = DateTime.UtcNow, + TagName = tagName, + }; + + releaseClient.Update(update); + }); + } + + public Task UpdateReleaseAsync(string owner, string repository, Release release) + { + return ExecuteAsync(async () => + { + var releaseClient = _gitLabClient.GetReleases(GetGitLabProjectId(owner, repository)); + + var update = new ReleaseUpdate + { + + Description = release.Body, + ReleasedAt = release.Draft ? DateTime.UtcNow.AddYears(1) : DateTime.UtcNow, + Name = release.Name, + TagName = release.TagName, + Milestones = new string[] { release.TagName }, + }; + + releaseClient.Update(update); + }); + } + + public RateLimit GetRateLimit() + { + // TODO: There doesn't currently seem to be a way to get the remaining + // rate limit for GitLab using the library we are using, so for now, + // let's just hard code it. + return new RateLimit { Limit = 600, Remaining = 100 }; + } + + public string GetMilestoneQueryString() + { + return "sort=due_date_desc&state=closed"; + } + + public string GetIssueType(Issue issue) + { + return issue.IsPullRequest ? "Merge Request" : "Issue"; + } + + private int GetGitLabProjectId(string owner, string repository) + { + var projectName = string.Format(CultureInfo.InvariantCulture, "{0}/{1}", owner, repository); + var project = _gitLabClient.Projects[projectName]; + return project.Id; + } + + private async Task ExecuteAsync(Func action) + { + try + { + await action().ConfigureAwait(false); + } + ////catch (Octokit.ForbiddenException ex) + ////{ + //// throw new ForbiddenException(ex.Message, ex); + ////} + ////catch (Octokit.NotFoundException ex) + ////{ + //// throw new NotFoundException(ex.Message, ex); + ////} + catch (Exception ex) when (!(ex is NotFoundException)) + { + throw new ApiException(ex.Message, ex); + } + } + + private async Task ExecuteAsync(Func> action) + { + try + { + return await action().ConfigureAwait(false); + } + ////catch (Octokit.ForbiddenException ex) + ////{ + //// throw new ForbiddenException(ex.Message, ex); + ////} + ////catch (Octokit.NotFoundException ex) + ////{ + //// throw new NotFoundException(ex.Message, ex); + ////} + catch (Exception ex) when (!(ex is NotFoundException)) + { + throw new ApiException(ex.Message, ex); + } + } + } +} diff --git a/src/GitReleaseManager.Tool/GitReleaseManager.Tool.csproj b/src/GitReleaseManager.Tool/GitReleaseManager.Tool.csproj index 99fad416..205bb31c 100644 --- a/src/GitReleaseManager.Tool/GitReleaseManager.Tool.csproj +++ b/src/GitReleaseManager.Tool/GitReleaseManager.Tool.csproj @@ -38,6 +38,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all +