From 0c3d842ec958fb838b062bc9ef5cfd48d04823e2 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Fri, 31 May 2024 14:35:22 -0400 Subject: [PATCH 1/2] Add code to map GH labels to AzDo Tags Add a config option to map from GitHub label to Azure DevOps tag. When creating a new Azure DevOps work item, add any tags from matching GitHub labels. --- actions/sequester/ImportIssues/Program.cs | 3 +- .../Quest2GitHub/Models/IssueExtensions.cs | 18 +++++++++++ .../Quest2GitHub/Models/QuestWorkItem.cs | 24 ++++++++++++-- .../Quest2GitHub/Options/ImportOptions.cs | 11 +++++++ .../Quest2GitHub/QuestGitHubService.cs | 31 +++++++++++++++---- 5 files changed, 78 insertions(+), 9 deletions(-) diff --git a/actions/sequester/ImportIssues/Program.cs b/actions/sequester/ImportIssues/Program.cs index 2d5a81b..045e455 100644 --- a/actions/sequester/ImportIssues/Program.cs +++ b/actions/sequester/ImportIssues/Program.cs @@ -115,6 +115,7 @@ private static async Task CreateService(ImportOptions option options.ImportTriggerLabel, options.ImportedLabel, options.DefaultParentNode, - options.ParentNodes); + options.ParentNodes, + options.WorkItemTags); } } diff --git a/actions/sequester/Quest2GitHub/Models/IssueExtensions.cs b/actions/sequester/Quest2GitHub/Models/IssueExtensions.cs index a029ffd..e1044e5 100644 --- a/actions/sequester/Quest2GitHub/Models/IssueExtensions.cs +++ b/actions/sequester/Quest2GitHub/Models/IssueExtensions.cs @@ -91,4 +91,22 @@ month descending } return default; } + + /// + /// Return tags for a given issue + /// + /// The GitHub issue or pull request + /// The mapping from issue to tag + /// An enumerable of tags + public static IEnumerable WorkItemTagsForIssue(this QuestIssueOrPullRequest issue, IEnumerable tags) + { + foreach (var label in issue.Labels) + { + var tag = tags.FirstOrDefault(t => t.Label == label.Name); + if (tag.Tag is not null) + { + yield return tag.Tag; + } + } + } } diff --git a/actions/sequester/Quest2GitHub/Models/QuestWorkItem.cs b/actions/sequester/Quest2GitHub/Models/QuestWorkItem.cs index b091147..52fd877 100644 --- a/actions/sequester/Quest2GitHub/Models/QuestWorkItem.cs +++ b/actions/sequester/Quest2GitHub/Models/QuestWorkItem.cs @@ -88,6 +88,8 @@ public class QuestWorkItem /// public required int? ParentRelationIndex { get; init; } + public required IEnumerable Tags { get; init; } + /// /// Create a work item object from the ID /// @@ -110,6 +112,8 @@ public static async Task QueryWorkItem(QuestClient client, int wo /// The path component for the area path. /// The current AzDo iteration /// The set of all iterations to search + /// The ID of the request label + /// The map of GH label to tags /// The newly created linked Quest work item. /// /// Fill in the Json patch document from the GitHub issue. @@ -124,7 +128,8 @@ public static async Task CreateWorkItemAsync(QuestIssueOrPullRequ string path, string? requestLabelNodeId, QuestIteration currentIteration, - IEnumerable allIterations) + IEnumerable allIterations, + IEnumerable tagMap) { string areaPath = $"""{questClient.QuestProject}\{path}"""; @@ -212,6 +217,17 @@ public static async Task CreateWorkItemAsync(QuestIssueOrPullRequ }); } + var tags = issue.WorkItemTagsForIssue(tagMap); + if (tags.Any()) + { + string azDoTags = string.Join(";", tags); + patchDocument.Add(new JsonPatchDocument + { + Operation = Op.Add, + Path = "/fields/System.Tags", + Value = azDoTags + }); + } /* This is ignored by Azure DevOps. It uses the PAT of the * account running the code. var creator = await issue.AuthorMicrosoftPreferredName(ospoClient); @@ -360,6 +376,9 @@ public static QuestWorkItem WorkItemFromJson(JsonElement root) (int)double.Truncate(storyPointNode.GetDouble()) : null; string? assignedID = (assignedNode.ValueKind is JsonValueKind.String) ? assignedNode.GetString() : null; + string tagElement = fields.TryGetProperty("System.Tags", out JsonElement tagsNode) ? + tagsNode.GetString()! : string.Empty; + IEnumerable tags = [..tagElement.Split(';').Select(s => s.Trim())]; return new QuestWorkItem { Id = id, @@ -371,7 +390,8 @@ public static QuestWorkItem WorkItemFromJson(JsonElement root) AreaPath = areaPath, IterationPath = iterationPath, AssignedToId = (assignedID is not null) ? new Guid(assignedID) : null, - StoryPoints = storyPoints + StoryPoints = storyPoints, + Tags = tags }; } diff --git a/actions/sequester/Quest2GitHub/Options/ImportOptions.cs b/actions/sequester/Quest2GitHub/Options/ImportOptions.cs index 958f1c1..55ec3ae 100644 --- a/actions/sequester/Quest2GitHub/Options/ImportOptions.cs +++ b/actions/sequester/Quest2GitHub/Options/ImportOptions.cs @@ -2,6 +2,8 @@ public record struct ParentForLabel(string Label, int ParentNodeId); +public record struct LabelToTagMap(string Label, string Tag); + public sealed record class ImportOptions { /// @@ -64,4 +66,13 @@ public sealed record class ImportOptions /// the default parent node is set for the work item. /// public int DefaultParentNode { get; init; } + + /// + /// A map of GitHub labels to Azure DevOps tags. + /// + /// + /// If an issue has the matching label, add the corresponding tag to + /// the mapped AzureDevOps item. + /// + public List WorkItemTags { get; init; } = []; } diff --git a/actions/sequester/Quest2GitHub/QuestGitHubService.cs b/actions/sequester/Quest2GitHub/QuestGitHubService.cs index 701db33..cae9a43 100644 --- a/actions/sequester/Quest2GitHub/QuestGitHubService.cs +++ b/actions/sequester/Quest2GitHub/QuestGitHubService.cs @@ -37,7 +37,8 @@ public class QuestGitHubService( string importTriggerLabelText, string importedLabelText, int defaultParentNode, - List parentNodes) : IDisposable + List parentNodes, + IEnumerable tagMap) : IDisposable { private const string LinkedWorkItemComment = "Associated WorkItem - "; private readonly QuestClient _azdoClient = new(azdoKey, questOrg, questProject); @@ -95,7 +96,7 @@ async Task ProcessItems(IAsyncEnumerable items) { if (questItem != null) { - await UpdateWorkItemAsync(questItem, item, _allIterations); + await UpdateWorkItemAsync(questItem, item, _allIterations, tagMap); } else { @@ -183,7 +184,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); + await UpdateWorkItemAsync(questItem, ghIssue, _allIterations, tagMap); } // Next, if the item is already linked, consider any updates. @@ -194,7 +195,7 @@ public async Task ProcessIssue(string gitHubOrganization, string gitHubRepositor } else if (sequestered && questItem is not null) { - await UpdateWorkItemAsync(questItem, ghIssue, _allIterations); + await UpdateWorkItemAsync(questItem, ghIssue, _allIterations, tagMap); } } @@ -252,7 +253,8 @@ private async Task RetrieveIterationLabelsAsync() if (workItem is null) { // Create work item: - QuestWorkItem questItem = await QuestWorkItem.CreateWorkItemAsync(issueOrPullRequest, parentId, _azdoClient, _ospoClient, areaPath, _importTriggerLabel?.Id, currentIteration, allIterations); + QuestWorkItem questItem = await QuestWorkItem.CreateWorkItemAsync(issueOrPullRequest, parentId, _azdoClient, _ospoClient, areaPath, + _importTriggerLabel?.Id, currentIteration, allIterations, tagMap); string linkText = $"[{LinkedWorkItemComment}{questItem.Id}]({_questLinkString}{questItem.Id})"; string updatedBody = $""" @@ -291,7 +293,10 @@ private async Task RetrieveLabelIdsAsync(string org, string repo) } } - private async Task UpdateWorkItemAsync(QuestWorkItem questItem, QuestIssueOrPullRequest ghIssue, IEnumerable allIterations) + private async Task UpdateWorkItemAsync(QuestWorkItem questItem, + QuestIssueOrPullRequest ghIssue, + IEnumerable allIterations, + IEnumerable tagMap) { int parentId = parentIdFromIssue(ghIssue); string? ghAssigneeEmailAddress = await ghIssue.QueryAssignedMicrosoftEmailAddressAsync(_ospoClient); @@ -392,6 +397,20 @@ private async Task RetrieveLabelIdsAsync(string org, string repo) Value = iterationSize.QuestStoryPoint(), }); } + 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) { From 3ce342bc8e8e2edf13f9c13d0243615f82936523 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Fri, 31 May 2024 16:11:40 -0400 Subject: [PATCH 2/2] a little formatting --- actions/sequester/Quest2GitHub/QuestGitHubService.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/actions/sequester/Quest2GitHub/QuestGitHubService.cs b/actions/sequester/Quest2GitHub/QuestGitHubService.cs index cae9a43..04234c9 100644 --- a/actions/sequester/Quest2GitHub/QuestGitHubService.cs +++ b/actions/sequester/Quest2GitHub/QuestGitHubService.cs @@ -245,7 +245,6 @@ private async Task RetrieveIterationLabelsAsync() return [.. iterations]; } - private async Task LinkIssueAsync(QuestIssueOrPullRequest issueOrPullRequest, QuestIteration currentIteration, IEnumerable allIterations) { int? workItem = LinkedQuestId(issueOrPullRequest);