Skip to content

Commit 7b86ca5

Browse files
authored
Add tags in Azure DevOps based on GitHub issue labels (#358)
* 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
1 parent 88b652a commit 7b86ca5

File tree

5 files changed

+78
-10
lines changed

5 files changed

+78
-10
lines changed

actions/sequester/ImportIssues/Program.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ private static async Task<QuestGitHubService> CreateService(ImportOptions option
115115
options.ImportTriggerLabel,
116116
options.ImportedLabel,
117117
options.DefaultParentNode,
118-
options.ParentNodes);
118+
options.ParentNodes,
119+
options.WorkItemTags);
119120
}
120121
}

actions/sequester/Quest2GitHub/Models/IssueExtensions.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,22 @@ month descending
9191
}
9292
return default;
9393
}
94+
95+
/// <summary>
96+
/// Return tags for a given issue
97+
/// </summary>
98+
/// <param name="issue">The GitHub issue or pull request</param>
99+
/// <param name="tags">The mapping from issue to tag</param>
100+
/// <returns>An enumerable of tags</returns>
101+
public static IEnumerable<string> WorkItemTagsForIssue(this QuestIssueOrPullRequest issue, IEnumerable<LabelToTagMap> tags)
102+
{
103+
foreach (var label in issue.Labels)
104+
{
105+
var tag = tags.FirstOrDefault(t => t.Label == label.Name);
106+
if (tag.Tag is not null)
107+
{
108+
yield return tag.Tag;
109+
}
110+
}
111+
}
94112
}

actions/sequester/Quest2GitHub/Models/QuestWorkItem.cs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ public class QuestWorkItem
8888
/// </remarks>
8989
public required int? ParentRelationIndex { get; init; }
9090

91+
public required IEnumerable<string> Tags { get; init; }
92+
9193
/// <summary>
9294
/// Create a work item object from the ID
9395
/// </summary>
@@ -110,6 +112,8 @@ public static async Task<QuestWorkItem> QueryWorkItem(QuestClient client, int wo
110112
/// <param name="path">The path component for the area path.</param>
111113
/// <param name="currentIteration">The current AzDo iteration</param>
112114
/// <param name="allIterations">The set of all iterations to search</param>
115+
/// <param name="requestLabelNodeId">The ID of the request label</param>
116+
/// <param name="tagMap">The map of GH label to tags</param>
113117
/// <returns>The newly created linked Quest work item.</returns>
114118
/// <remarks>
115119
/// Fill in the Json patch document from the GitHub issue.
@@ -124,7 +128,8 @@ public static async Task<QuestWorkItem> CreateWorkItemAsync(QuestIssueOrPullRequ
124128
string path,
125129
string? requestLabelNodeId,
126130
QuestIteration currentIteration,
127-
IEnumerable<QuestIteration> allIterations)
131+
IEnumerable<QuestIteration> allIterations,
132+
IEnumerable<LabelToTagMap> tagMap)
128133
{
129134
string areaPath = $"""{questClient.QuestProject}\{path}""";
130135

@@ -212,6 +217,17 @@ public static async Task<QuestWorkItem> CreateWorkItemAsync(QuestIssueOrPullRequ
212217
});
213218
}
214219

