diff --git a/src/Konmaripo.Web.Tests.Unit/Controllers/MassIssuesControllerTests.cs b/src/Konmaripo.Web.Tests.Unit/Controllers/MassIssuesControllerTests.cs new file mode 100644 index 0000000..36d1b13 --- /dev/null +++ b/src/Konmaripo.Web.Tests.Unit/Controllers/MassIssuesControllerTests.cs @@ -0,0 +1,56 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using FluentAssertions; +using Konmaripo.Web.Controllers; +using Konmaripo.Web.Services; +using Moq; +using Serilog; +using Xunit; + +namespace Konmaripo.Web.Tests.Unit.Controllers +{ + public class MassIssuesControllerTests + { + [SuppressMessage("ReSharper", "ObjectCreationAsStatement")] + public class Ctor + { + private readonly Mock _mockLogger; + private readonly Mock _mockGitHubService; + private readonly Mock _mockIssueCreator; + + public Ctor() + { + _mockLogger = new Mock(); + _mockGitHubService = new Mock(); + _mockIssueCreator = new Mock(); + } + + [Fact] + public void NullLogger_ThrowsException() + { + Action act = () => new MassIssuesController(null, _mockGitHubService.Object, _mockIssueCreator.Object); + + act.Should().Throw() + .And.ParamName.Should().Be("logger"); + } + + [Fact] + public void NullGitHubService_ThrowsException() + { + Action act = () => new MassIssuesController(_mockLogger.Object, null, _mockIssueCreator.Object); + + act.Should().Throw() + .And.ParamName.Should().Be("gitHubService"); + } + + [Fact] + public void NullMassIssueCreator_ThrowsException() + { + Action act = () => new MassIssuesController(_mockLogger.Object, _mockGitHubService.Object, null); + + act.Should().Throw() + .And.ParamName.Should().Be("massIssueCreator"); + } + } + } +} diff --git a/src/Konmaripo.Web/Controllers/MassIssuesController.cs b/src/Konmaripo.Web/Controllers/MassIssuesController.cs new file mode 100644 index 0000000..7209f5a --- /dev/null +++ b/src/Konmaripo.Web/Controllers/MassIssuesController.cs @@ -0,0 +1,129 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Konmaripo.Web.Services; +using Octokit; +using Serilog; + +namespace Konmaripo.Web.Controllers +{ + public class MassIssue + { + [Required] + [Display(Name = "Subject")] + public string IssueSubject { get; set; } + + [Required] + [Display(Name = "Body")] + [DataType(DataType.MultilineText)] + public string IssueBody { get; set; } + + [Display(Name = "Pin Issue?")] + public bool ShouldBePinned { get; set; } + + // ReSharper disable once UnusedMember.Global + public MassIssue() + { + // this is here because the model binding uses it + } + public MassIssue(string issueSubject, string issueBody, bool shouldBePinned) + { + IssueSubject = issueSubject; + IssueBody = issueBody; + ShouldBePinned = shouldBePinned; + } + } + public class MassIssueViewModel + { + public MassIssue MassIssue { get; set; } + public int NonArchivedRepos { get; set; } + public int RemainingAPIRequests { get; set; } + + // ReSharper disable once UnusedMember.Global + public MassIssueViewModel() + { + // This is here because the model binding uses it + } + public MassIssueViewModel(MassIssue massIssue, int nonArchivedRepos, int remainingApiRequests) + { + MassIssue = massIssue; + NonArchivedRepos = nonArchivedRepos; + RemainingAPIRequests = remainingApiRequests; + } + } + + [Authorize] + public class MassIssuesController : Controller + { + private readonly ILogger _logger; + private readonly IGitHubService _gitHubService; + private readonly IMassIssueCreator _massIssueCreator; + + public MassIssuesController(ILogger logger, IGitHubService gitHubService, IMassIssueCreator massIssueCreator) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _gitHubService = gitHubService ?? throw new ArgumentNullException(nameof(gitHubService)); + _massIssueCreator = massIssueCreator ?? throw new ArgumentNullException(nameof(massIssueCreator)); + } + + public async Task Index() + { + var remainingRequests = _gitHubService.RemainingAPIRequests(); + var nonArchivedRepos = await NonArchivedReposCount(); + var issue = new MassIssue(string.Empty, string.Empty, false); + + var vm = new MassIssueViewModel(issue, nonArchivedRepos, remainingRequests); + + return View(vm); + } + + [HttpPost] + public async Task Index(MassIssueViewModel vm) + { + var nonArchivedReposCount = await NonArchivedReposCount(); + if (!ModelState.IsValid) + { + _logger.Warning("Mass issue model is invalid; returning validation error messages."); + vm.MassIssue.IssueSubject = vm.MassIssue.IssueSubject; + vm.MassIssue.IssueBody = vm.MassIssue.IssueBody; + vm.NonArchivedRepos = nonArchivedReposCount; + vm.RemainingAPIRequests = _gitHubService.RemainingAPIRequests(); + return View(vm); + } + + var currentUser = this.User.Identity.Name; + var newIssue = new NewIssue(vm.MassIssue.IssueSubject) + { + Body = @$"{vm.MassIssue.IssueBody} + +---- + +Created by {currentUser} using the Konmaripo tool" + }; + + try + { + var repos = await _gitHubService.GetRepositoriesForOrganizationAsync(); + var nonArchivedRepos = repos.Where(x => !x.IsArchived).ToList(); + await Task.WhenAll(_massIssueCreator.CreateIssue(newIssue, nonArchivedRepos)); + } + catch (Exception ex) + { + _logger.Error(ex, "An error occurred while creating the mass issues."); + } + + return View("IssueSuccess"); + + + } + + private async Task NonArchivedReposCount() + { + var repos = await _gitHubService.GetRepositoriesForOrganizationAsync(); + return repos.Count(x => !x.IsArchived); + } + } +} diff --git a/src/Konmaripo.Web/Services/CachedGitHubService.cs b/src/Konmaripo.Web/Services/CachedGitHubService.cs index 23f7c77..397ce6a 100644 --- a/src/Konmaripo.Web/Services/CachedGitHubService.cs +++ b/src/Konmaripo.Web/Services/CachedGitHubService.cs @@ -5,6 +5,7 @@ using Functional.Maybe; using Konmaripo.Web.Models; using Microsoft.Extensions.Caching.Memory; +using Octokit; namespace Konmaripo.Web.Services { @@ -68,5 +69,15 @@ public Task GetRepoQuotaForOrg() { return _gitHubService.GetRepoQuotaForOrg(); } + + public int RemainingAPIRequests() + { + return _gitHubService.RemainingAPIRequests(); + } + + public Task CreateIssueInRepo(NewIssue issue, long repoId) + { + return _gitHubService.CreateIssueInRepo(issue, repoId); + } } } diff --git a/src/Konmaripo.Web/Services/GitHubService.cs b/src/Konmaripo.Web/Services/GitHubService.cs index 23f450c..f75046f 100644 --- a/src/Konmaripo.Web/Services/GitHubService.cs +++ b/src/Konmaripo.Web/Services/GitHubService.cs @@ -1,10 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Runtime.InteropServices; using System.Threading.Tasks; using Konmaripo.Web.Models; -using Microsoft.AspNetCore.Http.Connections; using Microsoft.Extensions.Options; using Octokit; using Serilog; @@ -80,5 +78,15 @@ public async Task GetRepoQuotaForOrg() var org = await _githubClient.Organization.Get(_gitHubSettings.OrganizationName); return new RepoQuota(org.Plan.PrivateRepos, org.OwnedPrivateRepos); } + + public int RemainingAPIRequests() + { + return _githubClient.GetLastApiInfo().RateLimit.Remaining; + } + + public Task CreateIssueInRepo(NewIssue issue, long repoId) + { + return _githubClient.Issue.Create(repoId, issue); + } } } diff --git a/src/Konmaripo.Web/Services/IGitHubService.cs b/src/Konmaripo.Web/Services/IGitHubService.cs index 18c6598..92bdef7 100644 --- a/src/Konmaripo.Web/Services/IGitHubService.cs +++ b/src/Konmaripo.Web/Services/IGitHubService.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Konmaripo.Web.Models; +using Octokit; namespace Konmaripo.Web.Services { @@ -11,5 +12,7 @@ public interface IGitHubService Task CreateArchiveIssueInRepo(long repoId, string currentUser); Task ArchiveRepository(long repoId, string repoName); Task GetRepoQuotaForOrg(); + int RemainingAPIRequests(); + Task CreateIssueInRepo(NewIssue issue, long repoId); } } \ No newline at end of file diff --git a/src/Konmaripo.Web/Services/IMassIssueCreator.cs b/src/Konmaripo.Web/Services/IMassIssueCreator.cs new file mode 100644 index 0000000..87f09b2 --- /dev/null +++ b/src/Konmaripo.Web/Services/IMassIssueCreator.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Konmaripo.Web.Models; +using Octokit; + +namespace Konmaripo.Web.Services +{ + public interface IMassIssueCreator + { + List CreateIssue(NewIssue issue, List repoList); + } +} \ No newline at end of file diff --git a/src/Konmaripo.Web/Services/MassIssueCreator.cs b/src/Konmaripo.Web/Services/MassIssueCreator.cs new file mode 100644 index 0000000..815b031 --- /dev/null +++ b/src/Konmaripo.Web/Services/MassIssueCreator.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Konmaripo.Web.Models; +using Octokit; +using Serilog; + +namespace Konmaripo.Web.Services +{ + public class MassIssueCreator : IMassIssueCreator + { + private readonly System.Threading.SemaphoreSlim _batcher = new System.Threading.SemaphoreSlim(10, 10); + private readonly IGitHubService _gitHubService; + private readonly ILogger _logger; + + public MassIssueCreator(IGitHubService gitHubService, ILogger logger) + { + _gitHubService = gitHubService; + _logger = logger; + } + + public List CreateIssue(NewIssue issue, List repoList) + { + _logger.Information("Queuing issues for {RepoCount} repositories", repoList.Count); + var taskList = new List(); + + repoList.ForEach(x=> taskList.Add(CreateIssue(issue, x.Id))); + + _logger.Information("Added tasks"); + + return taskList; + } + private async Task CreateIssue(NewIssue issue, long repoId) + { + await _batcher.WaitAsync(); + + try + { + await _gitHubService.CreateIssueInRepo(issue, repoId); + } + catch (Exception e) + { + _logger.Error(e, "An error occurred while creating an issue in repoId {RepoId}", repoId); + } + finally + { + _batcher.Release(); + } + } + } +} \ No newline at end of file diff --git a/src/Konmaripo.Web/Startup.cs b/src/Konmaripo.Web/Startup.cs index 6311a04..e1d65f7 100644 --- a/src/Konmaripo.Web/Startup.cs +++ b/src/Konmaripo.Web/Startup.cs @@ -72,6 +72,8 @@ public void ConfigureServices(IServiceCollection services) return cachedService; }); + + services.AddTransient(); } /// diff --git a/src/Konmaripo.Web/Views/Home/Index.cshtml b/src/Konmaripo.Web/Views/Home/Index.cshtml index 62aacdb..e812b2b 100644 --- a/src/Konmaripo.Web/Views/Home/Index.cshtml +++ b/src/Konmaripo.Web/Views/Home/Index.cshtml @@ -7,7 +7,8 @@

Welcome to Konmaripo!

A tool to enable easier GitHub repo and organization management.

-

Currently, this tool enables you to @Html.ActionLink("sunset and archive repositories", "Index", "Sunsetting").

+

Currently, this tool enables you to @(Html.ActionLink("sunset and archive repositories", "Index", "MassIssues")).

+

You can also @Html.ActionLink("Submit a mass issue", "Index", "MassIssues").

diff --git a/src/Konmaripo.Web/Views/MassIssues/Index.cshtml b/src/Konmaripo.Web/Views/MassIssues/Index.cshtml new file mode 100644 index 0000000..94caea2 --- /dev/null +++ b/src/Konmaripo.Web/Views/MassIssues/Index.cshtml @@ -0,0 +1,80 @@ +@model Konmaripo.Web.Controllers.MassIssueViewModel +@{ + ViewData["Title"] = "Home Page"; +} + +
+

Mass Issue Creation

+

Create an issue that will be posted in all of your organization's repositories.

+
+ +@using (Html.BeginForm("Index", "MassIssues", FormMethod.Post)) +{ + +
+

Issue Contents

+ @Html.ValidationSummary(false, null, htmlAttributes: new { @class = "text-danger" }) + +
+ @Html.LabelFor(x => x.MassIssue.IssueSubject) + @Html.TextBoxFor(x => x.MassIssue.IssueSubject, htmlAttributes: new { @class = "form-control" }) + @Html.ValidationMessageFor(x=>x.MassIssue.IssueSubject, null, htmlAttributes: new { @class = "text-danger" }) +
+ + +
+ @Html.LabelFor(x => x.MassIssue.IssueBody) + @Html.TextAreaFor(x => x.MassIssue.IssueBody, htmlAttributes: new { @class = "form-control", rows = 10 }) + @Html.ValidationMessageFor(x => x.MassIssue.IssueBody, null, htmlAttributes: new { @class = "text-danger" }) +
+ +
+ @Html.CheckBoxFor(x => x.MassIssue.ShouldBePinned, htmlAttributes: new { @class = "form-check-input" }) + @Html.LabelFor(x => x.MassIssue.ShouldBePinned, htmlAttributes: new { @class = "form-check-label" }) + @Html.ValidationMessageFor(x=>x.MassIssue.ShouldBePinned, null, htmlAttributes: new { @class = "text-danger" }) +
+ +
+ +
+

Double-check: Preview

+
+
+ +
+ +
+

Double-check: Distribution

+

This will be sent to @Model.NonArchivedRepos repositories

+

We have @Model.RemainingAPIRequests API requests remaining before it resets.

+
+ +
+

Submit

+

This will be sent to @Model.NonArchivedRepos repositories

+ +
+ +} + +@section Scripts { + + +} \ No newline at end of file diff --git a/src/Konmaripo.Web/Views/MassIssues/IssueSuccess.cshtml b/src/Konmaripo.Web/Views/MassIssues/IssueSuccess.cshtml new file mode 100644 index 0000000..da8c465 --- /dev/null +++ b/src/Konmaripo.Web/Views/MassIssues/IssueSuccess.cshtml @@ -0,0 +1,9 @@ +@using Humanizer +@{ + ViewData["Title"] = "Issue Posting Completed"; +} + +
+

Mass Issue Posting Complete.

+

You can @Html.ActionLink("Create another issue", "Index").

+
\ No newline at end of file diff --git a/src/Konmaripo.Web/Views/Shared/_Layout.cshtml b/src/Konmaripo.Web/Views/Shared/_Layout.cshtml index a29fdd5..853b81a 100644 --- a/src/Konmaripo.Web/Views/Shared/_Layout.cshtml +++ b/src/Konmaripo.Web/Views/Shared/_Layout.cshtml @@ -24,6 +24,9 @@ +
diff --git a/src/Konmaripo.sln.DotSettings b/src/Konmaripo.sln.DotSettings index 86593e7..6fc318b 100644 --- a/src/Konmaripo.sln.DotSettings +++ b/src/Konmaripo.sln.DotSettings @@ -1,4 +1,6 @@  + API + True True True True