Skip to content

Commit

Permalink
Devops Service: Auto assign bugs to owners (Azure#5334)
Browse files Browse the repository at this point in the history
We have bug generation tooling for failing tests. The next step here is to assign them to the responsible party. This can be done by assigning a bug to the commit author of the failing build. If commits are batched, the assignee will be responsible for determining if any potential failures are caused by their changes and triaging if needed.

In order to do this, the existing bug creation abstraction (`BugManagement`) has been modified via composition to contain two more abstractions. Specifically these steps were taken:
1. Parse the commit from from the devops api response and store in the VstsBuild datatype object.
2. Use new abstraction `CommitManagement` that can take a commit and return a full name. This full name is determined through the git config of the committer at commit time. Since microsoft open source guidelines specify to declare your full name on your account, this should be a safe assumption. 
3. Use new abstraction `UserManagement` to take the committer full name and look up the email address for this microsoft identity.
4. Once we have the email address of the committer we can open a bug on them.

Here is an example bug:
https://msazure.visualstudio.com/One/_workitems/edit/10570189

Also I took this as an opportunity to add isa95 to the devops service.
  • Loading branch information
and-rewsmith authored Aug 13, 2021
1 parent 6f9816b commit b3a61b1
Show file tree
Hide file tree
Showing 19 changed files with 252 additions and 74 deletions.
8 changes: 4 additions & 4 deletions tools/IoTEdgeDevOps/DevOpsLib/AgentManagement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ public AgentManagement(DevOpsAccessSetting accessSetting)

public async Task<IList<VstsAgent>> GetAgentsAsync(int poolId)
{
string requestPath = string.Format(AgentListPathSegmentFormat, this.accessSetting.Organization, poolId);
string requestPath = string.Format(AgentListPathSegmentFormat, DevOpsAccessSetting.AzureOrganization, poolId);
IFlurlRequest agentListRequest = DevOpsAccessSetting.BaseUrl
.AppendPathSegment(requestPath)
.SetQueryParam("api-version", "5.1")
.WithBasicAuth(string.Empty, this.accessSetting.PersonalAccessToken);
.WithBasicAuth(string.Empty, this.accessSetting.MsazurePAT);

VstsAgentList agentList = await agentListRequest.GetJsonAsync<VstsAgentList>().ConfigureAwait(false);

Expand All @@ -43,12 +43,12 @@ public async Task<IList<VstsAgent>> GetAgentsAsync(int poolId)

async Task<VstsAgent> GetAgentAsync(int poolId, int agentId)
{
string requestPath = string.Format(AgentPathSegmentFormat, this.accessSetting.Organization, poolId, agentId);
string requestPath = string.Format(AgentPathSegmentFormat, DevOpsAccessSetting.AzureOrganization, poolId, agentId);
IFlurlRequest agentRequest = DevOpsAccessSetting.BaseUrl
.AppendPathSegment(requestPath)
.SetQueryParam("api-version", "5.1")
.SetQueryParam("includeCapabilities", "true")
.WithBasicAuth(string.Empty, this.accessSetting.PersonalAccessToken);
.WithBasicAuth(string.Empty, this.accessSetting.MsazurePAT);

return await agentRequest.GetJsonAsync<VstsAgent>().ConfigureAwait(false);
}
Expand Down
67 changes: 62 additions & 5 deletions tools/IoTEdgeDevOps/DevOpsLib/BugManagement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
namespace DevOpsLib
{
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using DevOpsLib.VstsModels;
using Flurl;
Expand All @@ -11,12 +12,17 @@ namespace DevOpsLib
public class BugManagement
{
const string WorkItemPathSegmentFormat = "{0}/{1}/{2}/_apis/wit/workitems/$Bug";
const string BackupOnCallReportLink = "https://microsoft.sharepoint.com/teams/Azure_IoT/_layouts/15/Doc.aspx?sourcedoc={1d485f94-0812-41c5-81b1-7fc0ca2dc9e4}&action=edit&wd=target%28Azure%20IoT%20Edge%2FServicing.one%7C0E88E43B-8A10-4A3F-982E-CF8B7441EAE8%2FBackup%20On-Call%20Reports%7Cfda0177f-3b35-4751-bfc9-7420cd56652c%2F%29&wdorigin=703";

readonly DevOpsAccessSetting accessSetting;
readonly CommitManagement commitManagement;
readonly UserManagement userManagement;

public BugManagement(DevOpsAccessSetting accessSetting)
public BugManagement(DevOpsAccessSetting accessSetting, CommitManagement commitManagement, UserManagement userManagement)
{
this.accessSetting = accessSetting;
this.commitManagement = commitManagement;
this.userManagement = userManagement;
}

/// <summary>
Expand All @@ -29,15 +35,19 @@ public BugManagement(DevOpsAccessSetting accessSetting)
/// <returns>Work item id for the created bug.</returns>
public async Task<string> CreateBugAsync(string branch, VstsBuild build)
{
string requestPath = string.Format(WorkItemPathSegmentFormat, DevOpsAccessSetting.BaseUrl, this.accessSetting.Organization, this.accessSetting.Project);
string requestPath = string.Format(WorkItemPathSegmentFormat, DevOpsAccessSetting.BaseUrl, DevOpsAccessSetting.AzureOrganization, DevOpsAccessSetting.AzureProject);
IFlurlRequest workItemQueryRequest = ((Url)requestPath)
.WithBasicAuth(string.Empty, this.accessSetting.PersonalAccessToken)
.WithBasicAuth(string.Empty, this.accessSetting.MsazurePAT)
.WithHeader("Content-Type", "application/json-patch+json")
.SetQueryParam("api-version", "6.0");

(string bugOwnerFullName, string bugOwnerEmail) = await this.GetBugOwnerInfoAsync(build.SourceVersion);
string bugDescription = GenerateBugDescription(bugOwnerFullName, bugOwnerEmail, build);

var jsonBody = new object[]
{
new {
new
{
op = "add",
path = "/fields/System.Title",
from = string.Empty,
Expand All @@ -48,7 +58,7 @@ public async Task<string> CreateBugAsync(string branch, VstsBuild build)
op = "add",
path = "/fields/Microsoft.VSTS.TCM.ReproSteps",
from = string.Empty,
value = $"This bug is autogenerated by the vsts-pipeline-sync service. Link to failing build:<div> {build.WebUri}"
value = bugDescription
},
new
{
Expand All @@ -73,6 +83,18 @@ public async Task<string> CreateBugAsync(string branch, VstsBuild build)
rel = "Hyperlink",
url = $"{build.WebUri}"
}
},
new
{
op = "add",
path = "/fields/System.AssignedTo",
value = bugOwnerEmail
},
new
{
op = "add",
path = "/fields/System.Tags",
value = "auto-pipeline-failed"
}
};

Expand All @@ -97,5 +119,40 @@ public async Task<string> CreateBugAsync(string branch, VstsBuild build)

return result["id"].ToString();
}

static string GenerateBugDescription(string bugOwnerFullName, string bugOwnerEmail, VstsBuild build)
{
string bugDescription = "This bug is autogenerated and assigned by the vsts-pipeline-sync service. ";
if (bugOwnerEmail.Equals(string.Empty))
{
bugDescription = $"Attempted to assign to {bugOwnerFullName}, but failed to do so. Either this person is not a team member or they are not in the iotedge devops organization.";
}
else
{
bugDescription = $"Assigned to {bugOwnerFullName}.";
}

bugDescription += $"<div>`<div> Please address if the failure was caused by your changes. Otherwise please help to triage appropriately. ";
bugDescription += $"Reference the backup on-call report to match to an existing bug. If the bug does not exist yet, please create a new bug and coordinate with backup on-call to confirm the bug gets added to the most recent backup on-call report. After this is complete you can close this bug. Link to resource: <div> <a href=\"{BackupOnCallReportLink}\">Backup On-Call Reports</a> <div>`<div>";
bugDescription += $"Link to failing build:<div> <a href=\"{build.WebUri}\">Failing Build</a>";

return bugDescription;
}

async Task<(string, string)> GetBugOwnerInfoAsync(string commit)
{
string ownerFullName = await this.commitManagement.GetAuthorFullNameFromCommitAsync(commit);

IList<VstsUser> allTeamMembers = await this.userManagement.ListUsersAsync();
foreach (VstsUser user in allTeamMembers)
{
if (user.Name == ownerFullName)
{
return (ownerFullName, user.MailAddress);
}
}

return (ownerFullName, String.Empty);
}
}
}
4 changes: 2 additions & 2 deletions tools/IoTEdgeDevOps/DevOpsLib/BugWiqlManagement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ public BugWiqlManagement(DevOpsAccessSetting accessSetting)
public async Task<int> GetBugsCountAsync(BugWiqlQuery bugQuery)
{
// TODO: need to think about how to handle unexpected exception during REST API call
string requestPath = string.Format(WorkItemPathSegmentFormat, DevOpsAccessSetting.BaseUrl, this.accessSetting.Organization, this.accessSetting.Project, this.accessSetting.Team);
string requestPath = string.Format(WorkItemPathSegmentFormat, DevOpsAccessSetting.BaseUrl, DevOpsAccessSetting.AzureOrganization, DevOpsAccessSetting.AzureProject, DevOpsAccessSetting.IoTTeam);
IFlurlRequest workItemQueryRequest = ((Url)requestPath)
.WithBasicAuth(string.Empty, this.accessSetting.PersonalAccessToken)
.WithBasicAuth(string.Empty, this.accessSetting.MsazurePAT)
.SetQueryParam("api-version", "5.1");

JObject result;
Expand Down
1 change: 1 addition & 0 deletions tools/IoTEdgeDevOps/DevOpsLib/BuildDefinitionId.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ public enum BuildDefinitionId
StressTestEnv1 = 66357,
StressTestEnv2 = 96102,
StressTestEnv3 = 96103,
NestedISA95 = 167652,
}
}
4 changes: 3 additions & 1 deletion tools/IoTEdgeDevOps/DevOpsLib/BuildExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ public static class BuildExtension
BuildDefinitionId.NestedLonghaulTest,
BuildDefinitionId.StressTestEnv1,
BuildDefinitionId.StressTestEnv2,
BuildDefinitionId.StressTestEnv3
BuildDefinitionId.StressTestEnv3,
BuildDefinitionId.NestedISA95
};
static Dictionary<BuildDefinitionId, string> definitionIdToDisplayNameMapping = new Dictionary<BuildDefinitionId, string>
{
Expand All @@ -45,6 +46,7 @@ public static class BuildExtension
{ BuildDefinitionId.StressTestEnv1, "Stress Test" },
{ BuildDefinitionId.StressTestEnv2, "Stress Test Release Candidate" },
{ BuildDefinitionId.StressTestEnv3, "Stress Test Release" },
{ BuildDefinitionId.NestedISA95, "ISA95 Smoke Test" },
};

