From 22d0f231b39a36bab9aabdff0c295560f7d65bb8 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 26 Sep 2024 15:27:54 -0400 Subject: [PATCH] Handle per-semester parents in Quest (#413) * Set the state to "committed" after creation * First pass at a new config * Revert "First pass at a new config" This reverts commit 8e028fd45dd4b3ec35289dc60f1d7f303cf08325. * Code refactoring Move some code around to make it easier to add the config features without extensive copying. * Expand config object to include semesters Set the parent based on semester + parent config. * Apply suggestions from code review Co-authored-by: David Pine * fix for production --------- Co-authored-by: David Pine --- .../Quest2GitHub/Models/QuestIteration.cs | 2 + .../Quest2GitHub/Models/QuestWorkItem.cs | 182 ++++++++++++++++- .../Quest2GitHub/Options/ImportOptions.cs | 2 +- .../Quest2GitHub/QuestGitHubService.cs | 192 ++---------------- quest-config.json | 12 +- 5 files changed, 214 insertions(+), 176 deletions(-) diff --git a/actions/sequester/Quest2GitHub/Models/QuestIteration.cs b/actions/sequester/Quest2GitHub/Models/QuestIteration.cs index ac0bdb3c..12a620a6 100644 --- a/actions/sequester/Quest2GitHub/Models/QuestIteration.cs +++ b/actions/sequester/Quest2GitHub/Models/QuestIteration.cs @@ -7,6 +7,8 @@ public class QuestIteration public required string Name { get; init; } public required string Path { get; init; } + public bool IsInSemester(string semesterName) => Path.Contains(semesterName); + public static QuestIteration? CurrentIteration(IEnumerable iterations) { var currentYear = int.Parse(DateTime.Now.ToString("yyyy")); diff --git a/actions/sequester/Quest2GitHub/Models/QuestWorkItem.cs b/actions/sequester/Quest2GitHub/Models/QuestWorkItem.cs index 929bff95..31dbc0dd 100644 --- a/actions/sequester/Quest2GitHub/Models/QuestWorkItem.cs +++ b/actions/sequester/Quest2GitHub/Models/QuestWorkItem.cs @@ -1,5 +1,6 @@ using DotNet.DocsTools.GitHubObjects; using DotNet.DocsTools.Utility; +using Microsoft.DotnetOrg.Ospo; namespace Quest2GitHub.Models; @@ -123,7 +124,6 @@ public static async Task QueryWorkItem(QuestClient client, int wo /// Create a work item from a GitHub issue. /// /// The GitHub issue. - /// The ID of the parent ID /// The quest client. /// the MS open source programs office client. /// The path component for the area path. @@ -139,16 +139,18 @@ public static async Task QueryWorkItem(QuestClient client, int wo /// Json element. /// public static async Task CreateWorkItemAsync(QuestIssueOrPullRequest issue, - int parentId, QuestClient questClient, OspoClient? ospoClient, string path, string? requestLabelNodeId, QuestIteration currentIteration, IEnumerable allIterations, - IEnumerable tagMap) + IEnumerable tagMap, + IEnumerable parentNodes, + int defaultParentNode) { string areaPath = $"""{questClient.QuestProject}\{path}"""; + int parentId = ParentIdFromIssue(parentNodes, issue, defaultParentNode, allIterations); List patchDocument = [ @@ -399,6 +401,180 @@ public static string BuildDescriptionFromIssue(QuestIssueOrPullRequest issue, st } } + static internal async Task UpdateWorkItemAsync(QuestWorkItem questItem, + QuestIssueOrPullRequest ghIssue, + QuestClient questClient, + OspoClient? ospoClient, + IEnumerable allIterations, + IEnumerable tagMap, + IEnumerable parentNodes, + int defaultParentNode) + { + int parentId = ParentIdFromIssue(parentNodes, ghIssue, defaultParentNode, allIterations); + string? ghAssigneeEmailAddress = await ghIssue.QueryAssignedMicrosoftEmailAddressAsync(ospoClient); + AzDoIdentity? questAssigneeID = default; + var proposedQuestState = questItem.State; + if (ghAssigneeEmailAddress?.EndsWith("@microsoft.com") == true) + { + questAssigneeID = await questClient.GetIDFromEmail(ghAssigneeEmailAddress); + } + List patchDocument = []; + if ((parentId != 0) && (parentId != questItem.ParentWorkItemId)) + { + if (questItem.ParentWorkItemId != 0) + { + // Remove the existing parent relation. + patchDocument.Add(new JsonPatchDocument + { + Operation = Op.Remove, + Path = "/relations/" + questItem.ParentRelationIndex, + }); + }; + var parentRelation = new Relation + { + RelationName = "System.LinkTypes.Hierarchy-Reverse", + Url = $"https://dev.azure.com/{questClient.QuestOrg}/{questClient.QuestProject}/_apis/wit/workItems/{parentId}", + Attributes = + { + ["name"] = "Parent", + ["isLocked"] = false + } + }; + + patchDocument.Add(new JsonPatchDocument + { + Operation = Op.Add, + Path = "/relations/-", + From = default, + Value = parentRelation + }); + } + if ((questAssigneeID is not null) && (questAssigneeID?.Id != questItem.AssignedToId)) + { + // build patch document for assignment. + JsonPatchDocument assignPatch = new() + { + Operation = Op.Add, + Path = "/fields/System.AssignedTo", + Value = questAssigneeID, + }; + patchDocument.Add(assignPatch); + } + bool questItemOpen = questItem.State is not "Closed"; + proposedQuestState = ghIssue.IsOpen ? "Committed" : "Closed"; + if (ghIssue.IsOpen != questItemOpen) + { + + // When the issue is opened or closed, + // update the description. That picks up any new + // labels and comments. + patchDocument.Add(new JsonPatchDocument + { + Operation = Op.Add, + Path = "/fields/System.Description", + From = default, + Value = BuildDescriptionFromIssue(ghIssue, null) + }); + } + StoryPointSize? iterationSize = ghIssue.LatestStoryPointSize(); + QuestIteration? iteration = iterationSize?.ProjectIteration(allIterations); + if (iterationSize != null) + { + Console.WriteLine($"Latest GitHub sprint project: {iterationSize?.Month}-{iterationSize?.CalendarYear}, size: {iterationSize?.Size}"); + if ((iterationSize?.IsPastIteration == true) && (ghIssue.IsOpen == true)) + { + Console.WriteLine($"Moving to the backlog / future iteration."); + iteration = QuestIteration.FutureIteration(allIterations); + proposedQuestState = "New"; + } + } + else + { + Console.WriteLine("No GitHub sprint project found - using current iteration."); + } + if (proposedQuestState != questItem.State) + { + patchDocument.Add(new JsonPatchDocument + { + Operation = Op.Add, + Path = "/fields/System.State", + Value = proposedQuestState, + }); + } + if ((iteration is not null) && (iteration.Path != questItem.IterationPath)) + { + patchDocument.Add(new JsonPatchDocument + { + Operation = Op.Add, + Path = "/fields/System.IterationPath", + Value = iteration.Path, + }); + } + if ((iterationSize?.QuestStoryPoint() is not null) && (iterationSize.QuestStoryPoint() != questItem.StoryPoints)) + { + patchDocument.Add(new JsonPatchDocument + { + Operation = Op.Add, + From = default, + Path = "/fields/Microsoft.VSTS.Scheduling.StoryPoints", + Value = iterationSize.QuestStoryPoint(), + }); + } + int? priority = ghIssue.GetPriority(iterationSize); + if (priority.HasValue && priority != questItem.Priority) + { + patchDocument.Add(new JsonPatchDocument + { + Operation = Op.Add, + Path = "/fields/Microsoft.VSTS.Common.Priority", + Value = priority.Value + }); + } + var tags = from t in ghIssue.WorkItemTagsForIssue(tagMap) + where !questItem.Tags.Contains(t) + select t; + if (tags.Any()) + { + string azDoTags = string.Join(";", tags); + patchDocument.Add(new JsonPatchDocument + { + Operation = Op.Add, + Path = "/fields/System.Tags", + Value = azDoTags + }); + } + + QuestWorkItem? newItem = default; + if (patchDocument.Count != 0) + { + JsonElement jsonDocument = await questClient.PatchWorkItem(questItem.Id, patchDocument); + newItem = QuestWorkItem.WorkItemFromJson(jsonDocument); + } + if (!ghIssue.IsOpen && (ghIssue.ClosingPRUrl is not null)) + { + newItem = await questItem.AddClosingPR(questClient, ghIssue.ClosingPRUrl) ?? newItem; + } + return newItem; + } + + static private int ParentIdFromIssue(IEnumerable parentNodes, QuestIssueOrPullRequest ghIssue, int defaultParentNode, IEnumerable allIterations) + { + var iteration = ghIssue.LatestStoryPointSize()?.ProjectIteration(allIterations); + + foreach (ParentForLabel pair in parentNodes) + { + if (ghIssue.Labels.Any(l => l.Name == pair.Label) || (pair.Label is null)) + { + if ((pair.Semester is null) || (iteration?.IsInSemester(pair.Semester) is true)) + { + return pair.ParentNodeId; + } + } + } + return defaultParentNode; + } + + /// /// Construct a work item from the JSON document. /// diff --git a/actions/sequester/Quest2GitHub/Options/ImportOptions.cs b/actions/sequester/Quest2GitHub/Options/ImportOptions.cs index 55ec3aeb..41b418e3 100644 --- a/actions/sequester/Quest2GitHub/Options/ImportOptions.cs +++ b/actions/sequester/Quest2GitHub/Options/ImportOptions.cs @@ -1,6 +1,6 @@ namespace Quest2GitHub.Options; -public record struct ParentForLabel(string Label, int ParentNodeId); +public record struct ParentForLabel(string? Label, string? Semester, int ParentNodeId); public record struct LabelToTagMap(string Label, string Tag); diff --git a/actions/sequester/Quest2GitHub/QuestGitHubService.cs b/actions/sequester/Quest2GitHub/QuestGitHubService.cs index b115208d..0a37c57a 100644 --- a/actions/sequester/Quest2GitHub/QuestGitHubService.cs +++ b/actions/sequester/Quest2GitHub/QuestGitHubService.cs @@ -1,4 +1,5 @@ -using DotNet.DocsTools.GitHubObjects; +using System.Xml.XPath; +using DotNet.DocsTools.GitHubObjects; using DotNet.DocsTools.GraphQLQueries; namespace Quest2GitHub; @@ -107,7 +108,7 @@ async Task ProcessItems(IAsyncEnumerable items) { if (questItem != null) { - await UpdateWorkItemAsync(questItem, item, _allIterations, tagMap); + await QuestWorkItem.UpdateWorkItemAsync(questItem, item, _azdoClient, _ospoClient, _allIterations, tagMap, parentNodes, defaultParentNode); } else { @@ -204,7 +205,7 @@ public async Task ProcessIssue(string gitHubOrganization, string gitHubRepositor { // This allows a human to force a manual update: just add the trigger label. // Note that it updates even if the item is closed. - await UpdateWorkItemAsync(questItem, ghIssue, _allIterations, tagMap); + await QuestWorkItem.UpdateWorkItemAsync(questItem, ghIssue, _azdoClient, _ospoClient, _allIterations, tagMap, parentNodes, defaultParentNode); } // Next, if the item is already linked, consider any updates. @@ -215,7 +216,7 @@ public async Task ProcessIssue(string gitHubOrganization, string gitHubRepositor } else if (sequestered && questItem is not null) { - await UpdateWorkItemAsync(questItem, ghIssue, _allIterations, tagMap); + await QuestWorkItem.UpdateWorkItemAsync(questItem, ghIssue, _azdoClient, _ospoClient, _allIterations, tagMap, parentNodes, defaultParentNode); } } @@ -297,12 +298,11 @@ static IEnumerable ChildIterations(JsonElement parentIteration) private async Task LinkIssueAsync(QuestIssueOrPullRequest issueOrPullRequest, QuestIteration currentIteration, IEnumerable allIterations) { int? workItem = LinkedQuestId(issueOrPullRequest); - int parentId = parentIdFromIssue(issueOrPullRequest); if (workItem is null) { // Create work item: - QuestWorkItem questItem = await QuestWorkItem.CreateWorkItemAsync(issueOrPullRequest, parentId, _azdoClient, _ospoClient, areaPath, - _importTriggerLabel?.Id, currentIteration, allIterations, tagMap); + QuestWorkItem questItem = await QuestWorkItem.CreateWorkItemAsync(issueOrPullRequest, _azdoClient, _ospoClient, areaPath, + _importTriggerLabel?.Id, currentIteration, allIterations, tagMap, parentNodes, defaultParentNode); string linkText = $"[{LinkedWorkItemComment}{questItem.Id}]({_questLinkString}{questItem.Id})"; string updatedBody = $""" @@ -322,6 +322,20 @@ static IEnumerable ChildIterations(JsonElement parentIteration) var prMutation = new Mutation(ghClient); await prMutation.PerformMutation(new SequesterVariables(pr.Id, _importTriggerLabel?.Id ?? "", _importedLabel?.Id ?? "", updatedBody)); } + // All work items are created in the "New" state, despite the REST packet + // indicating the "Committed" status. So, update the state to "Committed". + List patchDocument = []; + patchDocument.Add(new JsonPatchDocument + { + Operation = Op.Add, + Path = "/fields/System.State", + Value = "Committed", + }); + if (patchDocument.Count != 0) + { + JsonElement jsonDocument = await _azdoClient.PatchWorkItem(questItem.Id, patchDocument); + questItem = QuestWorkItem.WorkItemFromJson(jsonDocument); + } return questItem; } else @@ -341,170 +355,6 @@ private async Task RetrieveLabelIdsAsync(string org, string repo) } } - private async Task UpdateWorkItemAsync(QuestWorkItem questItem, - QuestIssueOrPullRequest ghIssue, - IEnumerable allIterations, - IEnumerable tagMap) - { - int parentId = parentIdFromIssue(ghIssue); - string? ghAssigneeEmailAddress = await ghIssue.QueryAssignedMicrosoftEmailAddressAsync(_ospoClient); - AzDoIdentity? questAssigneeID = default; - var proposedQuestState = questItem.State; - if (ghAssigneeEmailAddress?.EndsWith("@microsoft.com") == true) - { - questAssigneeID = await _azdoClient.GetIDFromEmail(ghAssigneeEmailAddress); - } - List patchDocument = []; - if ((parentId != 0) && (parentId != questItem.ParentWorkItemId)) - { - if (questItem.ParentWorkItemId != 0) - { - // Remove the existing parent relation. - patchDocument.Add(new JsonPatchDocument - { - Operation = Op.Remove, - Path = "/relations/" + questItem.ParentRelationIndex, - }); - }; - var parentRelation = new Relation - { - RelationName = "System.LinkTypes.Hierarchy-Reverse", - Url = $"https://dev.azure.com/{_azdoClient.QuestOrg}/{_azdoClient.QuestProject}/_apis/wit/workItems/{parentId}", - Attributes = - { - ["name"] = "Parent", - ["isLocked"] = false - } - }; - - patchDocument.Add(new JsonPatchDocument - { - Operation = Op.Add, - Path = "/relations/-", - From = default, - Value = parentRelation - }); - } - if ((questAssigneeID is not null) && (questAssigneeID?.Id != questItem.AssignedToId)) - { - // build patch document for assignment. - JsonPatchDocument assignPatch = new () - { - Operation = Op.Add, - Path = "/fields/System.AssignedTo", - Value = questAssigneeID, - }; - patchDocument.Add(assignPatch); - } - bool questItemOpen = questItem.State is not "Closed"; - proposedQuestState = ghIssue.IsOpen ? "Committed" : "Closed"; - if (ghIssue.IsOpen != questItemOpen) - { - - // When the issue is opened or closed, - // update the description. That picks up any new - // labels and comments. - patchDocument.Add(new JsonPatchDocument - { - Operation = Op.Add, - Path = "/fields/System.Description", - From = default, - Value = QuestWorkItem.BuildDescriptionFromIssue(ghIssue, null) - }); - } - StoryPointSize? iterationSize = ghIssue.LatestStoryPointSize(); - QuestIteration? iteration = iterationSize?.ProjectIteration(allIterations); - if (iterationSize != null) - { - Console.WriteLine($"Latest GitHub sprint project: {iterationSize?.Month}-{iterationSize?.CalendarYear}, size: {iterationSize?.Size}"); - if ((iterationSize?.IsPastIteration == true) && (ghIssue.IsOpen == true)) - { - Console.WriteLine($"Moving to the backlog / future iteration."); - iteration = QuestIteration.FutureIteration(allIterations); - proposedQuestState = "New"; - } - } - else - { - Console.WriteLine("No GitHub sprint project found - using current iteration."); - } - if (proposedQuestState != questItem.State) - { - patchDocument.Add(new JsonPatchDocument - { - Operation = Op.Add, - Path = "/fields/System.State", - Value = proposedQuestState, - }); - } - if ((iteration is not null) && (iteration.Path != questItem.IterationPath)) - { - patchDocument.Add(new JsonPatchDocument - { - Operation = Op.Add, - Path = "/fields/System.IterationPath", - Value = iteration.Path, - }); - } - if ((iterationSize?.QuestStoryPoint() is not null) && (iterationSize.QuestStoryPoint() != questItem.StoryPoints)) - { - patchDocument.Add(new JsonPatchDocument - { - Operation = Op.Add, - From = default, - Path = "/fields/Microsoft.VSTS.Scheduling.StoryPoints", - Value = iterationSize.QuestStoryPoint(), - }); - } - int? priority = ghIssue.GetPriority(iterationSize); - if (priority.HasValue && priority != questItem.Priority) - { - patchDocument.Add(new JsonPatchDocument - { - Operation = Op.Add, - Path = "/fields/Microsoft.VSTS.Common.Priority", - Value = priority.Value - }); - } - var tags = from t in ghIssue.WorkItemTagsForIssue(tagMap) - where !questItem.Tags.Contains(t) - select t; - if (tags.Any()) - { - string azDoTags = string.Join(";", tags); - patchDocument.Add(new JsonPatchDocument - { - Operation = Op.Add, - Path = "/fields/System.Tags", - Value = azDoTags - }); - } - - QuestWorkItem? newItem = default; - if (patchDocument.Count != 0) - { - JsonElement jsonDocument = await _azdoClient.PatchWorkItem(questItem.Id, patchDocument); - newItem = QuestWorkItem.WorkItemFromJson(jsonDocument); - } - if (!ghIssue.IsOpen && (ghIssue.ClosingPRUrl is not null)) - { - newItem = await questItem.AddClosingPR(_azdoClient, ghIssue.ClosingPRUrl) ?? newItem; - } - return newItem; - } - - private int parentIdFromIssue(QuestIssueOrPullRequest ghIssue) - { - foreach (ParentForLabel pair in parentNodes) - { - if (ghIssue.Labels.Any(l => l.Name == pair.Label)) - { - return pair.ParentNodeId; - } - } - return defaultParentNode; - } - private async Task FindLinkedWorkItemAsync(QuestIssueOrPullRequest issue) { int? questId = LinkedQuestId(issue); diff --git a/quest-config.json b/quest-config.json index f527c85a..39b61e26 100644 --- a/quest-config.json +++ b/quest-config.json @@ -6,5 +6,15 @@ }, "ImportTriggerLabel": ":world_map: reQUEST", "ImportedLabel": ":pushpin: seQUESTered", - "DefaultParentNode": 233488 + "ParentNodes": [ + { + "Semester": "Dilithium", + "ParentNodeId": 233488 + }, + { + "Semester": "Selenium", + "ParentNodeId": 286035 + } + ], + "DefaultParentNode": 286035 } \ No newline at end of file