Planner Tenant To Tenant Migration!

Planner.png

Say you have a large organization, and it gets acquired by another company and you need to migrate to their tenant. What do you do with all projects that have been planned long in advance with Microsoft Planner? If not to say all the other various plans. It could be several hundred plans depending on the size of the organization. You don’t want to have to recreate all these manually or use both the old and new tenant for a while.

I have looked through the Docs pages for Planner/Graph API, and it turns out there’s a lot of possibilities there. Its been a lot of tinkering and failing, but finally after some hard work, I have managed to create a Planner tenant to tenant migration script!

So what am I able to migrate?

– Plans
– Buckets
– Labels/Categories
– Tasks
– Checklist items
– StartDate/DueDate
– Progress, able to migrate status Completed, but not ‘In Progress’
– Description/Notes
– Assignees
– Labels/Categories

The things that are either very complicated or not possible to migrate are comments and attachments. Comments are stored in a mailbox, and there’s no API to link these to a new task in a new tenant. Attachments are very complicated, since often it’s either SharePoint URL or files, and it would be a highly manual process to set them on the tasks in a new tenant. I don’t say it’s not possible to do this, but it would take a considerate amount of time to investigate.

The script also has the following prerequisites:

– Groups created and populated with owner/members in destination tenant.
– Group.ReadWrite.All rights in Graph API in source and destination tenant.
– Admin user added as owner and member in the groups in both source and destination tenant.

Demo:

