diff --git a/src/DotNet.Status.Web/DotNet.Status.Web/Controllers/AlertHookController.cs b/src/DotNet.Status.Web/DotNet.Status.Web/Controllers/AlertHookController.cs index f9a9f1dec..340127e23 100644 --- a/src/DotNet.Status.Web/DotNet.Status.Web/Controllers/AlertHookController.cs +++ b/src/DotNet.Status.Web/DotNet.Status.Web/Controllers/AlertHookController.cs @@ -10,10 +10,10 @@ using DotNet.Status.Web.Models; using DotNet.Status.Web.Options; using Microsoft.AspNetCore.Mvc; -using Microsoft.DotNet.GitHub.Authentication; +using Microsoft.DotNet.Internal.AzureDevOps; +using Microsoft.DotNet.Internal.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Octokit; namespace DotNet.Status.Web.Controllers; @@ -21,29 +21,23 @@ namespace DotNet.Status.Web.Controllers; [Route("api/alert")] public class AlertHookController : ControllerBase { - public const string NotificationIdLabel = "Grafana Alert"; - public const string ActiveAlertLabel = "Active Alert"; - public const string InactiveAlertLabel = "Inactive Alert"; + public const string NotificationIdTag = "Grafana Alert"; + public const string ActiveAlertTag = "Active Alert"; + public const string InactiveAlertTag = "Inactive Alert"; public const string BodyLabelTextFormat = "Grafana-Automated-Alert-Id-{0}"; public const string NotificationTagName = "NotificationId"; - private static bool s_labelsCreated; - private static readonly SemaphoreSlim s_labelLock = new SemaphoreSlim(1); - - private readonly IOptions _githubOptions; - private readonly IOptions _githubClientOptions; - private readonly ILogger _logger; - private readonly IGitHubTokenProvider _tokenProvider; + private readonly IOptions _azureDevOpsOptions; + private readonly IClientFactory _azureDevOpsClientFactory; + private readonly ILogger _logger; public AlertHookController( - IGitHubTokenProvider tokenProvider, - IOptions githubOptions, - IOptions githubClientOptions, + IClientFactory azureDevOpsClientFactory, + IOptions azureDevOpsOptions, ILogger logger) { - _tokenProvider = tokenProvider; - _githubOptions = githubOptions; - _githubClientOptions = githubClientOptions; + _azureDevOpsClientFactory = azureDevOpsClientFactory; + _azureDevOpsOptions = azureDevOpsOptions; _logger = logger; } @@ -68,136 +62,161 @@ public async Task NotifyAsync(GrafanaNotification notification) private async Task OpenNewNotificationAsync(GrafanaNotification notification) { - string org = _githubOptions.Value.Organization; - string repo = _githubOptions.Value.Repository; + string organization = _azureDevOpsOptions.Value.Organization; + string project = _azureDevOpsOptions.Value.Project; _logger.LogInformation( - "Alert state detected for {ruleUrl} in stage {ruleState}, porting to github repo {org}/{repo}", + "Alert state detected for {ruleUrl} in stage {ruleState}, porting to Azure DevOps project {organization}/{project}", notification.RuleUrl, notification.State, - org, - repo); - - IGitHubClient client = await GetGitHubClientAsync(_githubOptions.Value.Organization, _githubOptions.Value.Repository); - Issue issue = await GetExistingIssueAsync(client, notification); - await EnsureLabelsAsync(client, org, repo); - if (issue == null) + organization, + project); + + using Reference clientRef = GetAzureDevOpsClient(); + IAzureDevOpsClient client = clientRef.Value; + + WorkItem? existingWorkItem = await GetExistingWorkItemAsync(client, notification); + + if (existingWorkItem == null) { - _logger.LogInformation("No existing issue found, creating new active issue with {label}", - ActiveAlertLabel); - issue = await client.Issue.Create(org, repo, GenerateNewIssue(notification)); - _logger.LogInformation("Github issue {org}/{repo}#{issueNumber} created", org, repo, issue.Number); + _logger.LogInformation("No existing work item found, creating new active work item with tag {tag}", + ActiveAlertTag); + + string title = GenerateWorkItemTitle(notification); + string description = GenerateWorkItemDescription(notification); + string[] tags = GenerateWorkItemTags(notification, true); + + WorkItem? workItem = await client.CreateAlertWorkItem(project, title, description, tags, CancellationToken.None); + _logger.LogInformation("Azure DevOps work item {workItemId} created in project {project}", + workItem?.Id, project); } else { _logger.LogInformation( - "Found existing issue {org}/{repo}#{issueNumber}, replacing {inactiveTag} with {activeTag}", - org, - repo, - issue.Number, - InactiveAlertLabel, - ActiveAlertLabel); - - await GitHubModifications.TryRemoveAsync(() => client.Issue.Labels.RemoveFromIssue(org, repo, issue.Number, InactiveAlertLabel), _logger); - await GitHubModifications.TryCreateAsync(() => - client.Issue.Labels.AddToIssue(org, repo, issue.Number, new[] {ActiveAlertLabel}), - _logger); - - _logger.LogInformation("Adding recurrence comment to {org}/{repo}#{issueNumber}", - org, - repo, - issue.Number); - IssueComment comment = await client.Issue.Comment.Create(org, - repo, - issue.Number, - GenerateNewNotificationComment(notification)); - _logger.LogInformation("Created comment {org}/{repo}#{issue}-issuecomment-{comment}", - org, - repo, - issue.Id, - comment.Id); + "Found existing work item {workItemId}, replacing {inactiveTag} with {activeTag}", + existingWorkItem.Id, + InactiveAlertTag, + ActiveAlertTag); + + await client.UpdateWorkItemTags(project, existingWorkItem.Id, + new[] { ActiveAlertTag }, + new[] { InactiveAlertTag }, + CancellationToken.None); + + _logger.LogInformation("Adding recurrence comment to work item {workItemId}", + existingWorkItem.Id); + + string comment = GenerateNewNotificationComment(notification); + await client.AddWorkItemComment(project, existingWorkItem.Id, comment, CancellationToken.None); + + _logger.LogInformation("Created comment on work item {workItemId}", existingWorkItem.Id); } } private string GenerateNewNotificationComment(GrafanaNotification notification) { - var metricText = new StringBuilder(); + StringBuilder metricText = new StringBuilder(); foreach (GrafanaNotificationMatch match in notification.EvalMatches) { - metricText.AppendLine($" - *{match.Metric}* {match.Value}"); + metricText.AppendLine($" - {match.Metric} {match.Value}"); } string icon = GetIcon(notification); - string image = !string.IsNullOrEmpty(notification.ImageUrl) ? $"![Metric Graph]({notification.ImageUrl})" : string.Empty; + string image = string.Empty; + if (!string.IsNullOrEmpty(notification.ImageUrl)) + { + image = $"\"Metric"; + } - return $@":{icon}: Metric state changed to *{notification.State}* + return $@":{icon}: Metric state changed to {notification.State} -> {notification.Message?.Replace("\n", "\n> ")} +{notification.Message?.Replace("\n", "\n")} {metricText} {image} -[Go to rule]({notification.RuleUrl})".Replace("\r\n","\n"); +Go to rule".Replace("\r\n","\n"); } - private NewIssue GenerateNewIssue(GrafanaNotification notification) + private string GenerateWorkItemTitle(GrafanaNotification notification) { - var metricText = new StringBuilder(); - foreach (GrafanaNotificationMatch match in notification.EvalMatches) - { - metricText.AppendLine($" - *{match.Metric}* {match.Value}"); - } - - string icon = GetIcon(notification); - string image = !string.IsNullOrEmpty(notification.ImageUrl) ? $"![Metric Graph]({notification.ImageUrl})" : string.Empty; - string issueTitle = notification.Title; - GitHubConnectionOptions options = _githubOptions.Value; + AzureDevOpsAlertOptions options = _azureDevOpsOptions.Value; string prefix = options.TitlePrefix; if (prefix != null) { issueTitle = prefix + issueTitle; } - var issue = new NewIssue(issueTitle) + return issueTitle; + } + + private string GenerateWorkItemDescription(GrafanaNotification notification) + { + StringBuilder metricText = new StringBuilder(); + foreach (GrafanaNotificationMatch match in notification.EvalMatches) { - Body = $@":{icon}: Metric state changed to *{notification.State}* + metricText.AppendLine($" - {match.Metric} {match.Value}"); + } + + string icon = GetIcon(notification); + string image = string.Empty; + if (!string.IsNullOrEmpty(notification.ImageUrl)) + { + image = $"\"Metric"; + } + + AzureDevOpsAlertOptions options = _azureDevOpsOptions.Value; + + string notificationTargets = string.Empty; + if (options.NotificationTargets != null && options.NotificationTargets.Length > 0) + { + notificationTargets = $"{string.Join(", ", options.NotificationTargets.Select(target => $"@{target}"))}, please investigate"; + } + + return $@":{icon}: Metric state changed to {notification.State} -> {notification.Message?.Replace("\n", "\n> ")} +{notification.Message?.Replace("\n", "\n")} {metricText} {image} -[Go to rule]({notification.RuleUrl}) +Go to rule -{string.Join(", ", options.NotificationTargets.Select(target => $"@{target}"))}, please investigate +{notificationTargets} {options.SupplementalBodyText} -
-Automation information below, do not change +
+Automation information below, do not change {string.Format(BodyLabelTextFormat, GetUniqueIdentifier(notification))} +
+".Replace("\r\n","\n"); + } -
-".Replace("\r\n","\n") - }; - - issue.Labels.Add(NotificationIdLabel); - issue.Labels.Add(ActiveAlertLabel); - foreach (string label in options.AlertLabels.OrEmpty()) + private string[] GenerateWorkItemTags(GrafanaNotification notification, bool isActive) + { + List tags = new List(); + + tags.Add(NotificationIdTag); + tags.Add(isActive ? ActiveAlertTag : InactiveAlertTag); + + AzureDevOpsAlertOptions options = _azureDevOpsOptions.Value; + + if (options.AlertTags != null) { - issue.Labels.Add(label); + tags.AddRange(options.AlertTags); } - foreach (string label in options.EnvironmentLabels.OrEmpty()) + if (options.EnvironmentTags != null) { - issue.Labels.Add(label); + tags.AddRange(options.EnvironmentTags); } - return issue; + return tags.ToArray(); } private static string GetIcon(GrafanaNotification notification) @@ -225,104 +244,88 @@ private static string GetIcon(GrafanaNotification notification) return icon; } - private async Task EnsureLabelsAsync(IGitHubClient client, string org, string repo) - { - // Assume someone didn't delete the labels, it's an expensive call to make every time - if (s_labelsCreated) - { - return; - } - - await s_labelLock.WaitAsync(); - try - { - if (s_labelsCreated) - { - return; - } - - var desiredLabels = new[] - { - new NewLabel(NotificationIdLabel, "f957b6"), - new NewLabel(ActiveAlertLabel, "d73a4a"), - new NewLabel(InactiveAlertLabel, "e4e669"), - }; - - await GitHubModifications.CreateLabelsAsync(client, org, repo, _logger, desiredLabels); - - s_labelsCreated = true; - } - finally - { - s_labelLock.Release(); - } - } - private async Task CloseExistingNotificationAsync(GrafanaNotification notification) { - string org = _githubOptions.Value.Organization; - string repo = _githubOptions.Value.Repository; - IGitHubClient client = await GetGitHubClientAsync(org, repo); - Issue issue = await GetExistingIssueAsync(client, notification); - if (issue == null) + string organization = _azureDevOpsOptions.Value.Organization; + string project = _azureDevOpsOptions.Value.Project; + + using Reference clientRef = GetAzureDevOpsClient(); + IAzureDevOpsClient client = clientRef.Value; + + WorkItem? workItem = await GetExistingWorkItemAsync(client, notification); + if (workItem == null) { - _logger.LogInformation("No active issue found for alert '{ruleName}', ignoring", notification.RuleName); + _logger.LogInformation("No active work item found for alert '{ruleName}', ignoring", notification.RuleName); return; } _logger.LogInformation( - "Found existing issue {org}/{repo}#{issueNumber}, replacing {activeTag} with {inactiveTag}", - org, - repo, - issue.Number, - ActiveAlertLabel, - InactiveAlertLabel); - - await GitHubModifications.TryRemoveAsync(() => client.Issue.Labels.RemoveFromIssue(org, repo, issue.Number, ActiveAlertLabel), _logger); - await GitHubModifications.TryCreateAsync(() => - client.Issue.Labels.AddToIssue(org, repo, issue.Number, new[] {InactiveAlertLabel}), - _logger); - - _logger.LogInformation("Adding recurrence comment to {org}/{repo}#{issueNumber}", - org, - repo, - issue.Number); - IssueComment comment = await client.Issue.Comment.Create(org, - repo, - issue.Number, - GenerateNewNotificationComment(notification)); - _logger.LogInformation("Created comment {org}/{repo}#{issue}-issuecomment-{comment}", - org, - repo, - issue.Id, - comment.Id); + "Found existing work item {workItemId}, replacing {activeTag} with {inactiveTag}", + workItem.Id, + ActiveAlertTag, + InactiveAlertTag); + + await client.UpdateWorkItemTags(project, workItem.Id, + new[] { InactiveAlertTag }, + new[] { ActiveAlertTag }, + CancellationToken.None); + + _logger.LogInformation("Adding recurrence comment to work item {workItemId}", + workItem.Id); + + string comment = GenerateNewNotificationComment(notification); + await client.AddWorkItemComment(project, workItem.Id, comment, CancellationToken.None); + + _logger.LogInformation("Created comment on work item {workItemId}", workItem.Id); } - private async Task GetExistingIssueAsync(IGitHubClient client, GrafanaNotification notification) + private async Task GetExistingWorkItemAsync(IAzureDevOpsClient client, GrafanaNotification notification) { string id = GetUniqueIdentifier(notification); + string project = _azureDevOpsOptions.Value.Project; - var searchedLabels = new List + string searchTag = NotificationIdTag; + + WorkItem[]? workItems = await client.QueryWorkItemsByTag(project, searchTag, CancellationToken.None); + + if (workItems == null || workItems.Length == 0) { - NotificationIdLabel - }; - - searchedLabels.AddRange(_githubOptions.Value.EnvironmentLabels.OrEmpty()); + return null; + } string automationId = string.Format(BodyLabelTextFormat, id); - var request = new SearchIssuesRequest(automationId) + + foreach (WorkItem workItem in workItems) { - Labels = searchedLabels, - Order = SortDirection.Descending, - SortField = IssueSearchSort.Created, - Type = IssueTypeQualifier.Issue, - In = new[] {IssueInQualifier.Body}, - State = ItemState.Open, - }; - - SearchIssuesResult issues = await client.Search.SearchIssues(request); + if (workItem.Fields.TryGetValue("System.Description", out object? descriptionObj)) + { + string description = descriptionObj?.ToString() ?? string.Empty; + if (description.Contains(automationId)) + { + AzureDevOpsAlertOptions options = _azureDevOpsOptions.Value; + if (options.EnvironmentTags != null && options.EnvironmentTags.Length > 0) + { + if (workItem.Fields.TryGetValue("System.Tags", out object? tagsObj)) + { + string tags = tagsObj?.ToString() ?? string.Empty; + bool hasAllEnvironmentTags = options.EnvironmentTags.All(envTag => + tags.Split(new[] { "; " }, System.StringSplitOptions.RemoveEmptyEntries).Contains(envTag)); + + if (hasAllEnvironmentTags) + { + return workItem; + } + } + } + else + { + return workItem; + } + } + } + } - return issues.Items.FirstOrDefault(); + return null; } private static string GetUniqueIdentifier(GrafanaNotification notification) @@ -336,11 +339,10 @@ private static string GetUniqueIdentifier(GrafanaNotification notification) return notification.RuleId.ToString(); } - private async Task GetGitHubClientAsync(string org, string repo) + private Reference GetAzureDevOpsClient() { - return new GitHubClient(_githubClientOptions.Value.ProductHeader) - { - Credentials = new Credentials(await _tokenProvider.GetTokenForRepository(org, repo)) - }; + string organization = _azureDevOpsOptions.Value.Organization; + _logger.LogInformation("Getting AzureDevOpsClient for org {organization}", organization); + return _azureDevOpsClientFactory.GetClient($"alert/{organization}"); } } diff --git a/src/DotNet.Status.Web/DotNet.Status.Web/Options/AzureDevOpsAlertOptions.cs b/src/DotNet.Status.Web/DotNet.Status.Web/Options/AzureDevOpsAlertOptions.cs new file mode 100644 index 000000000..6e71eaf82 --- /dev/null +++ b/src/DotNet.Status.Web/DotNet.Status.Web/Options/AzureDevOpsAlertOptions.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace DotNet.Status.Web.Options; + +public class AzureDevOpsAlertOptions +{ + public string Organization { get; set; } + public string Project { get; set; } + public string[] NotificationTargets { get; set; } + public string[] AlertTags { get; set; } + public string[] EnvironmentTags { get; set; } + public string TitlePrefix { get; set; } + public string SupplementalBodyText { get; set; } +} diff --git a/src/DotNet.Status.Web/DotNet.Status.Web/Startup.cs b/src/DotNet.Status.Web/DotNet.Status.Web/Startup.cs index 9c3fff381..f41103761 100644 --- a/src/DotNet.Status.Web/DotNet.Status.Web/Startup.cs +++ b/src/DotNet.Status.Web/DotNet.Status.Web/Startup.cs @@ -96,6 +96,24 @@ private void ConfigureConfiguration(IServiceCollection services) services.Configure(Configuration.GetSection("GitHubAppAuth")); services.Configure("dnceng", Configuration.GetSection("AzureDevOps:dnceng")); services.Configure("build-monitor/dnceng", Configuration.GetSection("AzureDevOps:build-monitor/dnceng")); + services.Configure(Configuration.GetSection("AzureDevOpsAlert")); + + // Configure AzureDevOpsClientOptions for alert system based on AzureDevOpsAlert configuration + services.Configure("alert/dnceng", (AzureDevOpsClientOptions options) => + { + IConfigurationSection alertSection = Configuration.GetSection("AzureDevOpsAlert"); + IConfigurationSection dncengSection = Configuration.GetSection("AzureDevOps:dnceng"); + + string organization = alertSection.GetValue("Organization"); + options.Organization = !string.IsNullOrEmpty(organization) ? organization : "dnceng"; + + string accessToken = dncengSection.GetValue("AccessToken"); + options.AccessToken = accessToken ?? string.Empty; + + int maxParallelRequests = dncengSection.GetValue("MaxParallelRequests"); + options.MaxParallelRequests = maxParallelRequests > 0 ? maxParallelRequests : 4; + }); + services.Configure(Configuration.GetSection("BuildMonitor")); services.Configure(Configuration.GetSection("Kusto")); services.Configure(Configuration.GetSection("Rca")); diff --git a/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.cs b/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.cs index 7c23b6b6e..6d0149319 100644 --- a/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.cs +++ b/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.cs @@ -163,6 +163,135 @@ private async Task GetTimelineRaw(string project, int buildId, string id return JsonConvert.DeserializeObject(json); } + public async Task CreateAlertWorkItem(string project, string title, string description, string[] tags, CancellationToken cancellationToken) + { + Dictionary fields = new Dictionary(); + fields.Add("System.Title", title); + fields.Add("System.Description", description); + + if (tags != null && tags.Length > 0) + { + fields.Add("System.Tags", string.Join("; ", tags)); + } + + string json = await CreateWorkItem(project, "Issue", fields, cancellationToken); + return JsonConvert.DeserializeObject(json); + } + + public async Task UpdateWorkItemTags(string project, int workItemId, string[] tagsToAdd, string[] tagsToRemove, CancellationToken cancellationToken) + { + StringBuilder builder = GetProjectApiRootBuilder(project); + builder.Append($"wit/workitems/{workItemId}?api-version=6.0"); + + WorkItem? existingWorkItem = await GetWorkItem(project, workItemId, cancellationToken); + if (existingWorkItem == null) + { + return null; + } + + string existingTags = existingWorkItem.Fields.TryGetValue("System.Tags", out object? tagsObj) + ? tagsObj?.ToString() ?? string.Empty + : string.Empty; + + HashSet tagSet = new HashSet( + existingTags.Split(new[] { "; " }, StringSplitOptions.RemoveEmptyEntries) + ); + + if (tagsToRemove != null) + { + foreach (string tag in tagsToRemove) + { + tagSet.Remove(tag); + } + } + + if (tagsToAdd != null) + { + foreach (string tag in tagsToAdd) + { + tagSet.Add(tag); + } + } + + List patchDocuments = new List(); + JsonPatchDocument tagsUpdate = new JsonPatchDocument() + { + From = null, + Op = "add", + Path = "/fields/System.Tags", + Value = string.Join("; ", tagSet) + }; + patchDocuments.Add(tagsUpdate); + + string body = JsonConvert.SerializeObject(patchDocuments); + string json = (await PatchJsonResult(builder.ToString(), body, cancellationToken)).Body; + return JsonConvert.DeserializeObject(json); + } + + public async Task AddWorkItemComment(string project, int workItemId, string comment, CancellationToken cancellationToken) + { + StringBuilder builder = GetProjectApiRootBuilder(project); + builder.Append($"wit/workitems/{workItemId}/comments?api-version=6.0-preview"); + + JObject commentBody = new JObject + { + ["text"] = comment + }; + + string body = JsonConvert.SerializeObject(commentBody); + string json = (await PostJsonResult(builder.ToString(), body, cancellationToken)).Body; + return json; + } + + public async Task QueryWorkItemsByTag(string project, string tag, CancellationToken cancellationToken) + { + StringBuilder builder = GetProjectApiRootBuilder(project); + builder.Append("wit/wiql?api-version=6.0"); + + string wiql = $@"SELECT [System.Id] FROM WorkItems WHERE [System.Tags] CONTAINS '{tag.Replace("'", "''")}' AND [System.State] <> 'Closed' AND [System.State] <> 'Resolved'"; + + JObject queryBody = new JObject + { + ["query"] = wiql + }; + + string body = JsonConvert.SerializeObject(queryBody); + string json = (await PostJsonResult(builder.ToString(), body, cancellationToken)).Body; + + JObject result = JObject.Parse(json); + JArray? workItemRefs = (JArray?)result["workItems"]; + + if (workItemRefs == null || workItemRefs.Count == 0) + { + return Array.Empty(); + } + + List workItems = new List(); + foreach (JToken workItemRef in workItemRefs) + { + int id = workItemRef["id"]?.Value() ?? 0; + if (id > 0) + { + WorkItem? workItem = await GetWorkItem(project, id, cancellationToken); + if (workItem != null) + { + workItems.Add(workItem); + } + } + } + + return workItems.ToArray(); + } + + private async Task GetWorkItem(string project, int workItemId, CancellationToken cancellationToken) + { + StringBuilder builder = GetProjectApiRootBuilder(project); + builder.Append($"wit/workitems/{workItemId}?api-version=6.0"); + + JsonResult result = await GetJsonResult(builder.ToString(), cancellationToken); + return JsonConvert.DeserializeObject(result.Body); + } + /// /// The method reads the logs as a stream, line by line and tries to match the regexes in order, one regex per line. /// If the consecutive regexes match the lines, the last match is returned. @@ -366,6 +495,47 @@ private async Task PostJsonResult(string uri, string body, Cancellat var result = await GetJsonResult(changeUrl, cancellationToken); return JsonConvert.DeserializeObject(result.Body); } + + private async Task PatchJsonResult(string uri, string body, CancellationToken cancellationToken) + { + await _parallelism.WaitAsync(cancellationToken); + try + { + int retry = 5; + while (true) + { + try + { + HttpRequestMessage request = new HttpRequestMessage(new HttpMethod("PATCH"), uri); + request.Content = new StringContent(body, Encoding.UTF8, "application/json-patch+json"); + + using (HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken)) + { + response.EnsureSuccessStatusCode(); + string responseBody = await response.Content.ReadAsStringAsync(); + response.Headers.TryGetValues("x-ms-continuationtoken", + out IEnumerable? continuationTokenHeaders); + string? continuationToken = continuationTokenHeaders?.FirstOrDefault(); + JsonResult result = new JsonResult(responseBody, continuationToken); + + return result; + } + } + catch (OperationCanceledException e) when (e.CancellationToken == cancellationToken) + { + throw; + } + catch (Exception) when (retry-- > 0) + { + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } + } + } + finally + { + _parallelism.Release(); + } + } } public class BuildChangeDetail diff --git a/src/Telemetry/AzureDevOpsClient/IAzureDevOpsClient.cs b/src/Telemetry/AzureDevOpsClient/IAzureDevOpsClient.cs index 853d2a75f..10b6f1c75 100644 --- a/src/Telemetry/AzureDevOpsClient/IAzureDevOpsClient.cs +++ b/src/Telemetry/AzureDevOpsClient/IAzureDevOpsClient.cs @@ -24,6 +24,10 @@ public interface IAzureDevOpsClient public Task GetTimelineAsync(string project, int buildId, string timelineId, CancellationToken cancellationToken); public Task GetChangeDetails(string changeUrl, CancellationToken cancellationToken = default); public Task CreateRcaWorkItem(string project, string title, CancellationToken cancellationToken = default); + public Task CreateAlertWorkItem(string project, string title, string description, string[] tags, CancellationToken cancellationToken = default); + public Task UpdateWorkItemTags(string project, int workItemId, string[] tagsToAdd, string[] tagsToRemove, CancellationToken cancellationToken = default); + public Task AddWorkItemComment(string project, int workItemId, string comment, CancellationToken cancellationToken = default); + public Task QueryWorkItemsByTag(string project, string tag, CancellationToken cancellationToken = default); public Task MatchLogLineSequence(string logUri, IReadOnlyList regexes, CancellationToken cancellationToken = default); public Task GetProjectNameAsync(string id); } diff --git a/src/Telemetry/Microsoft.DotNet.AzureDevOpsTimelineTests/MockAzureClient.cs b/src/Telemetry/Microsoft.DotNet.AzureDevOpsTimelineTests/MockAzureClient.cs index ba8395f1e..ba50c0d52 100644 --- a/src/Telemetry/Microsoft.DotNet.AzureDevOpsTimelineTests/MockAzureClient.cs +++ b/src/Telemetry/Microsoft.DotNet.AzureDevOpsTimelineTests/MockAzureClient.cs @@ -129,6 +129,26 @@ public Task ListBuilds(string project, CancellationToken cancellationTo throw new NotImplementedException(); } + public Task CreateAlertWorkItem(string project, string title, string description, string[] tags, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task UpdateWorkItemTags(string project, int workItemId, string[] tagsToAdd, string[] tagsToRemove, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task AddWorkItemComment(string project, int workItemId, string comment, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task QueryWorkItemsByTag(string project, string tag, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + public Task MatchLogLineSequence(string logUri, IReadOnlyList regexes, CancellationToken cancellationToken) { return Task.FromResult(_urlDictionary.GetOrDefault(logUri, null)); diff --git a/src/Telemetry/Microsoft.DotNet.AzureDevOpsTimelineTests/MockTimeoutAzureClient.cs b/src/Telemetry/Microsoft.DotNet.AzureDevOpsTimelineTests/MockTimeoutAzureClient.cs index 3e129390d..f2fc2f494 100644 --- a/src/Telemetry/Microsoft.DotNet.AzureDevOpsTimelineTests/MockTimeoutAzureClient.cs +++ b/src/Telemetry/Microsoft.DotNet.AzureDevOpsTimelineTests/MockTimeoutAzureClient.cs @@ -28,6 +28,26 @@ public MockTimeoutAzureClient(Dictionary> builds, HttpMess throw new NotImplementedException(); } + public Task CreateAlertWorkItem(string project, string title, string description, string[] tags, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task UpdateWorkItemTags(string project, int workItemId, string[] tagsToAdd, string[] tagsToRemove, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task AddWorkItemComment(string project, int workItemId, string comment, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task QueryWorkItemsByTag(string project, string tag, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + public Task GetBuildAsync(string project, long buildId, CancellationToken cancellationToken = default) { throw new NotImplementedException();