220+
var tags = issue.WorkItemTagsForIssue(tagMap);
221+
if (tags.Any())
222+
{
223+
string azDoTags = string.Join(";", tags);
224+
patchDocument.Add(new JsonPatchDocument
225+
{
226+
Operation = Op.Add,
227+
Path = "/fields/System.Tags",
228+
Value = azDoTags
229+
});
230+
}
215231
/* This is ignored by Azure DevOps. It uses the PAT of the
216232
* account running the code.
217233
var creator = await issue.AuthorMicrosoftPreferredName(ospoClient);
@@ -360,6 +376,9 @@ public static QuestWorkItem WorkItemFromJson(JsonElement root)
360376
(int)double.Truncate(storyPointNode.GetDouble()) : null;
361377
string? assignedID = (assignedNode.ValueKind is JsonValueKind.String) ?
362378
assignedNode.GetString() : null;
379+
string tagElement = fields.TryGetProperty("System.Tags", out JsonElement tagsNode) ?
380+
tagsNode.GetString()! : string.Empty;
381+
IEnumerable<string> tags = [..tagElement.Split(';').Select(s => s.Trim())];
363382
return new QuestWorkItem
364383
{
365384
Id = id,
@@ -371,7 +390,8 @@ public static QuestWorkItem WorkItemFromJson(JsonElement root)
371390
AreaPath = areaPath,
372391
IterationPath = iterationPath,
373392
AssignedToId = (assignedID is not null) ? new Guid(assignedID) : null,
374-
StoryPoints = storyPoints
393+
StoryPoints = storyPoints,
394+
Tags = tags
375395
};
376396
}
377397

actions/sequester/Quest2GitHub/Options/ImportOptions.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
public record struct ParentForLabel(string Label, int ParentNodeId);
44

5+
public record struct LabelToTagMap(string Label, string Tag);
6+
57
public sealed record class ImportOptions
68
{
79
/// <summary>
@@ -64,4 +66,13 @@ public sealed record class ImportOptions
6466
/// the default parent node is set for the work item.
6567
/// </remarks>
6668
public int DefaultParentNode { get; init; }
69+
70+
/// <summary>
71+
/// A map of GitHub labels to Azure DevOps tags.
72+
/// </summary>
73+
/// <remarks>
74+
/// If an issue has the matching label, add the corresponding tag to
75+
/// the mapped AzureDevOps item.
76+
/// </remarks>
77+
public List<LabelToTagMap> WorkItemTags { get; init; } = [];
6778
}

actions/sequester/Quest2GitHub/QuestGitHubService.cs

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ public class QuestGitHubService(
3737
string importTriggerLabelText,
3838
string importedLabelText,
3939
int defaultParentNode,
40-
List<ParentForLabel> parentNodes) : IDisposable
40+
List<ParentForLabel> parentNodes,
41+
IEnumerable<LabelToTagMap> tagMap) : IDisposable
4142
{
4243
private const string LinkedWorkItemComment = "Associated WorkItem - ";
4344
private readonly QuestClient _azdoClient = new(azdoKey, questOrg, questProject);
@@ -95,7 +96,7 @@ async Task ProcessItems(IAsyncEnumerable<QuestIssueOrPullRequest> items)
9596
{
9697
if (questItem != null)
9798
{
98-
await UpdateWorkItemAsync(questItem, item, _allIterations);
99+
await UpdateWorkItemAsync(questItem, item, _allIterations, tagMap);
99100
}
100101
else
101102
{
@@ -183,7 +184,7 @@ public async Task ProcessIssue(string gitHubOrganization, string gitHubRepositor
183184
{
184185
// This allows a human to force a manual update: just add the trigger label.
185186
// Note that it updates even if the item is closed.
186-
await UpdateWorkItemAsync(questItem, ghIssue, _allIterations);
187+
await UpdateWorkItemAsync(questItem, ghIssue, _allIterations, tagMap);
187188

188189
}
189190
// Next, if the item is already linked, consider any updates.
@@ -194,7 +195,7 @@ public async Task ProcessIssue(string gitHubOrganization, string gitHubRepositor
194195
}
195196
else if (sequestered && questItem is not null)
196197
{
197-
await UpdateWorkItemAsync(questItem, ghIssue, _allIterations);
198+
await UpdateWorkItemAsync(questItem, ghIssue, _allIterations, tagMap);
198199
}
199200
}
200201

@@ -244,15 +245,15 @@ private async Task<QuestIteration[]> RetrieveIterationLabelsAsync()
244245
return [.. iterations];
245246
}
246247

247-
248248
private async Task<QuestWorkItem?> LinkIssueAsync(QuestIssueOrPullRequest issueOrPullRequest, QuestIteration currentIteration, IEnumerable<QuestIteration> allIterations)
249249
{
250250
int? workItem = LinkedQuestId(issueOrPullRequest);
251251
int parentId = parentIdFromIssue(issueOrPullRequest);
252252
if (workItem is null)
253253
{
254254
// Create work item:
255-
QuestWorkItem questItem = await QuestWorkItem.CreateWorkItemAsync(issueOrPullRequest, parentId, _azdoClient, _ospoClient, areaPath, _importTriggerLabel?.Id, currentIteration, allIterations);
255+
QuestWorkItem questItem = await QuestWorkItem.CreateWorkItemAsync(issueOrPullRequest, parentId, _azdoClient, _ospoClient, areaPath,
256+
_importTriggerLabel?.Id, currentIteration, allIterations, tagMap);
256257

257258
string linkText = $"[{LinkedWorkItemComment}{questItem.Id}]({_questLinkString}{questItem.Id})";
258259
string updatedBody = $"""
@@ -291,7 +292,10 @@ private async Task RetrieveLabelIdsAsync(string org, string repo)
291292
}
292293
}
293294

294-
private async Task<QuestWorkItem?> UpdateWorkItemAsync(QuestWorkItem questItem, QuestIssueOrPullRequest ghIssue, IEnumerable<QuestIteration> allIterations)
295+
private async Task<QuestWorkItem?> UpdateWorkItemAsync(QuestWorkItem questItem,
296+
QuestIssueOrPullRequest ghIssue,
297+
IEnumerable<QuestIteration> allIterations,
298+
IEnumerable<LabelToTagMap> tagMap)
295299
{
296300
int parentId = parentIdFromIssue(ghIssue);
297301
string? ghAssigneeEmailAddress = await ghIssue.QueryAssignedMicrosoftEmailAddressAsync(_ospoClient);
@@ -392,6 +396,20 @@ private async Task RetrieveLabelIdsAsync(string org, string repo)
392396
Value = iterationSize.QuestStoryPoint(),
393397
});
394398
}
399+
var tags = from t in ghIssue.WorkItemTagsForIssue(tagMap)
400+
where !questItem.Tags.Contains(t)
401+
select t;
402+
if (tags.Any())
403+
{
404+
string azDoTags = string.Join(";", tags);
405+
patchDocument.Add(new JsonPatchDocument
406+
{
407+
Operation = Op.Add,
408+
Path = "/fields/System.Tags",
409+
Value = azDoTags
410+
});
411+
}
412+
395413
QuestWorkItem? newItem = default;
396414
if (patchDocument.Count != 0)
397415
{

0 commit comments

Comments
 (0)