From d2eb88e1b1f35dee1426364acc16415186488b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=E2=96=88=E2=96=88=E2=96=88=E2=96=88=E2=96=88?= Date: Wed, 7 May 2025 13:14:46 -0400 Subject: [PATCH] feat: Automatically move closed issues to done in backlog If an issue is closed, we want the card to be moved to the Done column in the board and not be left dangling somewhere. close #6 --- src/Nullinside.Cicd.GitHub/.editorconfig | 7 ++ src/Nullinside.Cicd.GitHub/Constants.cs | 10 +++ .../GraphQl/AbstractGitHubGraphQlQuery.cs | 54 +++++++++++++++ .../Model/GraphQlGenericMutationResponse.cs | 22 ++++++ .../Model/GraphQlProjectIssueResponse.cs | 68 +++++++++++++++++++ .../GraphQl/MutateIssueProjectStatuses.cs | 28 ++++++++ .../GraphQl/QueryIssueProjectStatuses.cs | 54 +++++++++++++++ .../Nullinside.Cicd.GitHub.csproj | 16 ++--- src/Nullinside.Cicd.GitHub/Program.cs | 3 +- .../Rule/AssociateIssuesWithProject.cs | 6 +- .../Rule/CreateRulesets.cs | 6 +- .../Rule/MoveClosedToDone.cs | 47 +++++++++++++ 12 files changed, 305 insertions(+), 16 deletions(-) create mode 100644 src/Nullinside.Cicd.GitHub/GraphQl/AbstractGitHubGraphQlQuery.cs create mode 100644 src/Nullinside.Cicd.GitHub/GraphQl/Model/GraphQlGenericMutationResponse.cs create mode 100644 src/Nullinside.Cicd.GitHub/GraphQl/Model/GraphQlProjectIssueResponse.cs create mode 100644 src/Nullinside.Cicd.GitHub/GraphQl/MutateIssueProjectStatuses.cs create mode 100644 src/Nullinside.Cicd.GitHub/GraphQl/QueryIssueProjectStatuses.cs create mode 100644 src/Nullinside.Cicd.GitHub/Rule/MoveClosedToDone.cs 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