public static string DisplayName(this BuildDefinitionId buildDefinitionId)
Expand Down
8 changes: 4 additions & 4 deletions tools/IoTEdgeDevOps/DevOpsLib/BuildManagement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ public async Task<IList<VstsBuild>> GetLatestBuildsAsync(HashSet<BuildDefinition
ValidationUtil.ThrowIfNullOrWhiteSpace(branchName, nameof(branchName));

// TODO: need to think about how to handle unexpected exception during REST API call
string requestPath = string.Format(LatestBuildPathSegmentFormat, this.accessSetting.Organization, this.accessSetting.Project);
string requestPath = string.Format(LatestBuildPathSegmentFormat, DevOpsAccessSetting.AzureOrganization, DevOpsAccessSetting.AzureProject);
IFlurlRequest latestBuildRequest = GetBuildsRequestUri(buildDefinitionIds, branchName, requestPath, null, 1)
.WithBasicAuth(string.Empty, this.accessSetting.PersonalAccessToken);
.WithBasicAuth(string.Empty, this.accessSetting.MsazurePAT);

string resultJson = await latestBuildRequest.GetStringAsync().ConfigureAwait(false);
JObject result = JObject.Parse(resultJson);
Expand All @@ -60,9 +60,9 @@ public async Task<IList<VstsBuild>> GetBuildsAsync(HashSet<BuildDefinitionId> bu
ValidationUtil.ThrowIfNullOrWhiteSpace(branchName, nameof(branchName));

// TODO: need to think about how to handle unexpected exception during REST API call
string requestPath = string.Format(LatestBuildPathSegmentFormat, this.accessSetting.Organization, this.accessSetting.Project);
string requestPath = string.Format(LatestBuildPathSegmentFormat, DevOpsAccessSetting.AzureOrganization, DevOpsAccessSetting.AzureProject);
IFlurlRequest latestBuildRequest = GetBuildsRequestUri(buildDefinitionIds, branchName, requestPath, minTime, maxBuildsPerDefinition)
.WithBasicAuth(string.Empty, this.accessSetting.PersonalAccessToken);
.WithBasicAuth(string.Empty, this.accessSetting.MsazurePAT);

string resultJson = await latestBuildRequest.GetStringAsync().ConfigureAwait(false);
JObject result = JObject.Parse(resultJson);
Expand Down
53 changes: 53 additions & 0 deletions tools/IoTEdgeDevOps/DevOpsLib/CommitManagement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) Microsoft. All rights reserved.
namespace DevOpsLib
{
using System;
using System.Threading.Tasks;
using Flurl;
using Flurl.Http;
using Newtonsoft.Json.Linq;

public class CommitManagement
{
const string UserPathSegmentFormat = "https://api.github.com/repos/Azure/iotedge/commits/{0}";

public CommitManagement()
{
}

/// <summary>
/// This method is used to get a commit author's full name via the github rest api.
/// Reference: https://docs.github.com/en/rest
/// For an example response: https://api.github.com/repos/Azure/iotedge/commits/704250b
/// </summary>
/// <param name="commit">Commit for which to get the author's full name.</param>
/// <returns>Full name of author.</returns>
public async Task<string> GetAuthorFullNameFromCommitAsync(string commit)
{
string requestPath = string.Format(UserPathSegmentFormat, commit);

IFlurlRequest workItemQueryRequest = ((Url)requestPath)
.WithHeader("Content-Type", "application/json")
.WithHeader("User-Agent", "Azure/iotedge");

JObject result;
try
{
IFlurlResponse response = await workItemQueryRequest.GetAsync();
result = await response.GetJsonAsync<JObject>();
string fullName = result["commit"]["author"]["name"].ToString();
return fullName;
}
catch (FlurlHttpException e)
{
string message = $"Failed making call to commit api: {e.Message}";
Console.WriteLine(message);
Console.WriteLine(e.Call.RequestBody);
Console.WriteLine(e.Call.Response.StatusCode);
Console.WriteLine(e.Call.Response.ResponseMessage);

throw new Exception(message);
}
}
}
}
32 changes: 7 additions & 25 deletions tools/IoTEdgeDevOps/DevOpsLib/DevOpsAccessSetting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,20 @@ public class DevOpsAccessSetting
{
public const string BaseUrl = "https://dev.azure.com";
public const string ReleaseManagementBaseUrl = "https://vsrm.dev.azure.com";
public const string UserManagementBaseUrl = "https://vssps.dev.azure.com";
public const string AzureOrganization = "msazure";
public const string AzureProject = "one";
public const string IoTTeam = "IoT-Platform-Edge";
public const string IotedgeOrganization = "iotedge";

public DevOpsAccessSetting(string personalAccessToken)
: this(AzureOrganization, AzureProject, personalAccessToken, IoTTeam)
public DevOpsAccessSetting(string msazurePAT, string iotedgePAT = "")
{
this.MsazurePAT = msazurePAT;
this.IotedgePAT = iotedgePAT;
}

public DevOpsAccessSetting(
string organization,
string project,
string personalAccessToken,
string team)
{
ValidationUtil.ThrowIfNullOrWhiteSpace(organization, nameof(organization));
ValidationUtil.ThrowIfNullOrWhiteSpace(project, nameof(project));
ValidationUtil.ThrowIfNullOrWhiteSpace(personalAccessToken, nameof(personalAccessToken));
ValidationUtil.ThrowIfNullOrWhiteSpace(team, nameof(team));

this.Organization = organization;
this.Project = project;
this.PersonalAccessToken = personalAccessToken;
this.Team = team;
}

public string Organization { get; }

public string Project { get; }

public string PersonalAccessToken { get; }
public string MsazurePAT { get; }

public string Team { get; }
public string IotedgePAT { get; }
}
}
2 changes: 1 addition & 1 deletion tools/IoTEdgeDevOps/DevOpsLib/DevOpsLib.csproj
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">

