diff --git a/.gitignore b/.gitignore index 4931dcb5c..e7bcc3d1c 100644 --- a/.gitignore +++ b/.gitignore @@ -361,3 +361,4 @@ MigrationBackup/ # some ide stuff .idea +.nuget/nuget.exe diff --git a/src/DotNet.Status.Web/DotNet.Status.Web.Tests/AzurePipelinesControllerTests.cs b/src/DotNet.Status.Web/DotNet.Status.Web.Tests/AzurePipelinesControllerTests.cs index 83ceb8ef4..a2a64c6ef 100644 --- a/src/DotNet.Status.Web/DotNet.Status.Web.Tests/AzurePipelinesControllerTests.cs +++ b/src/DotNet.Status.Web/DotNet.Status.Web.Tests/AzurePipelinesControllerTests.cs @@ -551,6 +551,80 @@ public async Task BuildCompleteUpdateExistingIssueDoesNotExist() VerifyGitHubCalls(testData, expectedIssueOwners, expectedIssueNames, expectedCommentOwners, expectedCommentNames); } + [Test] + public async Task BuildCompleteCreateAzureDevOpsWorkItem() + { + var buildEvent = new AzurePipelinesController.AzureDevOpsEvent + { + Resource = new AzurePipelinesController.AzureDevOpsMinimalBuildResource + { + Id = 123456, + Url = "test-build-url" + }, + ResourceContainers = new AzurePipelinesController.AzureDevOpsResourceContainers + { + Collection = new AzurePipelinesController.HasId + { + Id = "test-collection-id" + }, + Account = new AzurePipelinesController.HasId + { + Id = "test-account-id" + }, + Project = new AzurePipelinesController.HasId + { + Id = "test-project-id" + } + } + }; + + var build = new JObject + { + ["_links"] = new JObject + { + ["web"] = new JObject + { + ["href"] = "href" + } + }, + ["buildNumber"] = "123456", + ["definition"] = new JObject + { + ["name"] = "path5", + ["path"] = "\\test\\definition" + }, + ["finishTime"] = "05/01/2008 6:00:00", + ["id"] = "123", + ["project"] = new JObject + { + ["name"] = "test-project-name" + }, + ["reason"] = "batchedCI", + ["requestedFor"] = new JObject + { + ["displayName"] = "requested-for" + }, + ["result"] = "failed", + ["sourceBranch"] = "refs/heads/sourceBranch", + ["startTime"] = "05/01/2008 5:00:00", + }; + + await using TestData testData = await TestData.AzureDevOps.WithBuildData(build).BuildAsync(); + var response = await testData.Controller.BuildComplete(buildEvent); + + // Verify Azure DevOps work item creation was called + testData.GitHubCalls.AzureDevOpsClient.Verify( + m => m.CreateBuildFailureWorkItem( + "test-ado-project", + "test\\area\\path", + It.IsAny(), + It.IsAny(), + "test-assignee", + It.IsAny(), + It.IsAny()), + Times.Once); + } + [TestDependencyInjectionSetup] public static class TestDataConfiguration { @@ -598,6 +672,14 @@ public static void Default(IServiceCollection collection) DefinitionPath = "\\test\\definition\\path4", Branches = new string[] { "main", "release/*" }, IssuesId = "third-issues" + }, + new BuildMonitorOptions.AzurePipelinesOptions.BuildDescription + { + Project = "test-project-name", + DefinitionPath = "\\test\\definition\\path5", + Branches = new string[] { "sourceBranch" }, + Assignee = "test-assignee", + IssuesId = "azuredevops-issues" } } }; @@ -625,6 +707,52 @@ public static void Default(IServiceCollection collection) Name = "repo", Labels = new string[] { "label2" }, UpdateExisting = true + }, + new BuildMonitorOptions.IssuesOptions + { + Id = "azuredevops-issues", + UseAzureDevOps = true, + AzureDevOpsProject = "test-ado-project", + AzureDevOpsAreaPath = "test\\area\\path", + Labels = new string[] { "build-failure" } + } + }; + } + ); + } + + public static void AzureDevOps(IServiceCollection collection) + { + collection.AddOptions(); + collection.AddLogging(l => { l.AddProvider(new NUnitLogger()); }); + + collection.Configure( + options => + { + options.Monitor = new BuildMonitorOptions.AzurePipelinesOptions + { + Organization = "dnceng", + Builds = new[] + { + new BuildMonitorOptions.AzurePipelinesOptions.BuildDescription + { + Project = "test-project-name", + DefinitionPath = "\\test\\definition\\path5", + Branches = new string[] { "sourceBranch" }, + Assignee = "test-assignee", + IssuesId = "azuredevops-issues" + } + } + }; + options.Issues = new[] + { + new BuildMonitorOptions.IssuesOptions + { + Id = "azuredevops-issues", + UseAzureDevOps = true, + AzureDevOpsProject = "test-ado-project", + AzureDevOpsAreaPath = "test\\area\\path", + Labels = new string[] { "build-failure" } } }; } @@ -638,7 +766,7 @@ public static Func Controller(IServi } public static - Func IssueNames, List IssueOwners, List CommentNames, List CommentOwners)> + Func IssueNames, List IssueOwners, List CommentNames, List CommentOwners, Mock AzureDevOpsClient)> GitHubCalls(IServiceCollection collection, JObject buildData, bool expectMatchingTitle) { var commentOwners = new List(); @@ -720,6 +848,42 @@ public static mockAzureDevOpsClient .Setup(m => m.GetTimelineAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.FromResult(new Timeline())); + + // Setup Azure DevOps work item creation mocks + mockAzureDevOpsClient + .Setup(m => m.CreateBuildFailureWorkItem( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.FromResult((WorkItem?)new WorkItem + { + Id = 12345, + Fields = new Dictionary() + })); + + mockAzureDevOpsClient + .Setup(m => m.QueryWorkItemsByTitle( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.FromResult((WorkItem[]?)Array.Empty())); + + mockAzureDevOpsClient + .Setup(m => m.UpdateWorkItemComment( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.FromResult((WorkItem?)new WorkItem + { + Id = 12345, + Fields = new Dictionary() + })); var mockHttpClientFactory = new Mock(); @@ -731,7 +895,7 @@ public static collection.AddSingleton(mockHttpClientFactory.Object); collection.AddSingleton(ExponentialRetry.Default); - return _ => (issueNames, issueOwners, commentNames, commentOwners); + return _ => (issueNames, issueOwners, commentNames, commentOwners, mockAzureDevOpsClient); } } diff --git a/src/DotNet.Status.Web/DotNet.Status.Web/Controllers/AzurePipelinesController.cs b/src/DotNet.Status.Web/DotNet.Status.Web/Controllers/AzurePipelinesController.cs index 7dcfd6a59..c0a6ba4b9 100644 --- a/src/DotNet.Status.Web/DotNet.Status.Web/Controllers/AzurePipelinesController.cs +++ b/src/DotNet.Status.Web/DotNet.Status.Web/Controllers/AzurePipelinesController.cs @@ -206,25 +206,43 @@ private async Task ProcessBuildNotificationsAsync(IAzureDevOpsClient client, Bui if (repo != null) { - IGitHubClient github = await _gitHubApplicationClientFactory.CreateGitHubClientAsync(repo.Owner, repo.Name); - - DateTimeOffset? finishTime = DateTimeOffset.TryParse(build.FinishTime, out var parsedFinishTime) ?parsedFinishTime: (DateTimeOffset?) null; - DateTimeOffset? startTime = DateTimeOffset.TryParse(build.StartTime, out var parsedStartTime) ? parsedStartTime:(DateTimeOffset?) null; - - string timeString = ""; - string durationString = ""; - if (finishTime.HasValue) + if (repo.UseAzureDevOps) + { + await CreateAzureDevOpsWorkItemAsync(client, build, monitor, repo, branchName, prettyTags, timelineMessage, changesMessage); + } + else { - timeString = finishTime.Value.ToString("R"); - if (startTime.HasValue) - { - durationString = ((int) (finishTime.Value - startTime.Value).TotalMinutes) + " minutes"; - } + await CreateGitHubIssueAsync(build, monitor, repo, branchName, prettyTags, timelineMessage, changesMessage); } + } + else + { + _logger.LogWarning("Could not find a matching repo for {issuesId}", monitor.IssuesId); + } + } + } - string icon = build.Result == "failed" ? ":x:" : ":warning:"; + private async Task CreateGitHubIssueAsync(Build build, BuildMonitorOptions.AzurePipelinesOptions.BuildDescription monitor, BuildMonitorOptions.IssuesOptions repo, string branchName, string prettyTags, string timelineMessage, string changesMessage) + { + IGitHubClient github = await _gitHubApplicationClientFactory.CreateGitHubClientAsync(repo.Owner, repo.Name); + + DateTimeOffset? finishTime = DateTimeOffset.TryParse(build.FinishTime, out DateTimeOffset parsedFinishTime) ? parsedFinishTime : (DateTimeOffset?)null; + DateTimeOffset? startTime = DateTimeOffset.TryParse(build.StartTime, out DateTimeOffset parsedStartTime) ? parsedStartTime : (DateTimeOffset?)null; + + string timeString = ""; + string durationString = ""; + if (finishTime.HasValue) + { + timeString = finishTime.Value.ToString("R"); + if (startTime.HasValue) + { + durationString = ((int)(finishTime.Value - startTime.Value).TotalMinutes) + " minutes"; + } + } + + string icon = build.Result == "failed" ? ":x:" : ":warning:"; - string body = @$"Build [#{build.BuildNumber}]({build.Links.Web.Href}) {build.Result} + string body = @$"Build [#{build.BuildNumber}]({build.Links.Web.Href}) {build.Result} ## {icon} : {build.Project.Name} / {build.Definition.Name} {build.Result} @@ -242,92 +260,188 @@ private async Task ProcessBuildNotificationsAsync(IAzureDevOpsClient client, Bui {changesMessage} "; - string issueTitlePrefix = $"Build failed: {build.Definition.Name}/{branchName} {prettyTags}"; + string issueTitlePrefix = $"Build failed: {build.Definition.Name}/{branchName} {prettyTags}"; - if (repo.UpdateExisting) - { - // There is no way to get the username of our bot directly from the GithubApp with the C# api. - // Issue opened in Octokit: https://github.com/octokit/octokit.net/issues/2335 - // We do, however, have access to the HtmlUrl, which ends with the name of the bot. - // Additionally, when the bot opens issues, the username used ends with [bot], which isn't strictly - // part of the name anywhere else. So, to get the correct creator name, get the HtmlUrl, grab - // the bot's name from it, and append [bot] to that string. - var githubAppClient = _gitHubApplicationClientFactory.CreateGitHubAppClient(); - string creator = (await githubAppClient.GitHubApps.GetCurrent()).HtmlUrl.Split("/").Last(); - - RepositoryIssueRequest issueRequest = new RepositoryIssueRequest { - Creator = $"{creator}[bot]", - State = ItemStateFilter.Open, - SortProperty = IssueSort.Created, - SortDirection = SortDirection.Descending - }; - - foreach (string label in repo.Labels.OrEmpty()) - { - issueRequest.Labels.Add(label); - } - - foreach (string label in monitor.Labels.OrEmpty()) - { - issueRequest.Labels.Add(label); - } - - List matchingIssues = (await github.Issue.GetAllForRepository(repo.Owner, repo.Name, issueRequest)).ToList(); - Issue matchingIssue = matchingIssues.FirstOrDefault(i => i.Title.StartsWith(issueTitlePrefix)); - - if (matchingIssue != null) - { - _logger.LogInformation("Found matching issue {issueNumber} in {owner}/{repo}. Will attempt to add a new comment.", matchingIssue.Number, repo.Owner, repo.Name); - // Add a new comment to the issue with the body - IssueComment newComment = await github.Issue.Comment.Create(repo.Owner, repo.Name, matchingIssue.Number, body); - _logger.LogInformation("Logged comment in {owner}/{repo}#{issueNumber} for build failure", repo.Owner, repo.Name, matchingIssue.Number); - - return; - } - else - { - _logger.LogInformation("Matching issues for {issueTitlePrefix} not found. Creating a new issue.", issueTitlePrefix); - } - } + if (repo.UpdateExisting) + { + // There is no way to get the username of our bot directly from the GithubApp with the C# api. + // Issue opened in Octokit: https://github.com/octokit/octokit.net/issues/2335 + // We do, however, have access to the HtmlUrl, which ends with the name of the bot. + // Additionally, when the bot opens issues, the username used ends with [bot], which isn't strictly + // part of the name anywhere else. So, to get the correct creator name, get the HtmlUrl, grab + // the bot's name from it, and append [bot] to that string. + IGitHubClient githubAppClient = _gitHubApplicationClientFactory.CreateGitHubAppClient(); + string creator = (await githubAppClient.GitHubApps.GetCurrent()).HtmlUrl.Split("/").Last(); + + RepositoryIssueRequest issueRequest = new RepositoryIssueRequest + { + Creator = $"{creator}[bot]", + State = ItemStateFilter.Open, + SortProperty = IssueSort.Created, + SortDirection = SortDirection.Descending + }; - // Create new issue if repo.UpdateExisting is false or there were no matching issues - var newIssue = - new NewIssue($"{issueTitlePrefix} #{build.BuildNumber}") - { - Body = body, - }; + foreach (string label in repo.Labels.OrEmpty()) + { + issueRequest.Labels.Add(label); + } - if (!string.IsNullOrEmpty(monitor.Assignee)) - { - newIssue.Assignees.Add(monitor.Assignee); - } + foreach (string label in monitor.Labels.OrEmpty()) + { + issueRequest.Labels.Add(label); + } + + List matchingIssues = (await github.Issue.GetAllForRepository(repo.Owner, repo.Name, issueRequest)).ToList(); + Issue matchingIssue = matchingIssues.FirstOrDefault(i => i.Title.StartsWith(issueTitlePrefix)); + + if (matchingIssue != null) + { + _logger.LogInformation("Found matching issue {issueNumber} in {owner}/{repo}. Will attempt to add a new comment.", matchingIssue.Number, repo.Owner, repo.Name); + // Add a new comment to the issue with the body + IssueComment newComment = await github.Issue.Comment.Create(repo.Owner, repo.Name, matchingIssue.Number, body); + _logger.LogInformation("Logged comment in {owner}/{repo}#{issueNumber} for build failure", repo.Owner, repo.Name, matchingIssue.Number); + + return; + } + else + { + _logger.LogInformation("Matching issues for {issueTitlePrefix} not found. Creating a new issue.", issueTitlePrefix); + } + } + + // Create new issue if repo.UpdateExisting is false or there were no matching issues + NewIssue newIssue = new NewIssue($"{issueTitlePrefix} #{build.BuildNumber}") + { + Body = body, + }; + + if (!string.IsNullOrEmpty(monitor.Assignee)) + { + newIssue.Assignees.Add(monitor.Assignee); + } - foreach (string label in repo.Labels.OrEmpty()) - { - newIssue.Labels.Add(label); - } + foreach (string label in repo.Labels.OrEmpty()) + { + newIssue.Labels.Add(label); + } - foreach (string label in monitor.Labels.OrEmpty()) - { - newIssue.Labels.Add(label); - } + foreach (string label in monitor.Labels.OrEmpty()) + { + newIssue.Labels.Add(label); + } + + /* + * We are sometimes seeing an OctoKit.ApiValidationException in the dotneteng-status app insights logs when creating issues. + * This is potentially related to https://github.com/octokit/octokit.net/issues/612. + * Adding logging here to help us track down the issue. + */ + _logger.LogInformation("Creating issue {owner}/{repo} with title '{issueTitle}' in the {milestone} milestone.", repo.Owner, repo.Name, newIssue.Title, newIssue.Milestone); + + Issue issue = await github.Issue.Create(repo.Owner, repo.Name, newIssue); + + _logger.LogInformation("Logged issue {owner}/{repo}#{issueNumber} for build failure", repo.Owner, repo.Name, issue.Number); + } + + private async Task CreateAzureDevOpsWorkItemAsync(IAzureDevOpsClient client, Build build, BuildMonitorOptions.AzurePipelinesOptions.BuildDescription monitor, BuildMonitorOptions.IssuesOptions repo, string branchName, string prettyTags, string timelineMessage, string changesMessage) + { + DateTimeOffset? finishTime = DateTimeOffset.TryParse(build.FinishTime, out DateTimeOffset parsedFinishTime) ? parsedFinishTime : (DateTimeOffset?)null; + DateTimeOffset? startTime = DateTimeOffset.TryParse(build.StartTime, out DateTimeOffset parsedStartTime) ? parsedStartTime : (DateTimeOffset?)null; + + string timeString = ""; + string durationString = ""; + if (finishTime.HasValue) + { + timeString = finishTime.Value.ToString("R"); + if (startTime.HasValue) + { + durationString = ((int)(finishTime.Value - startTime.Value).TotalMinutes) + " minutes"; + } + } + + // Build description in HTML format for Azure DevOps + string description = $@"
Build #{build.BuildNumber} {build.Result}
+

