diff --git a/src/Nullinside.Cicd.GitHub/.editorconfig b/src/Nullinside.Cicd.GitHub/.editorconfig index 5ef8a11..d8f5d6e 100644 --- a/src/Nullinside.Cicd.GitHub/.editorconfig +++ b/src/Nullinside.Cicd.GitHub/.editorconfig @@ -4,6 +4,13 @@ root = true [*] indent_style = space +# ReSharper properties +resharper_csharp_wrap_lines = false + +# We don't want to code comment auto-generated files +[GraphQl/Model/*.cs] +dotnet_diagnostic.CS1591.severity = none + # Xml files [*.xml] indent_size = 2 diff --git a/src/Nullinside.Cicd.GitHub/Constants.cs b/src/Nullinside.Cicd.GitHub/Constants.cs index f3d82cc..89ef120 100644 --- a/src/Nullinside.Cicd.GitHub/Constants.cs +++ b/src/Nullinside.Cicd.GitHub/Constants.cs @@ -9,6 +9,16 @@ public static class Constants { /// public const string GITHUB_ORG = "nullinside-development-group"; + /// + /// The url to the graphql endpoint to post against. + /// + public const string GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"; + + /// + /// The name of the project that be sent in user agent headers. + /// + public const string PROJECT_NAME = "nullinside-cicd-github"; + /// /// The github project's unique identifier on github. /// diff --git a/src/Nullinside.Cicd.GitHub/GraphQl/AbstractGitHubGraphQlQuery.cs b/src/Nullinside.Cicd.GitHub/GraphQl/AbstractGitHubGraphQlQuery.cs new file mode 100644 index 0000000..4ee55ae --- /dev/null +++ b/src/Nullinside.Cicd.GitHub/GraphQl/AbstractGitHubGraphQlQuery.cs @@ -0,0 +1,54 @@ +using System.Net.Http.Headers; +using System.Text; + +using Newtonsoft.Json; + +using Nullinside.Cicd.GitHub.GraphQl.Model; + +namespace Nullinside.Cicd.GitHub.GraphQl; + +/// +/// The contract for executing a simple graphql query. +/// +/// The JSON response POCO to the query, for no response. +public abstract class AbstractGitHubGraphQlQuery { + /// + /// The query, typically coded into the object. + /// + public abstract string Query { get; } + + /// + /// The query variables, typically assembled in the constructor. + /// + public object? QueryVariables { get; set; } + + /// + /// Executes the request. + /// + /// The response object. + public virtual async Task SendAsync() { + using var httpClient = new HttpClient { + BaseAddress = new Uri(Constants.GITHUB_GRAPHQL_URL), + DefaultRequestHeaders = { + UserAgent = { new ProductInfoHeaderValue(Constants.PROJECT_NAME, "0.0.0") }, + Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("GITHUB_PAT")) + } + }; + + var queryObject = new { + query = Query, + variables = QueryVariables + }; + + var request = new HttpRequestMessage { + Method = HttpMethod.Post, + Content = new StringContent(JsonConvert.SerializeObject(queryObject), Encoding.UTF8, "application/json") + }; + + using HttpResponseMessage response = await httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + string responseString = await response.Content.ReadAsStringAsync(); + + return JsonConvert.DeserializeObject(responseString); + } +} \ No newline at end of file diff --git a/src/Nullinside.Cicd.GitHub/GraphQl/Model/GraphQlGenericMutationResponse.cs b/src/Nullinside.Cicd.GitHub/GraphQl/Model/GraphQlGenericMutationResponse.cs new file mode 100644 index 0000000..72fce99 --- /dev/null +++ b/src/Nullinside.Cicd.GitHub/GraphQl/Model/GraphQlGenericMutationResponse.cs @@ -0,0 +1,22 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +using System.Globalization; + +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Nullinside.Cicd.GitHub.GraphQl.Model; + + +public partial class GraphQlGenericMutationResponse +{ + [JsonProperty("data")] + public object Data { get; set; } +} diff --git a/src/Nullinside.Cicd.GitHub/GraphQl/Model/GraphQlProjectIssueResponse.cs b/src/Nullinside.Cicd.GitHub/GraphQl/Model/GraphQlProjectIssueResponse.cs new file mode 100644 index 0000000..f7903a8 --- /dev/null +++ b/src/Nullinside.Cicd.GitHub/GraphQl/Model/GraphQlProjectIssueResponse.cs @@ -0,0 +1,68 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +using Newtonsoft.Json; + +namespace Nullinside.Cicd.GitHub.Model; + +public class GraphQlProjectIssueResponse { + [JsonProperty("data")] public Data Data { get; set; } +} + +public class Data { + [JsonProperty("repository")] public Repository Repository { get; set; } +} + +public class Repository { + [JsonProperty("id")] public string Id { get; set; } + + [JsonProperty("issues")] public Issues Issues { get; set; } +} + +public class Issues { + [JsonProperty("nodes")] public List Nodes { get; set; } + + [JsonProperty("pageInfo")] public PageInfo PageInfo { get; set; } +} + +public class IssuesNode { + [JsonProperty("number")] public long Number { get; set; } + + [JsonProperty("closed")] public bool Closed { get; set; } + + [JsonProperty("id")] public string Id { get; set; } + + [JsonProperty("projectItems")] public ProjectItems ProjectItems { get; set; } +} + +public class ProjectItems { + [JsonProperty("nodes")] public List Nodes { get; set; } + + [JsonProperty("pageInfo")] public PageInfo PageInfo { get; set; } +} + +public class ProjectItemsNode { + [JsonProperty("fieldValueByName")] public FieldValueByName FieldValueByName { get; set; } + + [JsonProperty("id")] public string Id { get; set; } +} + +public class FieldValueByName { + [JsonProperty("field")] public Field Field { get; set; } + + [JsonProperty("name")] public string Name { get; set; } +} + +public class Field { + [JsonProperty("id")] public string Id { get; set; } +} + +public class PageInfo { + [JsonProperty("hasNextPage")] public bool HasNextPage { get; set; } +} \ No newline at end of file diff --git a/src/Nullinside.Cicd.GitHub/GraphQl/MutateIssueProjectStatuses.cs b/src/Nullinside.Cicd.GitHub/GraphQl/MutateIssueProjectStatuses.cs new file mode 100644 index 0000000..033a6c4 --- /dev/null +++ b/src/Nullinside.Cicd.GitHub/GraphQl/MutateIssueProjectStatuses.cs @@ -0,0 +1,28 @@ +using Nullinside.Cicd.GitHub.GraphQl.Model; + +namespace Nullinside.Cicd.GitHub.GraphQl; + +/// +/// Changes the status of the issue on the GitHub project board. (ex: Backlog, Ready, In progress, In review, Done) +/// +public class MutateIssueProjectStatuses : AbstractGitHubGraphQlQuery { + /// + /// Initializes a new instance of the class. + /// + /// The project id + /// The status field's id (ex: PVTI_lADOCZOBm84AdCw7zgQfohA NOT I_kwDOLcGb986MVe7p) + /// The status field's id (ex: PVTSSF_lADOCZOBm84AdCw7zgSzu_A) + /// The unique identifier of the single selection option to change the status to. + public MutateIssueProjectStatuses(string projectId, string projectIssueId, string statusFieldId, string selectionId) { + QueryVariables = new { project = projectId, item = projectIssueId, field = statusFieldId, selectionId }; + } + + /// + public override string Query => @"mutation($project: ID!, $item: ID!, $field: ID!, $selectionId: String!) { + updateProjectV2ItemFieldValue( + input: {projectId: $project, itemId: $item, fieldId: $field, value: {singleSelectOptionId: $selectionId}} + ) { + clientMutationId + } + }"; +} \ No newline at end of file diff --git a/src/Nullinside.Cicd.GitHub/GraphQl/QueryIssueProjectStatuses.cs b/src/Nullinside.Cicd.GitHub/GraphQl/QueryIssueProjectStatuses.cs new file mode 100644 index 0000000..837b0fc --- /dev/null +++ b/src/Nullinside.Cicd.GitHub/GraphQl/QueryIssueProjectStatuses.cs @@ -0,0 +1,54 @@ +using Nullinside.Cicd.GitHub.Model; + +namespace Nullinside.Cicd.GitHub.GraphQl; + +/// +/// Queries the issues in a GitHub code base. +/// +public class QueryIssueProjectStatuses : AbstractGitHubGraphQlQuery { + /// + /// Initializes a new instance of the class. + /// + /// The owner of the repo on github. + /// The name of the repo on github. + public QueryIssueProjectStatuses(string repoOwner, string repoName) { + QueryVariables = new { owner = repoOwner, name = repoName }; + } + + /// + public override string Query => @"query ($name: String!, $owner: String!) { + repository(name: $name, owner: $owner) { + id + issues(first: 100) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + number + closed + projectItems(first: 100) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + fieldValueByName(name: ""Status"") { + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { + ... on ProjectV2SingleSelectField { + id + } + } + } + } + } + } + } + } + } + }"; +} \ No newline at end of file diff --git a/src/Nullinside.Cicd.GitHub/Nullinside.Cicd.GitHub.csproj b/src/Nullinside.Cicd.GitHub/Nullinside.Cicd.GitHub.csproj index 7ef8816..463235b 100644 --- a/src/Nullinside.Cicd.GitHub/Nullinside.Cicd.GitHub.csproj +++ b/src/Nullinside.Cicd.GitHub/Nullinside.Cicd.GitHub.csproj @@ -19,20 +19,20 @@ - - - + + + - + - - - Always - + + + Always + diff --git a/src/Nullinside.Cicd.GitHub/Program.cs b/src/Nullinside.Cicd.GitHub/Program.cs index 1f63f76..10c1b28 100644 --- a/src/Nullinside.Cicd.GitHub/Program.cs +++ b/src/Nullinside.Cicd.GitHub/Program.cs @@ -2,7 +2,6 @@ using log4net; using log4net.Config; -using log4net.Core; using Nullinside.Cicd.GitHub; using Nullinside.Cicd.GitHub.Rule; @@ -16,7 +15,7 @@ using Query = Octokit.GraphQL.Query; XmlConfigurator.Configure(new FileInfo("log4net.config")); -var log = LogManager.GetLogger(typeof(Program)); +ILog log = LogManager.GetLogger(typeof(Program)); IRepoRule?[] rules = AppDomain.CurrentDomain.GetAssemblies() .SelectMany(a => a.GetTypes()) diff --git a/src/Nullinside.Cicd.GitHub/Rule/AssociateIssuesWithProject.cs b/src/Nullinside.Cicd.GitHub/Rule/AssociateIssuesWithProject.cs index 9bea242..46a38a9 100644 --- a/src/Nullinside.Cicd.GitHub/Rule/AssociateIssuesWithProject.cs +++ b/src/Nullinside.Cicd.GitHub/Rule/AssociateIssuesWithProject.cs @@ -15,10 +15,10 @@ namespace Nullinside.Cicd.GitHub.Rule; /// public class AssociateIssuesWithProject : IRepoRule { /// - /// The logger + /// The logger /// - private ILog _log = LogManager.GetLogger(typeof(AssociateIssuesWithProject)); - + private readonly ILog _log = LogManager.GetLogger(typeof(AssociateIssuesWithProject)); + /// public async Task Handle(GitHubClient client, Connection graphQl, ID projectId, Repository repo) { if (!repo.HasIssues) { diff --git a/src/Nullinside.Cicd.GitHub/Rule/CreateRulesets.cs b/src/Nullinside.Cicd.GitHub/Rule/CreateRulesets.cs index 336553e..98e6fa3 100644 --- a/src/Nullinside.Cicd.GitHub/Rule/CreateRulesets.cs +++ b/src/Nullinside.Cicd.GitHub/Rule/CreateRulesets.cs @@ -15,10 +15,10 @@ namespace Nullinside.Cicd.GitHub.Rule; /// public class CreateRulesets : IRepoRule { /// - /// The logger + /// The logger /// - private ILog _log = LogManager.GetLogger(typeof(CreateRulesets)); - + private readonly ILog _log = LogManager.GetLogger(typeof(CreateRulesets)); + /// public async Task Handle(GitHubClient client, Connection graphQl, ID projectId, Repository repo) { // This currently doesn't run properly. You get an error about not specifying multiple Parameters on the status diff --git a/src/Nullinside.Cicd.GitHub/Rule/MoveClosedToDone.cs b/src/Nullinside.Cicd.GitHub/Rule/MoveClosedToDone.cs new file mode 100644 index 0000000..2852846 --- /dev/null +++ b/src/Nullinside.Cicd.GitHub/Rule/MoveClosedToDone.cs @@ -0,0 +1,47 @@ +using log4net; + +using Nullinside.Cicd.GitHub.GraphQl; +using Nullinside.Cicd.GitHub.Model; + +using Octokit; +using Octokit.GraphQL; + +using Connection = Octokit.GraphQL.Connection; +using Repository = Octokit.Repository; + +namespace Nullinside.Cicd.GitHub.Rule; + +/// +/// Handles moving issues to done status. +/// +public class MoveClosedToDone : IRepoRule { + /// + /// The logger + /// + private readonly ILog _log = LogManager.GetLogger(typeof(MoveClosedToDone)); + + /// + public async Task Handle(GitHubClient client, Connection graphQl, ID projectId, Repository repo) { + if (!repo.HasIssues) { + return; + } + + var query = new QueryIssueProjectStatuses(Constants.GITHUB_ORG, repo.Name); + GraphQlProjectIssueResponse? issueInfoResponse = await query.SendAsync(); + + var issueStatuses = from issueInfo in issueInfoResponse?.Data.Repository.Issues.Nodes + from projects in issueInfo.ProjectItems.Nodes + select new { IssueId = issueInfo.Id, IssueNumber = issueInfo.Number, IsClosed = issueInfo.Closed, ProjectIssueId = projects.Id, FieldId = projects.FieldValueByName.Field.Id, Status = projects.FieldValueByName.Name }; + + foreach (var issue in issueStatuses) { + if (!issue.IsClosed || "Done".Equals(issue.Status, StringComparison.InvariantCultureIgnoreCase)) { + continue; + } + + _log.Info($"{repo.Name}: Associating issue #{issue.IssueNumber}"); + + var mutation = new MutateIssueProjectStatuses(projectId.Value, issue.ProjectIssueId, issue.FieldId, "98236657"); + await mutation.SendAsync(); + } + } +} \ No newline at end of file