Skip to content

Commit

Permalink
Add tags in Azure DevOps based on GitHub issue labels (#358)
Browse files Browse the repository at this point in the history
* 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.

* a little formatting
  • Loading branch information
BillWagner authored Jun 3, 2024
1 parent 88b652a commit 7b86ca5
Show file tree
Hide file tree
Showing 5 changed files with 78 additions and 10 deletions.
3 changes: 2 additions & 1 deletion actions/sequester/ImportIssues/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ private static async Task<QuestGitHubService> CreateService(ImportOptions option
options.ImportTriggerLabel,
options.ImportedLabel,
options.DefaultParentNode,
options.ParentNodes);
options.ParentNodes,
options.WorkItemTags);
}
}
18 changes: 18 additions & 0 deletions actions/sequester/Quest2GitHub/Models/IssueExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,22 @@ month descending
}
return default;
}

/// <summary>
/// Return tags for a given issue
/// </summary>
/// <param name="issue">The GitHub issue or pull request</param>
/// <param name="tags">The mapping from issue to tag</param>
/// <returns>An enumerable of tags</returns>
public static IEnumerable<string> WorkItemTagsForIssue(this QuestIssueOrPullRequest issue, IEnumerable<LabelToTagMap> 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;
}
}
}
}
24 changes: 22 additions & 2 deletions actions/sequester/Quest2GitHub/Models/QuestWorkItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ public class QuestWorkItem
/// </remarks>
public required int? ParentRelationIndex { get; init; }

public required IEnumerable<string> Tags { get; init; }

/// <summary>
/// Create a work item object from the ID
/// </summary>
Expand All @@ -110,6 +112,8 @@ public static async Task<QuestWorkItem> QueryWorkItem(QuestClient client, int wo
/// <param name="path">The path component for the area path.</param>
/// <param name="currentIteration">The current AzDo iteration</param>
/// <param name="allIterations">The set of all iterations to search</param>
/// <param name="requestLabelNodeId">The ID of the request label</param>
/// <param name="tagMap">The map of GH label to tags</param>
/// <returns>The newly created linked Quest work item.</returns>
/// <remarks>
/// Fill in the Json patch document from the GitHub issue.
Expand All @@ -124,7 +128,8 @@ public static async Task<QuestWorkItem> CreateWorkItemAsync(QuestIssueOrPullRequ
string path,
string? requestLabelNodeId,
QuestIteration currentIteration,
IEnumerable<QuestIteration> allIterations)
IEnumerable<QuestIteration> allIterations,
IEnumerable<LabelToTagMap> tagMap)
{
string areaPath = $"""{questClient.QuestProject}\{path}""";

Expand Down Expand Up @@ -212,6 +217,17 @@ public static async Task<QuestWorkItem> 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);
Expand Down Expand Up @@ -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<string> tags = [..tagElement.Split(';').Select(s => s.Trim())];
return new QuestWorkItem
{
Id = id,
Expand All @@ -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
};
}

Expand Down
11 changes: 11 additions & 0 deletions actions/sequester/Quest2GitHub/Options/ImportOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
/// <summary>
Expand Down Expand Up @@ -64,4 +66,13 @@ public sealed record class ImportOptions
/// the default parent node is set for the work item.
/// </remarks>
public int DefaultParentNode { get; init; }

/// <summary>
/// A map of GitHub labels to Azure DevOps tags.
/// </summary>
/// <remarks>
/// If an issue has the matching label, add the corresponding tag to
/// the mapped AzureDevOps item.
/// </remarks>
public List<LabelToTagMap> WorkItemTags { get; init; } = [];
}
32 changes: 25 additions & 7 deletions actions/sequester/Quest2GitHub/QuestGitHubService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ public class QuestGitHubService(
string importTriggerLabelText,
string importedLabelText,
int defaultParentNode,
List<ParentForLabel> parentNodes) : IDisposable
List<ParentForLabel> parentNodes,
IEnumerable<LabelToTagMap> tagMap) : IDisposable
{
private const string LinkedWorkItemComment = "Associated WorkItem - ";
private readonly QuestClient _azdoClient = new(azdoKey, questOrg, questProject);
Expand Down Expand Up @@ -95,7 +96,7 @@ async Task ProcessItems(IAsyncEnumerable<QuestIssueOrPullRequest> items)
{
if (questItem != null)
{
await UpdateWorkItemAsync(questItem, item, _allIterations);
await UpdateWorkItemAsync(questItem, item, _allIterations, tagMap);
}
else
{
Expand Down Expand Up @@ -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.
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -244,15 +245,15 @@ private async Task<QuestIteration[]> RetrieveIterationLabelsAsync()
return [.. iterations];
}


private async Task<QuestWorkItem?> LinkIssueAsync(QuestIssueOrPullRequest issueOrPullRequest, QuestIteration currentIteration, IEnumerable<QuestIteration> 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);
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 = $"""
Expand Down Expand Up @@ -291,7 +292,10 @@ private async Task RetrieveLabelIdsAsync(string org, string repo)
}
}

private async Task<QuestWorkItem?> UpdateWorkItemAsync(QuestWorkItem questItem, QuestIssueOrPullRequest ghIssue, IEnumerable<QuestIteration> allIterations)
private async Task<QuestWorkItem?> UpdateWorkItemAsync(QuestWorkItem questItem,
QuestIssueOrPullRequest ghIssue,
IEnumerable<QuestIteration> allIterations,
IEnumerable<LabelToTagMap> tagMap)
{
int parentId = parentIdFromIssue(ghIssue);
string? ghAssigneeEmailAddress = await ghIssue.QueryAssignedMicrosoftEmailAddressAsync(_ospoClient);
Expand Down Expand Up @@ -392,6 +396,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)
{
Expand Down

0 comments on commit 7b86ca5

Please sign in to comment.