Skip to content

Commit 8723d39

Browse files
authored
Add label to remove an item in Azure DevOps (#479)
* Add label to remove an item in Azure DevOps Fixes #416 Add a new label that sets the work item status of a linked item to "Removed". * Allow the default to be used
1 parent 7bfb011 commit 8723d39

File tree

5 files changed

+106
-59
lines changed

5 files changed

+106
-59
lines changed

DotNet.DocsTools/GitHubObjects/QuestIssueOrPullRequest.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ private protected QuestIssueOrPullRequest(JsonElement issueNode, string organiza
255255
UpdatedAt = ResponseExtractors.GetUpdatedAtValueOrNow(issueNode);
256256

257257
Assignees = [ ..ResponseExtractors.GetChildArrayElements(issueNode, "assignees", item =>
258-
Actor.FromJsonElement(item)).Where(actor => actor is not null)];
258+
Actor.FromJsonElement(item)).Where(actor => actor is not null)!];
259259

260260
Labels = ResponseExtractors.GetChildArrayElements(issueNode, "labels", item => GitHubLabel.FromJsonElement(item, default)!);
261261
Comments = [.. ResponseExtractors.GetChildArrayElements(issueNode, "comments", item =>
@@ -268,7 +268,7 @@ private protected QuestIssueOrPullRequest(JsonElement issueNode, string organiza
268268

269269
StoryPointSize?[] points = ResponseExtractors.GetChildArrayElements(issueNode, "projectItems", item =>
270270
StoryPointSize.OptionalFromJsonElement(item));
271-
ProjectStoryPoints = [ ..points.Where(p => p is not null).ToArray()];
271+
ProjectStoryPoints = [ ..points.Where(p => p is not null).ToArray()!];
272272

273273
// check state. If re-opened, don't reference the (not correct) closing PR
274274
if (includeTimeLine)

actions/sequester/ImportIssues/Program.cs

+1
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ private static async Task<QuestGitHubService> CreateService(ImportOptions option
117117
options.AzureDevOps.AreaPath,
118118
options.ImportTriggerLabel,
119119
options.ImportedLabel,
120+
options.UnlinkLabel,
120121
options.ParentNodes,
121122
options.WorkItemTags);
122123
}

actions/sequester/Quest2GitHub/Models/QuestWorkItem.cs

+54-15
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using DotNet.DocsTools.GitHubObjects;
22
using DotNet.DocsTools.Utility;
3-
using Microsoft.DotnetOrg.Ospo;
43

54
namespace Quest2GitHub.Models;
65

@@ -354,8 +353,7 @@ public static string BuildDescriptionFromIssue(QuestIssueOrPullRequest issue, st
354353
}
355354
}
356355

357-
static internal async Task<QuestWorkItem?> UpdateWorkItemAsync(QuestWorkItem questItem,
358-
QuestIssueOrPullRequest ghIssue,
356+
public async Task UpdateWorkItemAsync(QuestIssueOrPullRequest ghIssue,
359357
QuestClient questClient,
360358
OspoClient? ospoClient,
361359
WorkItemProperties issueProperties)
@@ -368,15 +366,15 @@ public static string BuildDescriptionFromIssue(QuestIssueOrPullRequest issue, st
368366
questAssigneeID = await questClient.GetIDFromEmail(ghAssigneeEmailAddress);
369367
}
370368
List<JsonPatchDocument> patchDocument = [];
371-
if (issueProperties.ParentNodeId != questItem.ParentWorkItemId)
369+
if (issueProperties.ParentNodeId != ParentWorkItemId)
372370
{
373-
if (questItem.ParentWorkItemId != 0)
371+
if (ParentWorkItemId != 0)
374372
{
375373
// Remove the existing parent relation.
376374
patchDocument.Add(new JsonPatchDocument
377375
{
378376
Operation = Op.Remove,
379-
Path = "/relations/" + questItem.ParentRelationIndex,
377+
Path = "/relations/" + ParentRelationIndex,
380378
});
381379
};
382380
if (issueProperties.ParentNodeId != 0)
@@ -401,7 +399,7 @@ public static string BuildDescriptionFromIssue(QuestIssueOrPullRequest issue, st
401399
});
402400
}
403401
}
404-
if ((questAssigneeID is not null) && (questAssigneeID?.Id != questItem.AssignedToId))
402+
if ((questAssigneeID is not null) && (questAssigneeID?.Id != AssignedToId))
405403
{
406404
// build patch document for assignment.
407405
JsonPatchDocument assignPatch = new()
@@ -413,7 +411,7 @@ public static string BuildDescriptionFromIssue(QuestIssueOrPullRequest issue, st
413411
patchDocument.Add(assignPatch);
414412
}
415413
Console.WriteLine(issueProperties.IssueLogString);
416-
if (issueProperties.WorkItemState != questItem.State)
414+
if (issueProperties.WorkItemState != State)
417415
{
418416
patchDocument.Add(new JsonPatchDocument
419417
{
@@ -422,7 +420,7 @@ public static string BuildDescriptionFromIssue(QuestIssueOrPullRequest issue, st
422420
Value = issueProperties.WorkItemState,
423421
});
424422
}
425-
if (issueProperties.IterationPath != questItem.IterationPath)
423+
if (issueProperties.IterationPath != IterationPath)
426424
{
427425
patchDocument.Add(new JsonPatchDocument
428426
{
@@ -431,7 +429,7 @@ public static string BuildDescriptionFromIssue(QuestIssueOrPullRequest issue, st
431429
Value = issueProperties.IterationPath,
432430
});
433431
}
434-
if (issueProperties.StoryPoints != (questItem.StoryPoints ?? 0))
432+
if (issueProperties.StoryPoints != (StoryPoints ?? 0))
435433
{
436434
patchDocument.Add(new JsonPatchDocument
437435
{
@@ -441,7 +439,7 @@ public static string BuildDescriptionFromIssue(QuestIssueOrPullRequest issue, st
441439
Value = issueProperties.StoryPoints,
442440
});
443441
}
444-
if (issueProperties.Priority != questItem.Priority)
442+
if (issueProperties.Priority != Priority)
445443
{
446444
patchDocument.Add(new JsonPatchDocument
447445
{
@@ -451,7 +449,7 @@ public static string BuildDescriptionFromIssue(QuestIssueOrPullRequest issue, st
451449
});
452450
}
453451
var tags = from t in issueProperties.Tags
454-
where !questItem.Tags.Contains(t)
452+
where !Tags.Contains(t)
455453
select t;
456454
if (tags.Any())
457455
{
@@ -475,16 +473,57 @@ public static string BuildDescriptionFromIssue(QuestIssueOrPullRequest issue, st
475473
From = default,
476474
Value = BuildDescriptionFromIssue(ghIssue, null)
477475
});
478-
JsonElement jsonDocument = await questClient.PatchWorkItem(questItem.Id, patchDocument);
476+
JsonElement jsonDocument = await questClient.PatchWorkItem(Id, patchDocument);
479477
newItem = WorkItemFromJson(jsonDocument);
480478
}
481479
if (!ghIssue.IsOpen && (ghIssue.ClosingPRUrl is not null))
482480
{
483-
newItem = await questItem.AddClosingPR(questClient, ghIssue.ClosingPRUrl) ?? newItem;
481+
newItem = await AddClosingPR(questClient, ghIssue.ClosingPRUrl) ?? newItem;
482+
}
483+
}
484+
485+
public async Task RemoveWorkItem(QuestIssueOrPullRequest ghIssue,
486+
QuestClient questClient,
487+
WorkItemProperties issueProperties)
488+
{
489+
List<JsonPatchDocument> patchDocument = [];
490+
patchDocument.Add(new JsonPatchDocument
491+
{
492+
Operation = Op.Add,
493+
Path = "/fields/System.State",
494+
Value = "Removed",
495+
});
496+
var tags = from t in issueProperties.Tags
497+
where !Tags.Contains(t)
498+
select t;
499+
if (tags.Any())
500+
{
501+
string azDoTags = string.Join(";", tags);
502+
patchDocument.Add(new JsonPatchDocument
503+
{
504+
Operation = Op.Add,
505+
Path = "/fields/System.Tags",
506+
Value = azDoTags
507+
});
508+
}
509+
510+
QuestWorkItem? newItem = default;
511+
if (patchDocument.Count != 0)
512+
{
513+
// If any updates are needed, add the description.
514+
patchDocument.Add(new JsonPatchDocument
515+
{
516+
Operation = Op.Add,
517+
Path = "/fields/System.Description",
518+
From = default,
519+
Value = BuildDescriptionFromIssue(ghIssue, null)
520+
});
521+
JsonElement jsonDocument = await questClient.PatchWorkItem(Id, patchDocument);
522+
newItem = WorkItemFromJson(jsonDocument);
484523
}
485-
return newItem;
486524
}
487525

526+
488527
/// <summary>
489528
/// Construct a work item from the JSON document.
490529
/// </summary>

actions/sequester/Quest2GitHub/Options/ImportOptions.cs

+14
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,20 @@ public sealed record class ImportOptions
4747
/// </remarks>
4848
public required string ImportedLabel { get; init; } = ":pushpin: seQUESTered";
4949

50+
/// <summary>
51+
/// The label used to indicate that a previously linked issue should be removed
52+
/// from Azure Devops. Defaults to <c>💣 vanQUEST</c>.
53+
/// </summary>
54+
/// Assign this from an environment variable with the following key, <c>ImportOptions__UnlinkLabel</c>:
55+
/// <code>
56+
/// env: # Defaults to '💣 vanQUEST'
57+
/// ImportOptions__ImportedLabel: ':smile: example'
58+
/// </code>
59+
/// If your label has an emoji in it, you must specify this using the GitHub emoji colon syntax:
60+
/// <a href="https://github.com/ikatyang/emoji-cheat-sheet"></a>
61+
/// </remarks>
62+
public string UnlinkLabel { get; init; } = ":bomb: vanQUEST";
63+
5064
/// <summary>
5165
/// The set of labels where specific parent nodes should be used.
5266
/// </summary>

actions/sequester/Quest2GitHub/QuestGitHubService.cs

+35-42
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Xml.XPath;
22
using DotNet.DocsTools.GitHubObjects;
33
using DotNet.DocsTools.GraphQLQueries;
4+
using Org.BouncyCastle.Asn1.Ocsp;
45

56
namespace Quest2GitHub;
67

@@ -22,6 +23,7 @@ namespace Quest2GitHub;
2223
/// <param name="areaPath">The area path for work items from this repo</param>
2324
/// <param name="importTriggerLabelText">The text of the label that triggers an import</param>
2425
/// <param name="importedLabelText">The text of the label that indicates an issue has been imported</param>
26+
/// <param name="removeLinkItemText">The text of the label that indicates an issue should be removed</param>
2527
/// <param name="parentNodes">A dictionary of label / parent ID pairs.</param>
2628
/// <remarks>
2729
/// The OAuth token takes precedence over the GitHub token, if both are
@@ -36,6 +38,7 @@ public class QuestGitHubService(
3638
string areaPath,
3739
string importTriggerLabelText,
3840
string importedLabelText,
41+
string removeLinkItemText,
3942
List<ParentForLabel> parentNodes,
4043
IEnumerable<LabelToTagMap> tagMap) : IDisposable
4144
{
@@ -46,6 +49,7 @@ public class QuestGitHubService(
4649

4750
private GitHubLabel? _importTriggerLabel;
4851
private GitHubLabel? _importedLabel;
52+
private GitHubLabel? _removeLinkedItemLabel;
4953
private QuestIteration[]? _allIterations;
5054

5155
/// <summary>
@@ -57,7 +61,7 @@ public class QuestGitHubService(
5761
/// <returns></returns>
5862
public async Task ProcessIssues(string organization, string repository, int duration)
5963
{
60-
if (_importTriggerLabel is null || _importedLabel is null)
64+
if (_importTriggerLabel is null || _importedLabel is null || _removeLinkedItemLabel is null)
6165
{
6266
await RetrieveLabelIdsAsync(organization, repository);
6367
}
@@ -97,23 +101,26 @@ async Task ProcessItems(IAsyncEnumerable<QuestIssueOrPullRequest> items)
97101
{
98102
await foreach (QuestIssueOrPullRequest item in items)
99103
{
100-
if (item.Labels.Any(l => (l.Id == _importTriggerLabel?.Id) || (l.Id == _importedLabel?.Id)))
104+
if (item.Labels.Any(l => (l.Id == _importTriggerLabel?.Id) || (l.Id == _importedLabel?.Id) || (l.Id == _removeLinkedItemLabel?.Id)))
101105
{
106+
bool request = item.Labels.Any(l => l.Id == _importTriggerLabel?.Id);
107+
bool sequestered = item.Labels.Any(l => l.Id == _importedLabel?.Id);
108+
bool vanquished = item.Labels.Any(l => l.Id == _removeLinkedItemLabel?.Id);
109+
// Only query AzDo if needed:
110+
QuestWorkItem? questItem = (request || sequestered || vanquished)
111+
? await FindLinkedWorkItemAsync(item)
112+
: null;
102113
var issueProperties = new WorkItemProperties(item, _allIterations, tagMap, parentNodes);
103114

104115
Console.WriteLine($"{item.Number}: {item.Title}, {issueProperties.IssueLogString}");
105-
// Console.WriteLine(item);
106-
QuestWorkItem? questItem = await FindLinkedWorkItemAsync(item);
107-
if (questItem != null)
116+
Task workDone = (request, sequestered, vanquished, questItem) switch
108117
{
109-
await QuestWorkItem.UpdateWorkItemAsync(questItem, item, _azdoClient, _ospoClient, issueProperties);
110-
}
111-
else
112-
{
113-
questItem = await LinkIssueAsync(item, issueProperties);
114-
// Because some fields can't be set in the initial creation, update the item.
115-
await QuestWorkItem.UpdateWorkItemAsync(questItem, item, _azdoClient, _ospoClient, issueProperties);
116-
}
118+
(false, false, false, _) => Task.CompletedTask, // No labels. Do nothing.
119+
(_, _, true, null) => Task.CompletedTask, // Unlink, but no link. Do nothing.
120+
(_, _, false, null) => LinkIssueAsync(item, issueProperties), // No link, but one of the link labels was applied.
121+
(_, _, true, not null) => questItem.RemoveWorkItem(item, _azdoClient, issueProperties), // Unlink.
122+
(_, _, false, not null) => questItem.UpdateWorkItemAsync(item, _azdoClient, _ospoClient, issueProperties), // update
123+
};
117124
totalImport++;
118125
}
119126
else
@@ -157,7 +164,7 @@ async IAsyncEnumerable<QuestIssueOrPullRequest> QueryAllOpenIssuesOrPullRequests
157164
/// <returns>A task representing the current operation</returns>
158165
public async Task ProcessIssue(string gitHubOrganization, string gitHubRepository, int issueNumber)
159166
{
160-
if (_importTriggerLabel is null || _importedLabel is null)
167+
if (_importTriggerLabel is null || _importedLabel is null || _removeLinkedItemLabel is null)
161168
{
162169
await RetrieveLabelIdsAsync(gitHubOrganization, gitHubRepository);
163170
}
@@ -183,41 +190,23 @@ public async Task ProcessIssue(string gitHubOrganization, string gitHubRepositor
183190
// Evaluate the labels to determine the right action.
184191
bool request = ghIssue.Labels.Any(l => l.Id == _importTriggerLabel?.Id);
185192
bool sequestered = ghIssue.Labels.Any(l => l.Id == _importedLabel?.Id);
193+
bool vanquished = ghIssue.Labels.Any(l => l.Id == _removeLinkedItemLabel?.Id);
186194
// Only query AzDo if needed:
187-
QuestWorkItem? questItem = (request || sequestered)
195+
QuestWorkItem? questItem = (request || sequestered || vanquished)
188196
? await FindLinkedWorkItemAsync(ghIssue)
189197
: null;
190198

191199
var issueProperties = new WorkItemProperties(ghIssue, _allIterations, tagMap, parentNodes);
192200

193-
// The order here is important to avoid a race condition that causes
194-
// an issue to be triggered multiple times.
195-
// First, if an issue is open and the trigger label is added, link or
196-
// update. Update is safe, because it will only update the quest issue's
197-
// state or assigned field. That can't trigger a new GH action run.
198-
if (request)
199-
{
200-
if (questItem is null)
201-
{
202-
questItem = await LinkIssueAsync(ghIssue, issueProperties);
203-
}
204-
else if (questItem is not null)
205-
{
206-
// This allows a human to force a manual update: just add the trigger label.
207-
// Note that it updates even if the item is closed.
208-
await QuestWorkItem.UpdateWorkItemAsync(questItem, ghIssue, _azdoClient, _ospoClient, issueProperties);
209-
210-
}
211-
// Next, if the item is already linked, consider any updates.
212-
// It's important that adding the linked label is the last
213-
// mutation done in the linking process. That way, the GH Action
214-
// does get triggered again. The second trigger will check for any updates
215-
// a human made to assigned or state while the initial run was taking place.
216-
}
217-
else if (sequestered && questItem is not null)
201+
Task workDone = (request, sequestered, vanquished, questItem) switch
218202
{
219-
await QuestWorkItem.UpdateWorkItemAsync(questItem, ghIssue, _azdoClient, _ospoClient, issueProperties);
220-
}
203+
(false, false, false, _) => Task.CompletedTask, // No labels. Do nothing.
204+
( _, _, true, null) => Task.CompletedTask, // Unlink, but no link. Do nothing.
205+
( _, _, false, null) => LinkIssueAsync(ghIssue, issueProperties), // No link, but one of the link labels was applied.
206+
( _, _, true, not null) => questItem.RemoveWorkItem(ghIssue, _azdoClient, issueProperties), // Unlink.
207+
( _, _, false, not null) => questItem.UpdateWorkItemAsync(ghIssue, _azdoClient, _ospoClient, issueProperties), // update
208+
};
209+
await workDone;
221210
}
222211

223212
/// <summary>
@@ -322,6 +311,9 @@ private async Task<QuestWorkItem> LinkIssueAsync(QuestIssueOrPullRequest issueOr
322311
var prMutation = new Mutation<SequesteredPullRequestMutation, SequesterVariables>(ghClient);
323312
await prMutation.PerformMutation(new SequesterVariables(pr.Id, _importTriggerLabel?.Id ?? "", _importedLabel?.Id ?? "", updatedBody));
324313
}
314+
315+
// Because some fields can't be set when an item is created, go through an update cycle:
316+
await questItem.UpdateWorkItemAsync(issueOrPullRequest, _azdoClient, _ospoClient, issueProperties);
325317
return questItem;
326318
}
327319
else
@@ -338,6 +330,7 @@ private async Task RetrieveLabelIdsAsync(string org, string repo)
338330
{
339331
if (label.Name == importTriggerLabelText) _importTriggerLabel = label;
340332
if (label.Name == importedLabelText) _importedLabel = label;
333+
if (label.Name == removeLinkItemText) _removeLinkedItemLabel = label;
341334
}
342335
}
343336

0 commit comments

Comments
 (0)