<Import Project="..\..\..\netcoreappVersion.props" />

<ItemGroup>
Expand All @@ -10,6 +9,7 @@
<ItemGroup>
<AdditionalFiles Include="..\..\..\stylecop.json" Link="stylecop.json" />
</ItemGroup>

<PropertyGroup>
<CodeAnalysisRuleSet>..\..\..\stylecop.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
Expand Down
11 changes: 5 additions & 6 deletions tools/IoTEdgeDevOps/DevOpsLib/ReleaseManagement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ namespace DevOpsLib
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;


public class ReleaseManagement
{
const string ReleasePathSegmentFormat = "{0}/{1}/_apis/release/releases";
Expand All @@ -35,34 +34,34 @@ public async Task<List<IoTEdgeRelease>> GetReleasesAsync(ReleaseDefinitionId def
ValidationUtil.ThrowIfNonPositive(top, nameof(top));

// TODO: need to think about how to handle unexpected exception during REST API call
string requestPath = string.Format(ReleasePathSegmentFormat, this.accessSetting.Organization, this.accessSetting.Project);
string requestPath = string.Format(ReleasePathSegmentFormat, DevOpsAccessSetting.AzureOrganization, DevOpsAccessSetting.AzureProject);
IFlurlRequest listReleasesRequest = DevOpsAccessSetting.ReleaseManagementBaseUrl
.AppendPathSegment(requestPath)
.SetQueryParam("definitionId", definitionId.IdString())
.SetQueryParam("queryOrder", "descending")
.SetQueryParam("$top", top)
.SetQueryParam("api-version", "5.1")
.SetQueryParam("sourceBranchFilter", branchName)
.WithBasicAuth(string.Empty, this.accessSetting.PersonalAccessToken);
.WithBasicAuth(string.Empty, this.accessSetting.MsazurePAT);

string releasesJson = await listReleasesRequest.GetStringAsync().ConfigureAwait(false);
JObject releasesJObject = JObject.Parse(releasesJson);

if (!releasesJObject.ContainsKey("count") || (int) releasesJObject["count"] <= 0)
if (!releasesJObject.ContainsKey("count") || (int)releasesJObject["count"] <= 0)
{
return new List<IoTEdgeRelease>();
}

VstsRelease[] vstsReleases = JsonConvert.DeserializeObject<VstsRelease[]>(releasesJObject["value"].ToString());
var iotEdgeReleases = new List<IoTEdgeRelease>();

foreach(VstsRelease vstsRelease in vstsReleases)
foreach (VstsRelease vstsRelease in vstsReleases)
{
IFlurlRequest getReleaseRequest = DevOpsAccessSetting.ReleaseManagementBaseUrl
.AppendPathSegment(requestPath)
.SetQueryParam("api-version", "5.1")
.SetQueryParam("releaseId", vstsRelease.Id)
.WithBasicAuth(string.Empty, this.accessSetting.PersonalAccessToken);
.WithBasicAuth(string.Empty, this.accessSetting.MsazurePAT);

string releaseJson = await getReleaseRequest.GetStringAsync().ConfigureAwait(false);

Expand Down
Loading

0 comments on commit b3a61b1

Please sign in to comment.