Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tags in Azure DevOps based on GitHub issue labels #358

Merged
merged 2 commits into from
Jun 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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