tools/notification-configuration/notification-creator/NotificationConfigurator.cs (319 lines of code) (raw):
using common.Helpers;
using Microsoft.Extensions.Logging;
using Microsoft.TeamFoundation.Build.WebApi;
using Microsoft.TeamFoundation.Core.WebApi;
using Microsoft.VisualStudio.Services.Notifications.WebApi;
using Microsoft.VisualStudio.Services.WebApi;
using Azure.Sdk.Tools.NotificationConfiguration.Enums;
using Azure.Sdk.Tools.NotificationConfiguration.Models;
using Azure.Sdk.Tools.NotificationConfiguration.Services;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Azure.Sdk.Tools.NotificationConfiguration.Helpers;
using System;
namespace Azure.Sdk.Tools.NotificationConfiguration
{
class NotificationConfigurator
{
private readonly AzureDevOpsService service;
private readonly GitHubService gitHubService;
private readonly ILogger<NotificationConfigurator> logger;
private const int MaxTeamNameLength = 64;
// A cache on the code owners github identity to owner descriptor.
private readonly Dictionary<string, string> contactsCache = new Dictionary<string, string>();
// A cache on the team member to member descriptor.
private readonly Dictionary<string, string> teamMemberCache = new Dictionary<string, string>();
public NotificationConfigurator(AzureDevOpsService service, GitHubService gitHubService, ILogger<NotificationConfigurator> logger)
{
this.service = service;
this.gitHubService = gitHubService;
this.logger = logger;
}
public async Task ConfigureNotifications(
string projectName,
string projectPath,
GitHubToAADConverter gitHubToAADConverter,
bool persistChanges = true,
PipelineSelectionStrategy strategy = PipelineSelectionStrategy.Scheduled)
{
var pipelines = await GetPipelinesAsync(projectName, projectPath, strategy);
var teams = await service.GetAllTeamsAsync(projectName);
foreach (var pipeline in pipelines)
{
using (logger.BeginScope("Evaluate Pipeline: Name = {0}, Path = {1}, Id = {2}", pipeline.Name, pipeline.Path, pipeline.Id))
{
var parentTeam = await EnsureTeamExists(pipeline, TeamPurpose.ParentNotificationTeam, teams, gitHubToAADConverter, persistChanges);
var childTeam = await EnsureTeamExists(pipeline, TeamPurpose.SynchronizedNotificationTeam, teams, gitHubToAADConverter, persistChanges);
if (!persistChanges && (parentTeam == default || childTeam == default))
{
// Skip team nesting and notification work if
logger.LogInformation("Skipping Teams and Notifications because parent or child team does not exist");
continue;
}
await EnsureSynchronizedNotificationTeamIsChild(parentTeam, childTeam, persistChanges);
}
}
}
private async Task<WebApiTeam> EnsureTeamExists(
BuildDefinition pipeline,
TeamPurpose purpose,
IEnumerable<WebApiTeam> teams,
GitHubToAADConverter gitHubToAADConverter,
bool persistChanges)
{
string teamName = $"{pipeline.Id} ";
if (purpose == TeamPurpose.ParentNotificationTeam)
{
// Ensure team name fits within maximum 64 character limit
// https://docs.microsoft.com/en-us/azure/devops/organizations/settings/naming-restrictions?view=azure-devops#teams
string fullTeamName = teamName + $"{pipeline.Name}";
teamName = StringHelper.MaxLength(fullTeamName, MaxTeamNameLength);
if (fullTeamName.Length > teamName.Length)
{
logger.LogWarning($"Notification team name (length {fullTeamName.Length}) will be truncated to {teamName}");
}
}
else if (purpose == TeamPurpose.SynchronizedNotificationTeam)
{
teamName += $"Code owners sync notifications";
}
bool updateMetadataAndName = false;
var result = teams.FirstOrDefault(
team =>
{
// Swallowing exceptions because parse errors on
// free form text fields which might be non-yaml text
// are not exceptional
var metadata = YamlHelper.Deserialize<TeamMetadata>(team.Description, swallowExceptions: true);
bool metadataMatches = (metadata?.PipelineId == pipeline.Id && metadata?.Purpose == purpose);
bool nameMatches = (team.Name == teamName);
if (metadataMatches && nameMatches)
{
return true;
}
if (metadataMatches)
{
logger.LogInformation("Found team with matching pipeline id {0} but different name '{1}', expected '{2}'. Purpose = '{3}'", metadata?.PipelineId, team.Name, teamName, metadata?.Purpose);
updateMetadataAndName = true;
return true;
}
if (nameMatches)
{
logger.LogInformation("Found team with matching name {0} but different pipeline id {1}, expected {2}. Purpose = '{3}'", team.Name, metadata?.PipelineId, pipeline.Id, metadata?.Purpose);
updateMetadataAndName = true;
return true;
}
return false;
});
if (result == default)
{
logger.LogInformation("Team Not Found purpose = {0}", purpose);
var teamMetadata = new TeamMetadata
{
PipelineId = pipeline.Id,
Purpose = purpose,
PipelineName = pipeline.Name,
};
var newTeam = new WebApiTeam
{
Description = YamlHelper.Serialize(teamMetadata),
Name = teamName
};
logger.LogInformation("Create Team for Pipeline PipelineId = {0} Purpose = {1} Name = '{2}'", pipeline.Id, purpose, teamName);
if (persistChanges)
{
result = await service.CreateTeamForProjectAsync(pipeline.Project.Id.ToString(), newTeam);
if (purpose == TeamPurpose.ParentNotificationTeam)
{
await EnsureScheduledBuildFailSubscriptionExists(pipeline, result, true);
}
}
}
else if (updateMetadataAndName)
{
var teamMetadata = new TeamMetadata
{
PipelineId = pipeline.Id,
Purpose = purpose,
};
result.Description = YamlHelper.Serialize(teamMetadata);
result.Name = teamName;
logger.LogInformation("Update Team for Pipeline PipelineId = {0} Purpose = {1} Name = '{2}'", pipeline.Id, purpose, teamName);
if (persistChanges)
{
result = await service.UpdateTeamForProjectAsync(pipeline.Project.Id.ToString(), result);
if (purpose == TeamPurpose.ParentNotificationTeam)
{
await EnsureScheduledBuildFailSubscriptionExists(pipeline, result, true);
}
}
}
if (purpose == TeamPurpose.SynchronizedNotificationTeam)
{
await SyncTeamWithCodeownersFile(pipeline, result, gitHubToAADConverter, persistChanges);
}
return result;
}
private async Task SyncTeamWithCodeownersFile(
BuildDefinition buildDefinition,
WebApiTeam team,
GitHubToAADConverter gitHubToAADConverter,
bool persistChanges)
{
using (logger.BeginScope("Team Name = {0}", team.Name))
{
List<string> contacts =
new Contacts(gitHubService, logger).GetFromBuildDefinitionRepoCodeowners(buildDefinition);
if (contacts == null)
{
// assert: the reason for why contacts is null has been already logged.
return;
}
// Get set of team members in the CODEOWNERS file
var contactsDescriptors = new List<string>();
foreach (string contact in contacts)
{
if (!contactsCache.ContainsKey(contact))
{
// TODO: Better to have retry if no success on this call.
var userPrincipal = await gitHubToAADConverter.GetUserPrincipalNameFromGithubAsync(contact);
if (!string.IsNullOrEmpty(userPrincipal))
{
contactsCache[contact] = await service.GetDescriptorForPrincipal(userPrincipal);
}
else
{
logger.LogInformation(
"Cannot find the user principal for GitHub contact '{contact}'",
contact);
contactsCache[contact] = null;
}
}
contactsDescriptors.Add(contactsCache[contact]);
}
var contactsSet = new HashSet<string>(contactsDescriptors);
// Get set of team members in the DevOps teams
var teamMembers = await service.GetMembersAsync(team);
var teamDescriptors = new List<String>();
foreach (var member in teamMembers)
{
if (!teamMemberCache.ContainsKey(member.Identity.Id))
{
var teamMemberDescriptor = (await service.GetUserFromId(new Guid(member.Identity.Id))).SubjectDescriptor.ToString();
teamMemberCache[member.Identity.Id] = teamMemberDescriptor;
}
teamDescriptors.Add(teamMemberCache[member.Identity.Id]);
}
var teamSet = new HashSet<string>(teamDescriptors);
var contactsToRemove = teamSet.Except(contactsSet);
var contactsToAdd = contactsSet.Except(teamSet);
foreach (string descriptor in contactsToRemove)
{
if (persistChanges && descriptor != null)
{
string teamDescriptor = await service.GetDescriptorAsync(team.Id);
logger.LogInformation("Delete Contact TeamDescriptor = {0}, ContactDescriptor = {1}", teamDescriptor, descriptor);
await service.RemoveMember(teamDescriptor, descriptor);
}
}
foreach (string descriptor in contactsToAdd)
{
if (persistChanges && descriptor != null)
{
string teamDescriptor = await service.GetDescriptorAsync(team.Id);
logger.LogInformation("Add Contact TeamDescriptor = {0}, ContactDescriptor = {1}", teamDescriptor, descriptor);
await service.AddToTeamAsync(teamDescriptor, descriptor);
}
}
}
}
private async Task<IEnumerable<BuildDefinition>> GetPipelinesAsync(string projectName, string projectPath, PipelineSelectionStrategy strategy)
{
var definitions = await service.GetPipelinesAsync(projectName, projectPath);
switch (strategy)
{
case PipelineSelectionStrategy.All:
return definitions;
case PipelineSelectionStrategy.Scheduled:
default:
return definitions.Where(
def => def.Triggers.Any(
trigger => trigger.TriggerType == DefinitionTriggerType.Schedule));
}
}
private async Task EnsureSynchronizedNotificationTeamIsChild(WebApiTeam parent, WebApiTeam child, bool persistChanges)
{
var parentDescriptor = await service.GetDescriptorAsync(parent.Id);
var childDescriptor = await service.GetDescriptorAsync(child.Id);
var isInTeam = await service.CheckMembershipAsync(parentDescriptor, childDescriptor);
logger.LogInformation("Child In Parent ParentId = {0}, ChildId = {1}, IsInTeam = {2}", parent.Id, child.Id, isInTeam);
if (!isInTeam)
{
logger.LogInformation("Adding Child Team");
if (persistChanges)
{
await service.AddToTeamAsync(parentDescriptor, childDescriptor);
}
}
}
private async Task EnsureScheduledBuildFailSubscriptionExists(BuildDefinition pipeline, WebApiTeam team, bool persistChanges)
{
const string BuildFailureNotificationTag = "#AutomaticBuildFailureNotification";
var subscriptions = await service.GetSubscriptionsAsync(team.Id);
var subscription = subscriptions.FirstOrDefault(sub => sub.Description.Contains(BuildFailureNotificationTag));
logger.LogInformation("Team Is Subscribed TeamName = {0} PipelineId = {1}", team.Name, pipeline.Id);
string definitionName = $"\\{pipeline.Project.Name}\\{pipeline.Name}";
if (subscription == default)
{
var filterModel = new ExpressionFilterModel
{
Clauses = new ExpressionFilterClause[]
{
new ExpressionFilterClause { Index = 1, LogicalOperator = "", FieldName = "Status", Operator = "=", Value = "Failed" },
new ExpressionFilterClause { Index = 2, LogicalOperator = "And", FieldName = "Definition name", Operator = "=", Value = definitionName },
new ExpressionFilterClause { Index = 3, LogicalOperator = "And", FieldName = "Build reason", Operator = "=", Value = "Scheduled" }
}
};
var filter = new ExpressionFilter("ms.vss-build.build-completed-event", filterModel);
var identity = new IdentityRef
{
Id = team.Id.ToString(),
Url = team.IdentityUrl
};
var newSubscription = new NotificationSubscriptionCreateParameters
{
Channel = new UserSubscriptionChannel { UseCustomAddress = false },
Description = $"A build fails {BuildFailureNotificationTag}",
Filter = filter,
Scope = new SubscriptionScope { Type = "none", Id = pipeline.Project.Id },
Subscriber = identity,
};
logger.LogInformation("Creating Subscription PipelineId = {0}, TeamId = {1}", pipeline.Id, team.Id);
if (persistChanges)
{
subscription = await service.CreateSubscriptionAsync(newSubscription);
}
}
else
{
var filter = subscription.Filter as ExpressionFilter;
if (filter == null)
{
logger.LogWarning("Subscription expression is not correct for of team {0}", team.Name);
return;
}
var definitionClause = filter.FilterModel.Clauses.FirstOrDefault(c => c.FieldName == "Definition name");
if (definitionClause == null)
{
logger.LogWarning("Subscription doesn't have correct expression filters for of team {0}", team.Name);
return;
}
if (definitionClause.Value != definitionName)
{
definitionClause.Value = definitionName;
if (persistChanges)
{
var updateParameters = new NotificationSubscriptionUpdateParameters()
{
Channel = subscription.Channel,
Description = subscription.Description,
Filter = subscription.Filter,
Scope = subscription.Scope,
};
logger.LogInformation("Updating Subscription expression for team {0} with correct definition name {1}", team.Name, definitionName);
subscription = await service.UpdatedSubscriptionAsync(updateParameters, subscription.Id.ToString());
}
}
}
}
}
}