From 8c871d97c59e9825053d532711526e3c584d5897 Mon Sep 17 00:00:00 2001 From: Gautam Sheth Date: Sun, 19 Jul 2026 00:27:42 +0300 Subject: [PATCH 1/3] Add Teams template support to New-PnPTeamsTeam Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + documentation/New-PnPTeamsTeam.md | 53 +++- src/Commands/Enums/TeamsTemplateType.cs | 2 + .../Model/Graph/TeamsAsyncOperation.cs | 49 +++ src/Commands/Teams/NewTeamsTeam.cs | 36 ++- src/Commands/Utilities/TeamsUtility.cs | 292 +++++++++++++++--- 6 files changed, 392 insertions(+), 41 deletions(-) create mode 100644 src/Commands/Model/Graph/TeamsAsyncOperation.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index de71694bde..86b10d7363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - Added `Add-PnPTenantSearchCrawledProperty` cmdlet which allows creation of a tenant level crawled property for SharePoint Online search. [#5373](https://github.com/pnp/powershell/pull/5373) - Added `GitLab CI/CD` support to `Connect-PnPOnline -FederatedIdentity`. [#5395](https://github.com/pnp/powershell/pull/5395) - Added `PreferredDataLocation` support to `Set-PnPMicrosoft365Group` and documented it for `Get-PnPMicrosoft365Group` and `New-PnPMicrosoft365Group`. [#5402](https://github.com/pnp/powershell/pull/5402) +- Added `Standard` and `EDU_Staff` template support to `New-PnPTeamsTeam` and provision Teams templates through Microsoft Graph team templates. [#999](https://github.com/pnp/powershell/issues/999) ### Changed diff --git a/documentation/New-PnPTeamsTeam.md b/documentation/New-PnPTeamsTeam.md index cc81571b93..8c291ccbab 100644 --- a/documentation/New-PnPTeamsTeam.md +++ b/documentation/New-PnPTeamsTeam.md @@ -14,6 +14,7 @@ online version: https://pnp.github.io/powershell/cmdlets/New-PnPTeamsTeam.html **Required Permissions** * Microsoft Graph API: Group.ReadWrite.All + * Microsoft Graph API: Team.Create when using -Template Creates a new team in Microsoft Teams or teamifies an existing Microsoft 365 Group. If the Microsoft 365 Group does not exist yet, it will create it first and then add a Microsoft Teams team to the group. If it does already exist, it will use the provided Microsoft 365 Group and just teamify it by adding a Microsoft Teams team to it. @@ -46,7 +47,8 @@ New-PnPTeamsTeam -DisplayName [-MailNickName ] [-Description ] [-Members ] [-ResourceBehaviorOptions ] [-SensitivityLabels ] - + [-Template ] + ``` ## DESCRIPTION @@ -98,6 +100,34 @@ New-PnPTeamsTeam -DisplayName "myPnPDemo1" -Visibility Private -Owners "user1@co This will create a new Microsoft Teams team called "myPnPDemo1" and sets the privacy to Private. User1 and user2 will be added as owners. User3 will be added as a member. The team will also get the sensitivity label value corresponding to the GUID specified. +### EXAMPLE 7 +```powershell +New-PnPTeamsTeam -DisplayName "Staff Team" -Visibility Private -Template EDU_Staff -Owners "principal@contoso.onmicrosoft.com" +``` + +This will create a new Microsoft Teams education staff team called "Staff Team" and adds the provided user as owner. Owners are required when using application permissions. + +### EXAMPLE 8 +```powershell +New-PnPTeamsTeam -DisplayName "Biology 101" -Template EDU_Class -Owners "teacher@contoso.onmicrosoft.com" -Members "student@contoso.onmicrosoft.com" +``` + +This will create a new Microsoft Teams education class team called "Biology 101". Microsoft Graph sets class team visibility to HiddenMembership. + +### EXAMPLE 9 +```powershell +New-PnPTeamsTeam -DisplayName "Math PLC" -Visibility Private -Template EDU_PLC -Owners "lead@contoso.onmicrosoft.com" +``` + +This will create a new Microsoft Teams education professional learning community team called "Math PLC". + +### EXAMPLE 10 +```powershell +New-PnPTeamsTeam -DisplayName "Project Team" -Visibility Private -Template Standard -Owners "owner@contoso.onmicrosoft.com" +``` + +This will create a new standard Microsoft Teams team through Microsoft Graph team templates. Omitting Template keeps the existing Microsoft 365 Group creation and teamify behavior. + ## PARAMETERS ### -AllowAddRemoveApps @@ -381,7 +411,7 @@ Accept wildcard characters: False ``` ### -MailNickName -The MailNickName parameter specifies the alias for the associated Microsoft 365 Group. This value will be used for the mail enabled object and will be used as PrimarySmtpAddress for this Microsoft 365 Group.The value of the MailNickName parameter has to be unique across your tenant. +The MailNickName parameter specifies the alias for the associated Microsoft 365 Group. This value will be used for the mail enabled object and will be used as PrimarySmtpAddress for this Microsoft 365 Group.The value of the MailNickName parameter has to be unique across your tenant. This parameter is ignored when used together with Template because Microsoft Graph creates the backing group. ```yaml Type: String @@ -468,7 +498,7 @@ Accept wildcard characters: False ### -ResourceBehaviorOptions -Allows providing ResourceBehaviorOptions which accepts multiple values that specify group behaviors for a Microsoft 365 Group. This will only work when you create a new Microsoft 365 Group, it will not work for existing groups. +Allows providing ResourceBehaviorOptions which accepts multiple values that specify group behaviors for a Microsoft 365 Group. This will only work when you create a new Microsoft 365 Group, it will not work for existing groups. This parameter cannot be used together with Template. ```yaml Type: TeamResourceBehaviorOptions @@ -483,7 +513,7 @@ Accept wildcard characters: False ``` ### -SensitivityLabels -The Sensitivity label to be set to the Microsoft 365 Group and Team. To retrieve the sensitivity label you need to use the Graph API mentioned [here](https://learn.microsoft.com/en-us/graph/api/informationprotectionlabel-get?view=graph-rest-beta&tabs=http). +The Sensitivity label to be set to the Microsoft 365 Group and Team. To retrieve the sensitivity label you need to use the Graph API mentioned [here](https://learn.microsoft.com/en-us/graph/api/informationprotectionlabel-get?view=graph-rest-beta&tabs=http). This parameter cannot be used together with Template. ```yaml Type: GUID[] @@ -495,7 +525,20 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -Template +The Microsoft Teams template to use for the new team. Use Standard for the standard team template, EDU_Class for the education class template, EDU_PLC for the education professional learning community template, or EDU_Staff for the education staff template. When this parameter is used, Microsoft Graph provisions the team using POST /teams with a teamsTemplates binding and creates the backing Microsoft 365 Group automatically. Owners are required when using application permissions. Omit this parameter to keep the existing Microsoft 365 Group creation and teamify behavior. + +```yaml +Type: TeamsTemplateType +Parameter Sets: For a new group +Accepted values: None, Standard, EDU_Class, EDU_PLC, EDU_Staff +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ## RELATED LINKS [Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) - diff --git a/src/Commands/Enums/TeamsTemplateType.cs b/src/Commands/Enums/TeamsTemplateType.cs index 23932e13b3..b8575bf43b 100644 --- a/src/Commands/Enums/TeamsTemplateType.cs +++ b/src/Commands/Enums/TeamsTemplateType.cs @@ -6,5 +6,7 @@ public enum TeamsTemplateType None, EDU_Class, EDU_PLC, + EDU_Staff, + Standard } } \ No newline at end of file diff --git a/src/Commands/Model/Graph/TeamsAsyncOperation.cs b/src/Commands/Model/Graph/TeamsAsyncOperation.cs new file mode 100644 index 0000000000..b4da14f51e --- /dev/null +++ b/src/Commands/Model/Graph/TeamsAsyncOperation.cs @@ -0,0 +1,49 @@ +namespace PnP.PowerShell.Commands.Model.Graph +{ + /// + /// Represents a Microsoft Teams async operation returned by Microsoft Graph. + /// + public class TeamsAsyncOperation + { + /// + /// Unique identifier of the operation. + /// + public string Id { get; set; } + + /// + /// Current status of the operation. + /// + public string Status { get; set; } + + /// + /// Identifier of the team that was created or modified by the operation. + /// + public string TargetResourceId { get; set; } + + /// + /// Location of the team that was created or modified by the operation. + /// + public string TargetResourceLocation { get; set; } + + /// + /// Error details returned when the operation fails. + /// + public TeamsAsyncOperationError Error { get; set; } + } + + /// + /// Represents Teams async operation error details returned by Microsoft Graph. + /// + public class TeamsAsyncOperationError + { + /// + /// Error code returned by Microsoft Graph. + /// + public string Code { get; set; } + + /// + /// Error message returned by Microsoft Graph. + /// + public string Message { get; set; } + } +} diff --git a/src/Commands/Teams/NewTeamsTeam.cs b/src/Commands/Teams/NewTeamsTeam.cs index 90ffffde0b..75e17ee06a 100644 --- a/src/Commands/Teams/NewTeamsTeam.cs +++ b/src/Commands/Teams/NewTeamsTeam.cs @@ -8,11 +8,13 @@ using PnP.PowerShell.Commands.Utilities; using System; using System.Management.Automation; +using ConnectionMethod = PnP.PowerShell.Commands.Model.ConnectionMethod; namespace PnP.PowerShell.Commands.Teams { [Cmdlet(VerbsCommon.New, "PnPTeamsTeam")] [RequiredApiDelegatedOrApplicationPermissions("graph/Group.ReadWrite.All")] + [RequiredApiDelegatedOrApplicationPermissions("graph/Team.Create")] public class NewTeamsTeam : PnPGraphCmdlet { private const string ParameterSet_EXISTINGGROUP = "For an existing group"; @@ -109,6 +111,31 @@ public class NewTeamsTeam : PnPGraphCmdlet protected override void ExecuteCmdlet() { + var contextSettings = Connection.Context.GetContextSettings(); + + if (Template != TeamsTemplateType.None) + { + if (ParameterSpecified(nameof(MailNickName))) + { + LogWarning($"{nameof(MailNickName)} is ignored when using {nameof(Template)} because Microsoft Graph creates the backing group when provisioning a team from a template."); + } + + if (ParameterSpecified(nameof(ResourceBehaviorOptions)) || ParameterSpecified(nameof(SensitivityLabels))) + { + throw new PSInvalidOperationException($"{nameof(ResourceBehaviorOptions)} and {nameof(SensitivityLabels)} are not supported when using {nameof(Template)} because Microsoft Graph creates the backing group when provisioning a team from a template."); + } + + if (IsApplicationConnection(Connection.ConnectionMethod) && (Owners == null || Owners.Length == 0)) + { + throw new PSInvalidOperationException($"{nameof(Owners)} is required when using {nameof(Template)} with application permissions because Microsoft Graph requires a user in the members collection when creating a team with application permissions."); + } + + if (Template == TeamsTemplateType.EDU_Class && ParameterSpecified(nameof(Visibility))) + { + LogWarning("The EDU_Class template sets team visibility to HiddenMembership and does not allow overriding it. The Visibility value will be ignored by Microsoft Graph."); + } + } + var teamCI = new TeamCreationInformation() { AllowAddRemoveApps = AllowAddRemoveApps, @@ -136,7 +163,6 @@ protected override void ExecuteCmdlet() AllowCreatePrivateChannels = AllowCreatePrivateChannels }; - var contextSettings = Connection.Context.GetContextSettings(); if (contextSettings.Type == Framework.Utilities.Context.ClientContextType.AzureADCertificate) { if (SensitivityLabels != null && SensitivityLabels.Length > 0) @@ -148,5 +174,13 @@ protected override void ExecuteCmdlet() WriteObject(TeamsUtility.NewTeam(GraphRequestHelper, GroupId, DisplayName, Description, Classification, MailNickName, (GroupVisibility)Enum.Parse(typeof(GroupVisibility), Visibility.ToString()), teamCI, Owners, Members, SensitivityLabels, Template, ResourceBehaviorOptions)); } + + private static bool IsApplicationConnection(ConnectionMethod connectionMethod) + { + return connectionMethod == ConnectionMethod.AzureADAppOnly || + connectionMethod == ConnectionMethod.ManagedIdentity || + connectionMethod == ConnectionMethod.AzureADWorkloadIdentity || + connectionMethod == ConnectionMethod.FederatedIdentity; + } } } diff --git a/src/Commands/Utilities/TeamsUtility.cs b/src/Commands/Utilities/TeamsUtility.cs index be71d33996..9e5be5d56b 100644 --- a/src/Commands/Utilities/TeamsUtility.cs +++ b/src/Commands/Utilities/TeamsUtility.cs @@ -8,8 +8,10 @@ using System.Collections.Generic; using System.Linq; using System.Management.Automation; +using System.Net; using System.Net.Http; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using Group = PnP.PowerShell.Commands.Model.Graph.Group; using Team = PnP.PowerShell.Commands.Model.Teams.Team; @@ -21,6 +23,8 @@ namespace PnP.PowerShell.Commands.Utilities internal static class TeamsUtility { private const int PageSize = 100; + private const int TeamsAsyncOperationPollingIntervalSeconds = 30; + private const int TeamsAsyncOperationMaxRetries = 12; #region Team public static List GetGroupsWithTeam(ApiRequestHelper requestHelper, string filter = null) @@ -147,6 +151,11 @@ private static Team ParseTeamJson(ApiRequestHelper requestHelper, string groupId public static Team NewTeam(ApiRequestHelper requestHelper, string groupId, string displayName, string description, string classification, string mailNickname, GroupVisibility visibility, TeamCreationInformation teamCI, string[] owners, string[] members, Guid[] sensitivityLabels, TeamsTemplateType templateType = TeamsTemplateType.None, TeamResourceBehaviorOptions?[] resourceBehaviorOptions = null) { + if (string.IsNullOrEmpty(groupId) && templateType != TeamsTemplateType.None) + { + return CreateTeamFromTemplate(requestHelper, displayName, description, classification, visibility, teamCI, owners, members, templateType); + } + Group group = null; Team returnTeam = null; Random random = new(); @@ -155,7 +164,7 @@ public static Team NewTeam(ApiRequestHelper requestHelper, string groupId, strin // Create the Group if (string.IsNullOrEmpty(groupId)) { - group = CreateGroup(requestHelper, displayName, description, classification, mailNickname, visibility, owners, sensitivityLabels, templateType, resourceBehaviorOptions); + group = CreateGroup(requestHelper, displayName, description, classification, mailNickname, visibility, owners, sensitivityLabels, resourceBehaviorOptions); bool wait = true; int iterations = 0; @@ -244,34 +253,262 @@ public static Team NewTeam(ApiRequestHelper requestHelper, string groupId, strin } } - // Construct a list of all owners and members to add - var teamOwnersAndMembers = new List(); - if (owners != null && owners.Length > 0) + AddTeamOwnersAndMembers(requestHelper, group.Id, owners, members); + } + return returnTeam; + } + + private static Team CreateTeamFromTemplate(ApiRequestHelper requestHelper, string displayName, string description, string classification, GroupVisibility visibility, TeamCreationInformation teamCI, string[] owners, string[] members, TeamsTemplateType templateType) + { + var teamVisibility = visibility == GroupVisibility.NotSpecified ? GroupVisibility.Private : visibility; + var team = teamCI.ToTeam(teamVisibility); + var payload = new Dictionary + { + { "template@odata.bind", $"https://{requestHelper.GraphEndPoint}/v1.0/teamsTemplates('{GetTeamsTemplateId(templateType)}')" }, + { "displayName", displayName }, + { "description", description ?? string.Empty } + }; + + if (!string.IsNullOrEmpty(classification)) + { + payload.Add("classification", classification); + } + + if (templateType != TeamsTemplateType.EDU_Class) + { + payload.Add("visibility", teamVisibility.ToString()); + } + + if (HasMemberSettings(team.MemberSettings)) + { + payload.Add("memberSettings", team.MemberSettings); + } + + if (HasGuestSettings(team.GuestSettings)) + { + payload.Add("guestSettings", team.GuestSettings); + } + + if (HasMessagingSettings(team.MessagingSettings)) + { + payload.Add("messagingSettings", team.MessagingSettings); + } + + if (HasFunSettings(team.FunSettings)) + { + payload.Add("funSettings", team.FunSettings); + } + + if (HasDiscoverySettings(team.DiscoverySettings)) + { + payload.Add("discoverySettings", team.DiscoverySettings); + } + + var teamMembers = CreateInitialTeamMembers(requestHelper, owners, members); + if (teamMembers.Count > 0) + { + payload.Add("members", teamMembers); + } + + var response = requestHelper.PostHttpContent("v1.0/teams", CreateJsonContent(payload)); + var operation = WaitForTeamsAsyncOperation(requestHelper, response); + + if (string.IsNullOrEmpty(operation.TargetResourceId)) + { + throw new PSInvalidOperationException("Microsoft Graph completed the team creation operation without returning a target team identifier."); + } + + team.GroupId = operation.TargetResourceId; + team.DisplayName = displayName; + team.Description = description; + team.Classification = classification; + team.Visibility = templateType == TeamsTemplateType.EDU_Class ? GroupVisibility.HiddenMembership : teamVisibility; + team.Specialization = GetTeamSpecialization(templateType); + return team; + } + + private static List CreateInitialTeamMembers(ApiRequestHelper requestHelper, string[] owners, string[] members) + { + var teamMembers = new List(); + var ownerIdentifiers = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (owners != null && owners.Length > 0) + { + foreach (var owner in owners.Distinct(StringComparer.OrdinalIgnoreCase)) { - foreach (var owner in owners) - { - teamOwnersAndMembers.Add(new TeamChannelMember { Roles = new List { "owner" }, UserIdentifier = $"https://{requestHelper.GraphEndPoint}/v1.0/users('{owner}')" }); - } + ownerIdentifiers.Add(owner); + teamMembers.Add(new TeamChannelMember { Roles = new List { "owner" }, UserIdentifier = GetUserODataBindUrl(requestHelper, owner) }); } + } - if (members != null && members.Length > 0) + if (members != null && members.Length > 0) + { + foreach (var member in members.Distinct(StringComparer.OrdinalIgnoreCase)) { - foreach (var member in members) + if (!ownerIdentifiers.Contains(member)) { - teamOwnersAndMembers.Add(new TeamChannelMember { Roles = new List(), UserIdentifier = $"https://{requestHelper.GraphEndPoint}/v1.0/users('{member}')" }); + teamMembers.Add(new TeamChannelMember { Roles = new List(), UserIdentifier = GetUserODataBindUrl(requestHelper, member) }); } } + } - if (teamOwnersAndMembers.Count > 0) + return teamMembers; + } + + private static string GetUserODataBindUrl(ApiRequestHelper requestHelper, string user) + { + return $"https://{requestHelper.GraphEndPoint}/v1.0/users('{user}')"; + } + + private static StringContent CreateJsonContent(object payload) + { + var content = new StringContent(JsonSerializer.Serialize(payload, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + return content; + } + + private static TeamsAsyncOperation WaitForTeamsAsyncOperation(ApiRequestHelper requestHelper, HttpResponseMessage response) + { + if (!response.Headers.TryGetValues("Location", out var locations) || !locations.Any()) + { + throw new PSInvalidOperationException("Microsoft Graph did not return a Location header for the team creation operation."); + } + + var operationStatusUrl = locations.First(); + TeamsAsyncOperation operation = null; + for (var retryCount = 0; retryCount < TeamsAsyncOperationMaxRetries; retryCount++) + { + if (retryCount > 0) { - var ownersAndMembers = GraphBatchUtility.Chunk(teamOwnersAndMembers, 200); - foreach (var chunk in ownersAndMembers) - { - requestHelper.Post($"v1.0/teams/{group.Id}/members/add", new { values = chunk.ToList() }); - } + Thread.Sleep(TimeSpan.FromSeconds(TeamsAsyncOperationPollingIntervalSeconds)); + } + + try + { + operation = requestHelper.Get(operationStatusUrl); + } + catch (GraphException ex) when (IsTransientTeamsAsyncOperationError(ex) && retryCount < TeamsAsyncOperationMaxRetries - 1) + { + continue; + } + + if (operation == null || string.IsNullOrEmpty(operation.Status)) + { + continue; + } + + if (operation.Status.Equals("succeeded", StringComparison.OrdinalIgnoreCase) || operation.Status.Equals("completed", StringComparison.OrdinalIgnoreCase)) + { + return operation; + } + + if (operation.Status.Equals("failed", StringComparison.OrdinalIgnoreCase)) + { + var errorMessage = !string.IsNullOrEmpty(operation.Error?.Message) ? $": {operation.Error.Message}" : string.Empty; + throw new PSInvalidOperationException($"Microsoft Graph team creation operation failed{errorMessage}"); } } - return returnTeam; + + throw new PSInvalidOperationException($"Timed out waiting for Microsoft Graph team creation operation at {operationStatusUrl} to complete."); + } + + private static bool IsTransientTeamsAsyncOperationError(GraphException ex) + { + return ex.HttpResponse?.StatusCode == HttpStatusCode.NotFound || + ex.HttpResponse?.StatusCode == HttpStatusCode.BadGateway || + ex.HttpResponse?.StatusCode == HttpStatusCode.ServiceUnavailable || + ex.HttpResponse?.StatusCode == HttpStatusCode.GatewayTimeout; + } + + private static string GetTeamsTemplateId(TeamsTemplateType templateType) + { + return templateType switch + { + TeamsTemplateType.Standard => "standard", + TeamsTemplateType.EDU_Class => "educationClass", + TeamsTemplateType.EDU_PLC => "educationProfessionalLearningCommunity", + TeamsTemplateType.EDU_Staff => "educationStaff", + _ => "standard" + }; + } + + private static TeamSpecialization GetTeamSpecialization(TeamsTemplateType templateType) + { + return templateType switch + { + TeamsTemplateType.Standard => TeamSpecialization.None, + TeamsTemplateType.EDU_Class => TeamSpecialization.EducationClass, + TeamsTemplateType.EDU_PLC => TeamSpecialization.EducationProfessionalLearningCommunity, + TeamsTemplateType.EDU_Staff => TeamSpecialization.EducationStaff, + _ => TeamSpecialization.None + }; + } + + private static void AddTeamOwnersAndMembers(ApiRequestHelper requestHelper, string groupId, string[] owners, string[] members) + { + var teamOwnersAndMembers = new List(); + if (owners != null && owners.Length > 0) + { + foreach (var owner in owners) + { + teamOwnersAndMembers.Add(new TeamChannelMember { Roles = new List { "owner" }, UserIdentifier = GetUserODataBindUrl(requestHelper, owner) }); + } + } + + if (members != null && members.Length > 0) + { + foreach (var member in members) + { + teamOwnersAndMembers.Add(new TeamChannelMember { Roles = new List(), UserIdentifier = GetUserODataBindUrl(requestHelper, member) }); + } + } + + if (teamOwnersAndMembers.Count > 0) + { + var ownersAndMembers = GraphBatchUtility.Chunk(teamOwnersAndMembers, 200); + foreach (var chunk in ownersAndMembers) + { + requestHelper.Post($"v1.0/teams/{groupId}/members/add", new { values = chunk.ToList() }); + } + } + } + + private static bool HasMemberSettings(TeamMemberSettings memberSettings) + { + return memberSettings.AllowCreateUpdateChannels.HasValue || + memberSettings.AllowDeleteChannels.HasValue || + memberSettings.AllowAddRemoveApps.HasValue || + memberSettings.AllowCreateUpdateRemoveTabs.HasValue || + memberSettings.AllowCreateUpdateRemoveConnectors.HasValue || + memberSettings.AllowCreatePrivateChannels.HasValue; + } + + private static bool HasGuestSettings(TeamGuestSettings guestSettings) + { + return guestSettings.AllowCreateUpdateChannels.HasValue || + guestSettings.AllowDeleteChannels.HasValue; + } + + private static bool HasMessagingSettings(TeamMessagingSettings messagingSettings) + { + return messagingSettings.AllowUserEditMessages.HasValue || + messagingSettings.AllowUserDeleteMessages.HasValue || + messagingSettings.AllowOwnerDeleteMessages.HasValue || + messagingSettings.AllowTeamMentions.HasValue || + messagingSettings.AllowChannelMentions.HasValue; + } + + private static bool HasFunSettings(TeamFunSettings funSettings) + { + return funSettings.AllowGiphy.HasValue || + funSettings.AllowCustomMemes.HasValue || + funSettings.AllowStickersAndMemes.HasValue || + funSettings.GiphyContentRating != default; + } + + private static bool HasDiscoverySettings(TeamDiscoverySettings discoverySettings) + { + return discoverySettings.ShowInTeamsSearchAndSuggestions.HasValue; } internal static string GetUserGraphUrlForUPN(string upn) @@ -284,7 +521,7 @@ internal static string GetUserGraphUrlForUPN(string upn) return $"users/{escapedUpn}"; } - private static Group CreateGroup(ApiRequestHelper requestHelper, string displayName, string description, string classification, string mailNickname, GroupVisibility visibility, string[] owners, Guid[] sensitivityLabels, TeamsTemplateType templateType = TeamsTemplateType.None, TeamResourceBehaviorOptions?[] resourceBehaviorOptions = null) + private static Group CreateGroup(ApiRequestHelper requestHelper, string displayName, string description, string classification, string mailNickname, GroupVisibility visibility, string[] owners, Guid[] sensitivityLabels, TeamResourceBehaviorOptions?[] resourceBehaviorOptions = null) { // When creating a group, we always need an owner, thus we'll try to define it from the passed in owners array string ownerId = null; @@ -376,22 +613,7 @@ private static Group CreateGroup(ApiRequestHelper requestHelper, string displayN group.AssignedLabels = assignedLabels; } - switch (templateType) - { - case TeamsTemplateType.EDU_Class: - group.Visibility = GroupVisibility.HiddenMembership; - group.CreationOptions = new List { "ExchangeProvisioningFlags:461", "classAssignments" }; - group.EducationObjectType = "Section"; - break; - - case TeamsTemplateType.EDU_PLC: - group.CreationOptions = new List { "PLC" }; - break; - - default: - group.CreationOptions = new List { "ExchangeProvisioningFlags:3552" }; - break; - } + group.CreationOptions = new List { "ExchangeProvisioningFlags:3552" }; try { return requestHelper.Post("v1.0/groups", group); From ead94de67650a540c9fb0bd832ee777064ca49af Mon Sep 17 00:00:00 2001 From: Gautam Sheth Date: Sun, 19 Jul 2026 00:40:37 +0300 Subject: [PATCH 2/3] Address Teams template PR review comments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- documentation/New-PnPTeamsTeam.md | 32 +++++------ src/Commands/Model/Teams/TeamSettings.cs | 4 ++ src/Commands/Teams/NewTeamsTeam.cs | 1 + src/Commands/Utilities/TeamsUtility.cs | 68 ++++++++++++++++++++---- 4 files changed, 78 insertions(+), 27 deletions(-) diff --git a/documentation/New-PnPTeamsTeam.md b/documentation/New-PnPTeamsTeam.md index 8c291ccbab..f86a4f272e 100644 --- a/documentation/New-PnPTeamsTeam.md +++ b/documentation/New-PnPTeamsTeam.md @@ -440,6 +440,20 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -Template +The Microsoft Teams template to use for the new team. Use Standard for the standard team template, EDU_Class for the education class template, EDU_PLC for the education professional learning community template, or EDU_Staff for the education staff template. When this parameter is used, Microsoft Graph provisions the team using POST /teams with a teamsTemplates binding and creates the backing Microsoft 365 Group automatically. Owners are required when using application permissions. Microsoft Graph allows at most 20 distinct users across Owners and Members in the initial members collection when using Template. Omit this parameter to keep the existing Microsoft 365 Group creation and teamify behavior. + +```yaml +Type: TeamsTemplateType +Parameter Sets: For a new group +Accepted values: None, Standard, EDU_Class, EDU_PLC, EDU_Staff +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Visibility Set to Public to allow all users in your organization to join the group by default. Set to Private to require that an owner approve the join request. @@ -470,7 +484,7 @@ Accept wildcard characters: False ``` ### -Owners -The User Principal Name(s) of the user(s) to be added to the Microsoft 365 Group as owners. If omitted and the cmdlet is run using a token containing a user identity, such as when logging on with -Interactive or -DeviceLogin, the user used to authenticate with would become the owner. You can provide as many owners as you want, as long as you stay within the [Microsoft 365 Groups limits](https://learn.microsoft.com/microsoft-365/admin/create-groups/office-365-groups?view=o365-worldwide#group-limits). Notice that e-mail addresses are not accepted, if they differ from the User Principal Name on the same account. +The User Principal Name(s) of the user(s) to be added to the Microsoft 365 Group as owners. If omitted and the cmdlet is run using a token containing a user identity, such as when logging on with -Interactive or -DeviceLogin, the user used to authenticate with would become the owner. You can provide as many owners as you want, as long as you stay within the [Microsoft 365 Groups limits](https://learn.microsoft.com/microsoft-365/admin/create-groups/office-365-groups?view=o365-worldwide#group-limits). When using Template, Microsoft Graph allows at most 20 distinct users across Owners and Members in the initial members collection. Notice that e-mail addresses are not accepted, if they differ from the User Principal Name on the same account. ```yaml Type: String[] @@ -484,7 +498,7 @@ Accept wildcard characters: False ``` ### -Members -The User Principal Name(s) of the user(s) to be added to the Microsoft 365 Group as members. You can provide as many members as you want, as long as you stay within the [Microsoft 365 Groups limits](https://learn.microsoft.com/microsoft-365/admin/create-groups/office-365-groups?view=o365-worldwide#group-limits). Notice that e-mail addresses are not accepted, if they differ from the User Principal Name on the same account. +The User Principal Name(s) of the user(s) to be added to the Microsoft 365 Group as members. You can provide as many members as you want, as long as you stay within the [Microsoft 365 Groups limits](https://learn.microsoft.com/microsoft-365/admin/create-groups/office-365-groups?view=o365-worldwide#group-limits). When using Template, Microsoft Graph allows at most 20 distinct users across Owners and Members in the initial members collection. Notice that e-mail addresses are not accepted, if they differ from the User Principal Name on the same account. ```yaml Type: String[] @@ -525,20 +539,6 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -Template -The Microsoft Teams template to use for the new team. Use Standard for the standard team template, EDU_Class for the education class template, EDU_PLC for the education professional learning community template, or EDU_Staff for the education staff template. When this parameter is used, Microsoft Graph provisions the team using POST /teams with a teamsTemplates binding and creates the backing Microsoft 365 Group automatically. Owners are required when using application permissions. Omit this parameter to keep the existing Microsoft 365 Group creation and teamify behavior. - -```yaml -Type: TeamsTemplateType -Parameter Sets: For a new group -Accepted values: None, Standard, EDU_Class, EDU_PLC, EDU_Staff -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ## RELATED LINKS [Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) diff --git a/src/Commands/Model/Teams/TeamSettings.cs b/src/Commands/Model/Teams/TeamSettings.cs index 6b996bd51e..89a2e19b18 100644 --- a/src/Commands/Model/Teams/TeamSettings.cs +++ b/src/Commands/Model/Teams/TeamSettings.cs @@ -23,6 +23,9 @@ public class TeamCreationInformation [JsonConverter(typeof(JsonStringEnumConverter))] public TeamGiphyContentRating GiphyContentRating { get; set; } + [JsonIgnore] + public bool GiphyContentRatingSpecified { get; set; } + public bool? AllowStickersAndMemes { get; set; } public bool? AllowCustomMemes { get; set; } @@ -69,6 +72,7 @@ public TeamCreationInformation(Team team) Classification = team.Classification; AllowGiphy = team.FunSettings.AllowGiphy; GiphyContentRating = team.FunSettings.GiphyContentRating; + GiphyContentRatingSpecified = true; AllowStickersAndMemes = team.FunSettings.AllowStickersAndMemes; AllowCustomMemes = team.FunSettings.AllowCustomMemes; AllowGuestCreateUpdateChannels = team.GuestSettings.AllowCreateUpdateChannels; diff --git a/src/Commands/Teams/NewTeamsTeam.cs b/src/Commands/Teams/NewTeamsTeam.cs index 75e17ee06a..11fbeb7472 100644 --- a/src/Commands/Teams/NewTeamsTeam.cs +++ b/src/Commands/Teams/NewTeamsTeam.cs @@ -157,6 +157,7 @@ protected override void ExecuteCmdlet() Description = Description, DisplayName = DisplayName, GiphyContentRating = GiphyContentRating, + GiphyContentRatingSpecified = ParameterSpecified(nameof(GiphyContentRating)), GroupId = GroupId, ShowInTeamsSearchAndSuggestions = ShowInTeamsSearchAndSuggestions, Visibility = (GroupVisibility)Enum.Parse(typeof(GroupVisibility), Visibility.ToString()), diff --git a/src/Commands/Utilities/TeamsUtility.cs b/src/Commands/Utilities/TeamsUtility.cs index 9e5be5d56b..aaf1daea79 100644 --- a/src/Commands/Utilities/TeamsUtility.cs +++ b/src/Commands/Utilities/TeamsUtility.cs @@ -25,6 +25,7 @@ internal static class TeamsUtility private const int PageSize = 100; private const int TeamsAsyncOperationPollingIntervalSeconds = 30; private const int TeamsAsyncOperationMaxRetries = 12; + private const int TeamsTemplateInitialMembersLimit = 20; #region Team public static List GetGroupsWithTeam(ApiRequestHelper requestHelper, string filter = null) @@ -294,9 +295,9 @@ private static Team CreateTeamFromTemplate(ApiRequestHelper requestHelper, strin payload.Add("messagingSettings", team.MessagingSettings); } - if (HasFunSettings(team.FunSettings)) + if (HasFunSettings(teamCI)) { - payload.Add("funSettings", team.FunSettings); + payload.Add("funSettings", CreateFunSettingsPayload(teamCI)); } if (HasDiscoverySettings(team.DiscoverySettings)) @@ -305,13 +306,19 @@ private static Team CreateTeamFromTemplate(ApiRequestHelper requestHelper, strin } var teamMembers = CreateInitialTeamMembers(requestHelper, owners, members); + ValidateInitialTeamMembersLimit(teamMembers); if (teamMembers.Count > 0) { payload.Add("members", teamMembers); } - var response = requestHelper.PostHttpContent("v1.0/teams", CreateJsonContent(payload)); - var operation = WaitForTeamsAsyncOperation(requestHelper, response); + string operationStatusUrl; + using (var response = requestHelper.PostHttpContent("v1.0/teams", CreateJsonContent(payload))) + { + operationStatusUrl = GetTeamsAsyncOperationStatusUrl(response); + } + + var operation = WaitForTeamsAsyncOperation(requestHelper, operationStatusUrl); if (string.IsNullOrEmpty(operation.TargetResourceId)) { @@ -355,6 +362,14 @@ private static List CreateInitialTeamMembers(ApiRequestHelper return teamMembers; } + private static void ValidateInitialTeamMembersLimit(IReadOnlyCollection teamMembers) + { + if (teamMembers.Count > TeamsTemplateInitialMembersLimit) + { + throw new PSInvalidOperationException($"Microsoft Graph supports a maximum of {TeamsTemplateInitialMembersLimit} distinct owners and members in the initial members collection when creating a team from a template. Specify no more than {TeamsTemplateInitialMembersLimit} combined owners and members when using the Template parameter."); + } + } + private static string GetUserODataBindUrl(ApiRequestHelper requestHelper, string user) { return $"https://{requestHelper.GraphEndPoint}/v1.0/users('{user}')"; @@ -367,14 +382,18 @@ private static StringContent CreateJsonContent(object payload) return content; } - private static TeamsAsyncOperation WaitForTeamsAsyncOperation(ApiRequestHelper requestHelper, HttpResponseMessage response) + private static string GetTeamsAsyncOperationStatusUrl(HttpResponseMessage response) { if (!response.Headers.TryGetValues("Location", out var locations) || !locations.Any()) { throw new PSInvalidOperationException("Microsoft Graph did not return a Location header for the team creation operation."); } - var operationStatusUrl = locations.First(); + return locations.First(); + } + + private static TeamsAsyncOperation WaitForTeamsAsyncOperation(ApiRequestHelper requestHelper, string operationStatusUrl) + { TeamsAsyncOperation operation = null; for (var retryCount = 0; retryCount < TeamsAsyncOperationMaxRetries; retryCount++) { @@ -498,12 +517,39 @@ private static bool HasMessagingSettings(TeamMessagingSettings messagingSettings messagingSettings.AllowChannelMentions.HasValue; } - private static bool HasFunSettings(TeamFunSettings funSettings) + private static bool HasFunSettings(TeamCreationInformation teamCI) + { + return teamCI.AllowGiphy.HasValue || + teamCI.AllowCustomMemes.HasValue || + teamCI.AllowStickersAndMemes.HasValue || + teamCI.GiphyContentRatingSpecified; + } + + private static Dictionary CreateFunSettingsPayload(TeamCreationInformation teamCI) { - return funSettings.AllowGiphy.HasValue || - funSettings.AllowCustomMemes.HasValue || - funSettings.AllowStickersAndMemes.HasValue || - funSettings.GiphyContentRating != default; + var funSettings = new Dictionary(); + + if (teamCI.AllowGiphy.HasValue) + { + funSettings.Add("allowGiphy", teamCI.AllowGiphy.Value); + } + + if (teamCI.GiphyContentRatingSpecified) + { + funSettings.Add("giphyContentRating", teamCI.GiphyContentRating.ToString()); + } + + if (teamCI.AllowStickersAndMemes.HasValue) + { + funSettings.Add("allowStickersAndMemes", teamCI.AllowStickersAndMemes.Value); + } + + if (teamCI.AllowCustomMemes.HasValue) + { + funSettings.Add("allowCustomMemes", teamCI.AllowCustomMemes.Value); + } + + return funSettings; } private static bool HasDiscoverySettings(TeamDiscoverySettings discoverySettings) From b24d0cd90a6fddb8d4bfc2c2375767e6b3e226e9 Mon Sep 17 00:00:00 2001 From: Gautam Sheth Date: Mon, 20 Jul 2026 21:13:44 +0300 Subject: [PATCH 3/3] Some more changes --- CHANGELOG.md | 2 +- documentation/New-PnPTeamsTeam.md | 6 +-- src/Commands/Utilities/TeamsUtility.cs | 60 ++++++++++++++++++++------ 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86b10d7363..2b13af3257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - Added `Add-PnPTenantSearchCrawledProperty` cmdlet which allows creation of a tenant level crawled property for SharePoint Online search. [#5373](https://github.com/pnp/powershell/pull/5373) - Added `GitLab CI/CD` support to `Connect-PnPOnline -FederatedIdentity`. [#5395](https://github.com/pnp/powershell/pull/5395) - Added `PreferredDataLocation` support to `Set-PnPMicrosoft365Group` and documented it for `Get-PnPMicrosoft365Group` and `New-PnPMicrosoft365Group`. [#5402](https://github.com/pnp/powershell/pull/5402) -- Added `Standard` and `EDU_Staff` template support to `New-PnPTeamsTeam` and provision Teams templates through Microsoft Graph team templates. [#999](https://github.com/pnp/powershell/issues/999) +- Added `Standard` and `EDU_Staff` template support to `New-PnPTeamsTeam` and provision Teams templates through Microsoft Graph team templates. Note that `EDU_Class` and `EDU_PLC` are now also provisioned through Microsoft Graph team templates (`POST /teams`) instead of the previous Microsoft 365 Group creation and teamify flow. [#999](https://github.com/pnp/powershell/issues/999) ### Changed diff --git a/documentation/New-PnPTeamsTeam.md b/documentation/New-PnPTeamsTeam.md index f86a4f272e..a9c1520a2b 100644 --- a/documentation/New-PnPTeamsTeam.md +++ b/documentation/New-PnPTeamsTeam.md @@ -441,7 +441,7 @@ Accept wildcard characters: False ``` ### -Template -The Microsoft Teams template to use for the new team. Use Standard for the standard team template, EDU_Class for the education class template, EDU_PLC for the education professional learning community template, or EDU_Staff for the education staff template. When this parameter is used, Microsoft Graph provisions the team using POST /teams with a teamsTemplates binding and creates the backing Microsoft 365 Group automatically. Owners are required when using application permissions. Microsoft Graph allows at most 20 distinct users across Owners and Members in the initial members collection when using Template. Omit this parameter to keep the existing Microsoft 365 Group creation and teamify behavior. +The Microsoft Teams template to use for the new team. Use Standard for the standard team template, EDU_Class for the education class template, EDU_PLC for the education professional learning community template, or EDU_Staff for the education staff template. When this parameter is used, Microsoft Graph provisions the team using POST /teams with a teamsTemplates binding and creates the backing Microsoft 365 Group automatically. Owners are required when using application permissions. Owners and Members are added to the initial members collection when provisioning the team; Microsoft Graph enforces its own limit on the number of users allowed in that collection. Omit this parameter to keep the existing Microsoft 365 Group creation and teamify behavior. ```yaml Type: TeamsTemplateType @@ -484,7 +484,7 @@ Accept wildcard characters: False ``` ### -Owners -The User Principal Name(s) of the user(s) to be added to the Microsoft 365 Group as owners. If omitted and the cmdlet is run using a token containing a user identity, such as when logging on with -Interactive or -DeviceLogin, the user used to authenticate with would become the owner. You can provide as many owners as you want, as long as you stay within the [Microsoft 365 Groups limits](https://learn.microsoft.com/microsoft-365/admin/create-groups/office-365-groups?view=o365-worldwide#group-limits). When using Template, Microsoft Graph allows at most 20 distinct users across Owners and Members in the initial members collection. Notice that e-mail addresses are not accepted, if they differ from the User Principal Name on the same account. +The User Principal Name(s) of the user(s) to be added to the Microsoft 365 Group as owners. If omitted and the cmdlet is run using a token containing a user identity, such as when logging on with -Interactive or -DeviceLogin, the user used to authenticate with would become the owner. You can provide as many owners as you want, as long as you stay within the [Microsoft 365 Groups limits](https://learn.microsoft.com/microsoft-365/admin/create-groups/office-365-groups?view=o365-worldwide#group-limits). When using Template, Owners and Members are added to the initial members collection and Microsoft Graph enforces its own limit on the number of users allowed in that collection. Notice that e-mail addresses are not accepted, if they differ from the User Principal Name on the same account. ```yaml Type: String[] @@ -498,7 +498,7 @@ Accept wildcard characters: False ``` ### -Members -The User Principal Name(s) of the user(s) to be added to the Microsoft 365 Group as members. You can provide as many members as you want, as long as you stay within the [Microsoft 365 Groups limits](https://learn.microsoft.com/microsoft-365/admin/create-groups/office-365-groups?view=o365-worldwide#group-limits). When using Template, Microsoft Graph allows at most 20 distinct users across Owners and Members in the initial members collection. Notice that e-mail addresses are not accepted, if they differ from the User Principal Name on the same account. +The User Principal Name(s) of the user(s) to be added to the Microsoft 365 Group as members. You can provide as many members as you want, as long as you stay within the [Microsoft 365 Groups limits](https://learn.microsoft.com/microsoft-365/admin/create-groups/office-365-groups?view=o365-worldwide#group-limits). When using Template, Owners and Members are added to the initial members collection and Microsoft Graph enforces its own limit on the number of users allowed in that collection. Notice that e-mail addresses are not accepted, if they differ from the User Principal Name on the same account. ```yaml Type: String[] diff --git a/src/Commands/Utilities/TeamsUtility.cs b/src/Commands/Utilities/TeamsUtility.cs index aaf1daea79..6e56ad2f21 100644 --- a/src/Commands/Utilities/TeamsUtility.cs +++ b/src/Commands/Utilities/TeamsUtility.cs @@ -25,7 +25,8 @@ internal static class TeamsUtility private const int PageSize = 100; private const int TeamsAsyncOperationPollingIntervalSeconds = 30; private const int TeamsAsyncOperationMaxRetries = 12; - private const int TeamsTemplateInitialMembersLimit = 20; + private const int TeamsTemplateGetTeamRetryIntervalSeconds = 5; + private const int TeamsTemplateGetTeamMaxRetries = 3; #region Team public static List GetGroupsWithTeam(ApiRequestHelper requestHelper, string filter = null) @@ -306,7 +307,6 @@ private static Team CreateTeamFromTemplate(ApiRequestHelper requestHelper, strin } var teamMembers = CreateInitialTeamMembers(requestHelper, owners, members); - ValidateInitialTeamMembersLimit(teamMembers); if (teamMembers.Count > 0) { payload.Add("members", teamMembers); @@ -325,6 +325,15 @@ private static Team CreateTeamFromTemplate(ApiRequestHelper requestHelper, strin throw new PSInvalidOperationException("Microsoft Graph completed the team creation operation without returning a target team identifier."); } + // The team was provisioned successfully. Try to return the fully hydrated team from Microsoft Graph so the + // output matches the non-template creation path. Fall back to the requested values if the freshly created + // team cannot be read back yet due to replication delays. + var provisionedTeam = GetProvisionedTeam(requestHelper, operation.TargetResourceId); + if (provisionedTeam != null) + { + return provisionedTeam; + } + team.GroupId = operation.TargetResourceId; team.DisplayName = displayName; team.Description = description; @@ -334,6 +343,34 @@ private static Team CreateTeamFromTemplate(ApiRequestHelper requestHelper, strin return team; } + private static Team GetProvisionedTeam(ApiRequestHelper requestHelper, string groupId) + { + // Right after an async provisioning operation completes, the backing group and team can take a short while + // to become queryable. Retry a few times before giving up and letting the caller fall back to the requested values. + for (var retryCount = 0; retryCount < TeamsTemplateGetTeamMaxRetries; retryCount++) + { + if (retryCount > 0) + { + Thread.Sleep(TimeSpan.FromSeconds(TeamsTemplateGetTeamRetryIntervalSeconds)); + } + + try + { + var team = GetTeam(requestHelper, groupId); + if (team != null) + { + return team; + } + } + catch (GraphException) + { + // The team is not queryable yet, retry. + } + } + + return null; + } + private static List CreateInitialTeamMembers(ApiRequestHelper requestHelper, string[] owners, string[] members) { var teamMembers = new List(); @@ -362,14 +399,6 @@ private static List CreateInitialTeamMembers(ApiRequestHelper return teamMembers; } - private static void ValidateInitialTeamMembersLimit(IReadOnlyCollection teamMembers) - { - if (teamMembers.Count > TeamsTemplateInitialMembersLimit) - { - throw new PSInvalidOperationException($"Microsoft Graph supports a maximum of {TeamsTemplateInitialMembersLimit} distinct owners and members in the initial members collection when creating a team from a template. Specify no more than {TeamsTemplateInitialMembersLimit} combined owners and members when using the Template parameter."); - } - } - private static string GetUserODataBindUrl(ApiRequestHelper requestHelper, string user) { return $"https://{requestHelper.GraphEndPoint}/v1.0/users('{user}')"; @@ -395,6 +424,7 @@ private static string GetTeamsAsyncOperationStatusUrl(HttpResponseMessage respon private static TeamsAsyncOperation WaitForTeamsAsyncOperation(ApiRequestHelper requestHelper, string operationStatusUrl) { TeamsAsyncOperation operation = null; + GraphException lastTransientException = null; for (var retryCount = 0; retryCount < TeamsAsyncOperationMaxRetries; retryCount++) { if (retryCount > 0) @@ -406,8 +436,11 @@ private static TeamsAsyncOperation WaitForTeamsAsyncOperation(ApiRequestHelper r { operation = requestHelper.Get(operationStatusUrl); } - catch (GraphException ex) when (IsTransientTeamsAsyncOperationError(ex) && retryCount < TeamsAsyncOperationMaxRetries - 1) + catch (GraphException ex) when (IsTransientTeamsAsyncOperationError(ex)) { + // A transient error (including on the final attempt) should not surface as a raw Graph exception. + // Remember it so it can be included in the timeout message if we never get a definitive status. + lastTransientException = ex; continue; } @@ -428,7 +461,10 @@ private static TeamsAsyncOperation WaitForTeamsAsyncOperation(ApiRequestHelper r } } - throw new PSInvalidOperationException($"Timed out waiting for Microsoft Graph team creation operation at {operationStatusUrl} to complete."); + var timeoutDetail = lastTransientException?.HttpResponse != null + ? $" The last polling attempt failed with status code {(int)lastTransientException.HttpResponse.StatusCode}." + : string.Empty; + throw new PSInvalidOperationException($"Timed out waiting for Microsoft Graph team creation operation at {operationStatusUrl} to complete.{timeoutDetail}"); } private static bool IsTransientTeamsAsyncOperationError(GraphException ex)