### Microsoft Planner Tenant To Tenant Migration Script ###
### ###
### Version 1.0 ###
### ###
### Author: Alexander Holmeset ###
### ###
### Twitter: twitter.com/alexholmeset ###
### ###
### Blog: alexholmeset.blog ###
### ###
### This scripts migrates Planner plans with buckets, ###
### tasks, labels, checklists, task asignees, ###
### task description and task proggress to a new tenant. ###
### The script does not migrate atachments and conversations. ###
### Prereq: ###
### - Groups created and popluated with owner/members in destination tenant. ###
### - Group.ReadWrite.All rights in Graph API in source and destiantion tenant. ###
### - Admin user added as owner and member in the groups in both source and destination tenant. ###
#Enter Source details
$clientIdSource = "91433c0c-769b-4fdf-a4d8-1e00deec8c77"
$tenantIdSource = "e99c0533-933c-4dc5-8252-076d5bd5ef55"
$domainSource = "M365x628786.onmicrosoft.com"
#Enter Destination details
$clientIdDestination = "067aa1c2-dbe4-44b2-b4cf-3866cecf0944"
$tenantIdDestination = "2749339e-69b9-4986-99b9-678ae30badba"
$domainDestination = "M365x842993.onmicrosoft.com"
# Application (client) ID, tenant ID, resource and scope i the source tenant
$resourceSource = "https://graph.microsoft.com/"
$scopeSource = ""
$codeBodySource = @{
resource = $resourceSource
client_id = $clientIdSource
scope = $scopeSource
}
# Get OAuth Code
$codeRequestSource = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$tenantIdSource/oauth2/devicecode" -Body $codeBodySource
# Print Code to console
"Source tenant"
Write-Host "`n$($codeRequestSource.message)"
$tokenBodySource = @{
grant_type = "urn:ietf:params:oauth:grant-type:device_code"
code = $codeRequestSource.device_code
client_id = $clientIdSource
}
# Get OAuth Token
while ([string]::IsNullOrEmpty($tokenRequestSource.access_token)) {
$tokenRequestSource = try {
Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$tenantIdSource/oauth2/token" -Body $tokenBodySource
}
catch {
$errorMessageSource = $_.ErrorDetails.Message | ConvertFrom-Json
# If not waiting for auth, throw error
if ($errorMessageSource.error -ne "authorization_pending") {
throw
}
}
}
$tokenSource = $tokenRequestSource.access_token
# Application (client) ID, tenant ID, resource and scope
$resourceDestination = "https://graph.microsoft.com/"
$scopeDestination = "Group.ReadWrite.All"
$codeBodyDestination = @{
resource = $resourceDestination
client_id = $clientIdDestination
scope = $scopeDestination
}
# Get OAuth Code
$codeRequestDestination = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$tenantIdDestination/oauth2/devicecode" -Body $codeBodyDestination
# Print Code to console
"Destination tenant"
Write-Host "`n$($codeRequestDestination.message)"
$tokenBodyDestination = @{
grant_type = "urn:ietf:params:oauth:grant-type:device_code"
code = $codeRequestDestination.device_code
client_id = $clientIdDestination
}
# Get OAuth Token
while ([string]::IsNullOrEmpty($tokenRequestDestination.access_token)) {
$tokenRequestDestination = try {
Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$tenantIdDestination/oauth2/token" -Body $tokenBodyDestination
}
catch {
$errorMessageDestination = $_.ErrorDetails.Message | ConvertFrom-Json
# If not waiting for auth, throw error
if ($errorMessageDestination.error -ne "authorization_pending") {
throw
}
}
}
$tokenDestination = $tokenRequestDestination.access_token
#Gets all groups in source tenant.
$uri = 'https://graph.microsoft.com/v1.0/groups/'
$groups = while (-not [string]::IsNullOrEmpty($uri)) {
# API Call
$apiCall = try {
Invoke-RestMethod -Method GET -Uri $uri -ContentType "application/json" -Headers @{Authorization = "Bearer $tokenSource" }
}
catch {
$errorMessage = $_.ErrorDetails.Message | ConvertFrom-Json
}
$uri = $null
if ($apiCall) {
# Check if any data is left
$uri = $apiCall.'@odata.nextLink'
$apiCall
}
}
$groups = $groups.value
$BucketOverview = @()
#Gets all groups in destination tenant.
$uriDestination = 'https://graph.microsoft.com/v1.0/groups/'
$groupsDestination = while (-not [string]::IsNullOrEmpty($uriDestination)) {
# API Call
$apiCall = try {
Invoke-RestMethod -Method GET -Uri $uriDestination -ContentType "application/json" -Headers @{Authorization = "Bearer $tokenDestination" }
}
catch {
$errorMessage = $_.ErrorDetails.Message | ConvertFrom-Json
}
$uriDestination = $null
if ($apiCall) {
# Check if any data is left
$uriDestination = $apiCall.'@odata.nextLink'
$apiCall
}
}
$groupsDestination = $groupsDestination.value
Foreach ($group in $groups) {
#Checks if group in source tenant have a Planner plan.
$groupID = $group.id
$groupDisplayName = $group.displayName
$uri2 = 'https://graph.microsoft.com/v1.0/groups/' + $groupID + '/planner/plans'
$query2 = Invoke-RestMethod -Method GET -Uri $uri2 -ContentType "application/json" -Headers @{Authorization = "Bearer $tokenSource" }
$plans = $query2.value
Foreach ($plan in $plans) {
#Plan ID in source tenant.
$planID = $plan.id
#Finds all buckets in plan.
$uri5 = 'https://graph.microsoft.com/v1.0/planner/plans/' + $planID + '/buckets/'
$query5 = Invoke-RestMethod -Method GET -Uri $uri5 -ContentType "application/json" -Headers @{Authorization = "Bearer $tokenSource" }
$buckets = $query5.value | Sort-Object orderhint -Descending
#Finds all categories in plan
$uriDestination545 = 'https://graph.microsoft.com/v1.0/planner/plans/' + $planID + '/details/'
$query545 = Invoke-RestMethod -Method GET -Uri $uriDestination545 -ContentType "application/json" -Headers @{Authorization = "Bearer $tokenSource" }
$categories = ((($query545.categoryDescriptions).psobject.members) | Where-Object { $_.MemberType -eq 'NoteProperty' }) | Select-Object name, value
#Destination
$GroupDestinationID = ($groupsDestination | Where-Object { $_.displayName -eq "$groupdisplayname" } | Select-Object ID).id
$RequestBody2 = @"
{
"owner": "$groupdestinationID",
"title": "$groupdisplayname",
}
"@
#Creates plan in destination tenant.
$uriDestination2 = 'https://graph.microsoft.com/v1.0/planner/plans/'
$queryDestination2 = Invoke-RestMethod -Method POST -Uri $uriDestination2 -ContentType "application/json" -Headers @{Authorization = "Bearer $tokenDestination" } -Body $requestbody2
$planDestination = $queryDestination2
$planDestinationID = $queryDestination2.id
#Create labels in destination tenant.
$uriDestination223423 = 'https://graph.microsoft.com/v1.0/planner/plans/' + $planDestinationID + '/details'
$queryDestination223423 = Invoke-RestMethod -Method GET -Uri $uriDestination223423 -ContentType "application/json" -Headers @{Authorization = "Bearer $tokenDestination" }
$planDestinationEtag = $queryDestination223423.'@odata.etag'
$headers = @{ }
$headers.Add("if-match", $planDestinationEtag)
$headers.Add("Authorization", "Bearer $tokendestination")
if (($categories | Where-Object { $_.name -eq 'category1' }).value) { $category1 = '"category1": "' + (($categories | Where-Object { $_.name -eq 'category1' }).value) + '",' }
Else { $category1 = '"category1" : null,' }
if (($categories | Where-Object { $_.name -eq 'category2' }).value) { $category2 = '"category2": "' + (($categories | Where-Object { $_.name -eq 'category2' }).value) + '",' }
Else { $category2 = '"category2" : null,' }
if (($categories | Where-Object { $_.name -eq 'category3' }).value) { $category3 = '"category3": "' + (($categories | Where-Object { $_.name -eq 'category3' }).value) + '",' }
Else { $category3 = '"category3" : null,' }
if (($categories | Where-Object { $_.name -eq 'category4' }).value) { $category4 = '"category4": "' + (($categories | Where-Object { $_.name -eq 'category4' }).value) + '",' }
Else { $category4 = '"category4" : null,' }
if (($categories | Where-Object { $_.name -eq 'category5' }).value) { $category5 = '"category5": "' + (($categories | Where-Object { $_.name -eq 'category5' }).value) + '",' }
Else { $category5 = '"category5" : null,' }
if (($categories | Where-Object { $_.name -eq 'category6' }).value) { $category6 = '"category6": "' + (($categories | Where-Object { $_.name -eq 'category6' }).value) + '",' }
Else { $category6 = '"category6" : null,' }
$RequestBody223423 = @"
{
"categoryDescriptions": {
$category1
$category2
$category3
$category4
$category5
$category6
}
}
"@
$queryDestination2342342 = Invoke-RestMethod -Uri $uriDestination223423 -Headers $headers -Method PATCH -Body $RequestBody223423 -ContentType application/json
Foreach ($bucket in $buckets) {
#Creates plan buckets in destiantion tenant.
$bucketnameDestination = $bucket.name
$planDestinationID = $planDestination.id
$RequestBody3 = @"
{
"name": "$bucketnamedestination",
"planId": "$planDestinationID",
"orderHint": " !"
}
"@
$uriDestination3 = 'https://graph.microsoft.com/v1.0/planner/buckets/'
$queryDestination3 = Invoke-RestMethod -Method POST -Uri $uriDestination3 -ContentType "application/json" -Headers @{Authorization = "Bearer $tokenDestination" } -Body $requestbody3
$BucketDestination = $queryDestination3
$Object = [PSCustomObject]@{
'BucketIDSource' = $bucket.id
'BucketIDDestination' = $BucketDestination.id
'BucketName' = $bucketnameDestination
}
$BucketOverview += $Object
}
#Finds all tasks for planc in source tenant.
$uri55 = 'https://graph.microsoft.com/v1.0/planner/plans/' + $planID + '/tasks/'
$query55 = Invoke-RestMethod -Method GET -Uri $uri55 -ContentType "application/json" -Headers @{Authorization = "Bearer $tokenSource" }
$tasks = $query55.value | Sort-Object orderHint -Descending
foreach ($task in $tasks) {
$taskBucketID = $task.bucketId
$taskTitle = $task.title
$taskID = $task.id
$taskPercentComplete = $task.percentComplete
$TaskBucketDestinationID = ($BucketOverview | Where-Object { $_.BucketIDSource -eq $taskBucketID }).BucketIDDestination
$TaskstartDateTime = If($task.startDateTime){ $task.startDateTime | Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'}
$TaskdueDateTime = If($task.dueDateTime){$task.dueDateTime | Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'}
If (!$TaskstartDateTime) { '' }
Else { $TaskstartDateTime = [string]('"startDateTime" : "' + $TaskstartDateTime + '",') }
If (!$TaskdueDateTime) { '' }
Else { $TaskdueDateTime = [string]('"dueDateTime" : "' + $TaskdueDateTime + '",') }
$taskappliedCategories = ((($task.appliedCategories).psobject.members) | Where-Object { $_.membertype -eq 'NoteProperty' }).name
$CustomAppliedCategory = @()
$AppliedCategoriesBody = @()
If ($taskappliedCategories) {
foreach ($appliedcategory in $taskappliedCategories) {
$applied = '"' + $appliedcategory + '": true,
'
$CustomAppliedCategory += $applied
}
$AppliedCategoriesBody = @"
"appliedCategories": {
$customappliedcategory
},
"@
}
#Users assigned to task in source tenant.
$assignees = ($task.assignments | get-member -MemberType 'NoteProperty').name
if ($assignees) {
$Addusers = @()
foreach ($assignee in $assignees) {
$DestinationUserID = @()
$DestinationUPN = @()
$query5555 = @()
$query555 = @()
$SourceUPN = @()
$AddUser = @()
$uri555 = 'https://graph.microsoft.com/v1.0/users/' + "$assignee"
$query555 = Invoke-RestMethod -Method GET -Uri $uri555 -ContentType "application/json" -Headers @{Authorization = "Bearer $tokenSource" }
$SourceUPN = $query555.userPrincipalName
$DestinationUPN = ($SourceUPN).Replace($domainSource, $domainDestination)
$DestinationUPN
If ($query555.userPrincipalName -like "#EXT#") {
$uri5555 = 'https://graph.microsoft.com/v1.0/users/?$filter=usertype' + ' eq ' + '''Guest''' + ' and mail eq ' + "$($query555.mail)"
$query5555 = Invoke-RestMethod -Method GET -Uri $uri5555 -ContentType "application/json" -Headers @{Authorization = "Bearer $tokendestination" }
$DestinationUserID = $query5555.ID
}
Else {
$uri5555 = 'https://graph.microsoft.com/v1.0/users/' + "$DestinationUPN"
$query5555 = Invoke-RestMethod -Method GET -Uri $uri5555 -ContentType "application/json" -Headers @{Authorization = "Bearer $tokendestination" }
$DestinationUserID = $query5555.ID
}
If($DestinationUserID){
$AddUser = @"
"$DestinationUserID": {
"@odata.type": "#microsoft.graph.plannerAssignment",
"orderHint": " !"
},
"@
$Addusers += $AddUser
}
}
$RequestBody4 = @"
{
"planId": "$planDestinationID",
"bucketId": "$TaskBucketDestinationID",
"title": "$tasktitle",
"percentComplete": "$taskpercentcomplete",
$TaskstartDateTime
$TaskdueDateTime
$AppliedCategoriesBody
"assignments": {
$addusers
},
}
"@
}
Else {
$RequestBody4 = @"
{
"planId": "$planDestinationID",
"bucketId": "$TaskBucketDestinationID",
"title": "$tasktitle",
"percentComplete": "$taskpercentcomplete",
$TaskstartDateTime
$TaskdueDateTime
$AppliedCategoriesBody
}
"@
}
#Creates task in destination tenant.
$uriDestination4 = 'https://graph.microsoft.com/v1.0/planner/tasks/'
$queryDestination4 = Invoke-RestMethod -Method POST -Uri $uriDestination4 -ContentType "application/json" -Headers @{Authorization = "Bearer $tokendestination" } -Body $RequestBody4
$TaskIDDestination = $queryDestination4.ID
$RequestBody4
$uriDestination6 = 'https://graph.microsoft.com/v1.0/planner/tasks/' + $TaskIDDestination + '/details'
$queryDestination6 = Invoke-RestMethod -Method GET -Uri $uriDestination6 -ContentType "application/json" -Headers @{Authorization = "Bearer $tokendestination" }
$uri4323426 = 'https://graph.microsoft.com/v1.0/planner/tasks/' + $taskID + '/details'
$query4323426 = Invoke-RestMethod -Method GET -Uri $uri4323426 -ContentType "application/json" -Headers @{Authorization = "Bearer $tokenSource" }
$TaskEtagDestination = $queryDestination6.'@odata.etag'
$Description = $query4323426.description
If (!$Description) { $Description = '"description" : null,' }
Else { $Description = '"description" : "' + $Description + '"' }
$RequestBody23111 = @"
{
$Description
}
"@
$headers = @{ }
$headers.Add("if-match", $TaskEtagDestination)
$headers.Add("Authorization", "Bearer $tokendestination")
#Updates task in destination tenant.
$queryDestination6154 = Invoke-RestMethod -Uri $uriDestination6 -Headers $headers -Method PATCH -Body $RequestBody23111 -ContentType application/json
$uri123 = 'https://graph.microsoft.com/v1.0/planner/tasks/' + $taskID + '/details'
$query123 = Invoke-RestMethod -Method GET -Uri $uri123 -ContentType "application/json" -Headers @{Authorization = "Bearer $tokenSource" }
$TaskDetails = (($query123.checklist).psobject.Properties).value
Foreach ($checklistitem in $TaskDetails) {
#Destination
$checklistItemGuid = (New-Guid).Guid
$IsChecked = $checklistitem.isChecked
$CheckListItemTitle = $checklistitem.title
$RequestBody5 = @"
{
"checklist": {
"$checklistItemGuid": {
"@odata.type": "#microsoft.graph.plannerChecklistItem",
"isChecked": "$IsChecked",
"title": "$checklistitemtitle"
}
}
}
"@
$headers = @{ }
$headers.Add("if-match", $TaskEtagDestination)
$headers.Add("Authorization", "Bearer $tokendestination")
#Creates checklist for task in destiantino tenant.
$uriDestination5 = "https://graph.microsoft.com/v1.0/planner/tasks/" + "$TaskIDDestination" + "/Details"
$queryDestination5 = Invoke-RestMethod -Uri $uriDestination5 -Headers $headers -Method PATCH -Body $RequestBody5 -ContentType application/json
$RequestBody5
}
}
}
}
view raw PlannerMigration.ps1 hosted with ❤ by GitHub

Link to script on GitHub

5 thoughts on “Planner Tenant To Tenant Migration!

  1. NiceWork!

    I am kind of new to Graph so I am just wondering from where you pulled out the information regarding the $clientIdSource = AppID…
    – is it where the information about the Enterprise Applications inside Azure Portal > Azure AD > Enterprise Applications?
    – and which application is it if so (Microsoft Teams, Outlook Group…)?

    Like

  2. Hi Alexander,

    Could this script be modified to move a Plannner or specific Planner tasks between Teams in the same tenant?

    Like

Leave a Reply to Alexander Holmeset Cancel reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s