{build.Project.Name} / {build.Definition.Name} {build.Result}

+

Summary

+
    +
  • Finished - {timeString}
  • +
  • Duration - {durationString}
  • +
  • Requested for - {build.RequestedFor.DisplayName}
  • +
  • Reason - {build.Reason}
  • +
+

Details

+
{System.Net.WebUtility.HtmlEncode(timelineMessage).Replace(Environment.NewLine, "
")}
+

Changes

+
{System.Net.WebUtility.HtmlEncode(changesMessage).Replace(Environment.NewLine, "
")}
"; + + string workItemTitlePrefix = $"Build failed: {build.Definition.Name}/{branchName} {prettyTags}"; + string workItemTitle = $"{workItemTitlePrefix} #{build.BuildNumber}"; + + List tags = new List(); + if (repo.Labels != null) + { + tags.AddRange(repo.Labels); + } + if (monitor.Labels != null) + { + tags.AddRange(monitor.Labels); + } - /* - * We are sometimes seeing an OctoKit.ApiValidationException in the dotneteng-status app insights logs when creating issues. - * This is potentially related to https://github.com/octokit/octokit.net/issues/612. - * Adding logging here to help us track down the issue. - */ - _logger.LogInformation("Creating issue {owner}/{repo} with title '{issueTitle}' in the {milestone} milestone.", repo.Owner, repo.Name, newIssue.Title, newIssue.Milestone); + if (repo.UpdateExisting) + { + _logger.LogInformation("Checking for existing work items with title prefix '{titlePrefix}'", workItemTitlePrefix); + WorkItem[]? existingWorkItems = await client.QueryWorkItemsByTitle(repo.AzureDevOpsProject, workItemTitlePrefix, repo.AzureDevOpsAreaPath, CancellationToken.None); - Issue issue = await github.Issue.Create(repo.Owner, repo.Name, newIssue); + if (existingWorkItems != null && existingWorkItems.Length > 0) + { + WorkItem existingWorkItem = existingWorkItems[0]; + _logger.LogInformation("Found matching work item {workItemId} in project {project}. Will attempt to add a new comment.", existingWorkItem.Id, repo.AzureDevOpsProject); + + // Add a comment with the new build failure info + string comment = $@"Build #{build.BuildNumber} {build.Result} + +Finished: {timeString} +Duration: {durationString} +Requested for: {build.RequestedFor.DisplayName} + +Build URL: {build.Links.Web.Href} + +Details: +{timelineMessage} - _logger.LogInformation("Logged issue {owner}/{repo}#{issueNumber} for build failure", repo.Owner, repo.Name, issue.Number); +Changes: +{changesMessage}"; + + WorkItem? updatedWorkItem = await client.UpdateWorkItemComment(repo.AzureDevOpsProject, existingWorkItem.Id, comment, CancellationToken.None); + _logger.LogInformation("Logged comment in work item {workItemId} for build failure", existingWorkItem.Id); + + return; } else { - _logger.LogWarning("Could not find a matching repo for {issuesId}", monitor.IssuesId); + _logger.LogInformation("Matching work items for '{titlePrefix}' not found. Creating a new work item.", workItemTitlePrefix); } } + + // Create new work item if repo.UpdateExisting is false or there were no matching work items + _logger.LogInformation("Creating work item in project {project} with title '{title}'", repo.AzureDevOpsProject, workItemTitle); + + WorkItem? workItem = await client.CreateBuildFailureWorkItem( + repo.AzureDevOpsProject, + repo.AzureDevOpsAreaPath, + workItemTitle, + description, + monitor.Assignee, + tags.ToArray(), + CancellationToken.None); + + if (workItem != null) + { + _logger.LogInformation("Logged work item {workItemId} in project {project} for build failure", workItem.Id, repo.AzureDevOpsProject); + } + else + { + _logger.LogError("Failed to create work item in project {project}", repo.AzureDevOpsProject); + } } private async Task BuildChangesMessage(IAzureDevOpsClient client, Build build) diff --git a/src/DotNet.Status.Web/DotNet.Status.Web/Options/BuildMonitorOptions.cs b/src/DotNet.Status.Web/DotNet.Status.Web/Options/BuildMonitorOptions.cs index d45be6aba..f6c999422 100644 --- a/src/DotNet.Status.Web/DotNet.Status.Web/Options/BuildMonitorOptions.cs +++ b/src/DotNet.Status.Web/DotNet.Status.Web/Options/BuildMonitorOptions.cs @@ -33,5 +33,10 @@ public class IssuesOptions public string Name { get; set; } public string[] Labels { get; set; } public bool UpdateExisting { get; set; } + + // Azure DevOps work item settings + public string AzureDevOpsProject { get; set; } + public string AzureDevOpsAreaPath { get; set; } + public bool UseAzureDevOps { get; set; } } } diff --git a/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.cs b/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.cs index 7c23b6b6e..ddb5c4495 100644 --- a/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.cs +++ b/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.cs @@ -163,6 +163,92 @@ private async Task GetTimelineRaw(string project, int buildId, string id return JsonConvert.DeserializeObject(json); } + public async Task CreateBuildFailureWorkItem(string project, string areaPath, string title, string description, string? assignee, string[] tags, CancellationToken cancellationToken) + { + Dictionary fields = new Dictionary(); + fields.Add("System.Title", title); + fields.Add("System.Description", description); + + if (!string.IsNullOrEmpty(assignee)) + { + fields.Add("System.AssignedTo", assignee); + } + + if (!string.IsNullOrEmpty(areaPath)) + { + fields.Add("System.AreaPath", areaPath); + } + + 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 UpdateWorkItemComment(string project, int workItemId, string comment, CancellationToken cancellationToken) + { + StringBuilder builder = GetProjectApiRootBuilder(project); + builder.Append($"wit/workitems/{workItemId}/comments?api-version=7.1-preview.3"); + + JObject commentBody = new JObject(); + commentBody["text"] = comment; + string body = JsonConvert.SerializeObject(commentBody); + + string json = (await PostJsonResult(builder.ToString(), body, cancellationToken)).Body; + + // After adding comment, get the updated work item + return await GetWorkItemAsync(project, workItemId, cancellationToken); + } + + public async Task QueryWorkItemsByTitle(string project, string titlePrefix, string areaPath, CancellationToken cancellationToken) + { + StringBuilder builder = GetProjectApiRootBuilder(project); + builder.Append($"wit/wiql?api-version=6.0"); + + string wiql = $@"SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = '{project}' AND [System.Title] CONTAINS '{titlePrefix}' AND [System.AreaPath] UNDER '{areaPath}' AND [System.State] <> 'Closed' AND [System.State] <> 'Resolved'"; + + JObject query = new JObject(); + query["query"] = wiql; + string body = JsonConvert.SerializeObject(query); + + string json = (await PostJsonResult(builder.ToString(), body, cancellationToken)).Body; + JObject result = JObject.Parse(json); + JArray? workItems = result["workItems"] as JArray; + + if (workItems == null || workItems.Count == 0) + { + return Array.Empty(); + } + + List results = new List(); + foreach (JToken item in workItems) + { + int id = item["id"]?.Value() ?? 0; + if (id > 0) + { + WorkItem? workItem = await GetWorkItemAsync(project, id, cancellationToken); + if (workItem != null) + { + results.Add(workItem); + } + } + } + + return results.ToArray(); + } + + private async Task GetWorkItemAsync(string project, int workItemId, CancellationToken cancellationToken) + { + StringBuilder builder = GetProjectApiRootBuilder(project); + builder.Append($"wit/workitems/{workItemId}?api-version=6.0"); + + JsonResult jsonResult = await GetJsonResult(builder.ToString(), cancellationToken); + return JsonConvert.DeserializeObject(jsonResult.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. @@ -248,7 +334,7 @@ private async Task CreateWorkItem(string project, string type, Dictionar builder.Append($"wit/workitems/${type}?api-version=6.0"); List patchDocuments = new List(); - foreach(var field in fields) + foreach(KeyValuePair field in fields) { JsonPatchDocument patchDocument = new JsonPatchDocument() { @@ -261,15 +347,19 @@ private async Task CreateWorkItem(string project, string type, Dictionar patchDocuments.Add(patchDocument); } - JsonPatchDocument areaPath = new JsonPatchDocument() + // Add default area path only if not already specified in fields + if (!fields.ContainsKey("System.AreaPath")) { - From = null, - Op = "add", - Path = "/fields/System.AreaPath", - Value = "internal\\Dotnet-Core-Engineering" - }; + JsonPatchDocument areaPath = new JsonPatchDocument() + { + From = null, + Op = "add", + Path = "/fields/System.AreaPath", + Value = "internal\\Dotnet-Core-Engineering" + }; - patchDocuments.Add(areaPath); + patchDocuments.Add(areaPath); + } string body = JsonConvert.SerializeObject(patchDocuments); diff --git a/src/Telemetry/AzureDevOpsClient/IAzureDevOpsClient.cs b/src/Telemetry/AzureDevOpsClient/IAzureDevOpsClient.cs index 853d2a75f..1a1d9fd9d 100644 --- a/src/Telemetry/AzureDevOpsClient/IAzureDevOpsClient.cs +++ b/src/Telemetry/AzureDevOpsClient/IAzureDevOpsClient.cs @@ -24,6 +24,9 @@ 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 CreateBuildFailureWorkItem(string project, string areaPath, string title, string description, string? assignee, string[] tags, CancellationToken cancellationToken = default); + public Task UpdateWorkItemComment(string project, int workItemId, string comment, CancellationToken cancellationToken = default); + public Task QueryWorkItemsByTitle(string project, string titlePrefix, string areaPath, CancellationToken cancellationToken = default); public Task MatchLogLineSequence(string logUri, IReadOnlyList regexes, CancellationToken cancellationToken = default); public Task GetProjectNameAsync(string id); }