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
}
}
}
}

Link to script on GitHub

16 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

  3. Brilliant script, thanks for sharing – I used an updated version from someone else.
    Any ideas how how to get past 400 tasks in a planner? Seems the script will return max of 400 due to a limitation of the API (source: https://planner.uservoice.com/forums/330525-microsoft-planner-feedback-forum/suggestions/39765460-remove-limit-of-400-items-in-get-tasks-from-plan-i )

    But with plans of >2000 tasks…..

    🙁

    the smaller ones were migrated fine, thanks

    Like

      • I have done this around line 364:
        $uriAux = ‘https://graph.microsoft.com/v1.0/planner/plans/’ + $planID + ‘/tasks/’

        #$query55 = Invoke-RestMethod -Method GET -Uri $uri55 -ContentType “application/json; charset=utf-8” -Headers @{Authorization = “Bearer $tokenSource” }

        $tasks = $null
        ################# Smialoski
        while (-not [string]::IsNullOrEmpty($UriAux)) {
        $query55 = Invoke-RestMethod -Method GET -Uri $UriAux -ContentType “application/json; charset=utf-8” -Headers @{Authorization = “Bearer $tokenSource” }
        #$apiCall = Invoke-WebRequest –Method “GET” –Uri $Uri –ContentType “application/json” –Headers $global:Header –ErrorAction Stop –UseBasicParsing

        $nextLink = $null
        $UriAux = $null

        if ($query55) {

        # Check if any data is left
        $nextLink = $query55 | Select-Object ‘@odata.nextLink’
        $UriAux = $nextLink.’@odata.nextLink’

        $Tasks +=$query55.value | Sort-Object orderHint -Descending

        }

        }

        Like

      • yeah, I have been meaning to update the script to handle paging. Thanks for the solution suggestion 🙂

        Like

  4. Hi Alex, I get a error saying

    Invoke-RestMethod : {“error”:”invalid_client”,”error_description”:”AADSTS7000218: The request body must contain the f
    ollowing parameter: ‘client_assertion’ or ‘client_secret’.

    Any advice?

    Like

  5. Well done. How does this handle things like the bucketTaskBoardFormat and the order of items like tasks and checklists as these are reverse order when assigning the order hints?

    Like

    • Thanks.
      Its actually been so long since i looked at this, so i dont remember. I might look through this on friday.

      Like

      • I had done an application in the past as a SPA that converted a template to a plan and added the members / owners, disabled email notifications for task assignments, and assigned all the tasks. This greatly simplified our process to be able to be able to launch a plan completely with a few button clicks, rather than the typical manual process.

        Adding the buckets is straightforward where they are received in reverse order so putting them back in as shown in your code is correct.

        In planner there is a plannerBucketTaskBoardTaskFormat resource type that stores the order hint for the tasks as they are shown in the buckets view in Planner. In JS for me it looks like this where I load the template into an object array.

        // Sort Tasks By Task Board Order Hint in Bucket Order
        let b, p, t;
        let ix;
        let sortedTasks = [];
        let bucketTasks = [];

        for (t = 0; t item.id === planTemplates[templateIndex].tasks[t].id);
        planTemplates[templateIndex].tasks[t][‘taskBoardOrderHint’] = planTemplates[templateIndex].taskBoardFormat[ix].orderHint;
        }

        for (b = 0; b < planTemplates[templateIndex].buckets.length; b++)
        {
        for (t = 0; t (a.taskBoardOrderHint > b.taskBoardOrderHint ? -1 : 1));
        sortedTasks.push.apply(sortedTasks, bucketTasks);
        bucketTasks = [];
        }
        planTemplates[templateIndex].tasks = sortedTasks;

        This way tasks are loaded in reverse order and can be updated in the correct order using the “orderHint”: ” !” in the request body.

        Checklist items are unique in that they are ordered by their numerical object id. I had to first sort them by id and store in another object then reassign with new id. That id is later replaced in the update request body with a unique GUID. In JS it would look something like this.

        let c, d, i, t;
        let ix;
        let copyDetails = [];
        let copyChecklist;
        let newChecklist = {};

        // Sort Details In Order Matching Tasks
        for (t = 0; t item.id === planTemplates[templateIndex].tasks[t].id);
        copyDetails.push(planTemplates[templateIndex].details[ix]);
        }
        planTemplates[templateIndex].details = copyDetails;

        // Sort Checklist Items In Order by OrderHint
        // Have To Rename the ID To Avoid Auto Sort Of Objects
        for (d = 0; d 0)
        {
        copyChecklist = Object.entries(planTemplates[templateIndex].details[d].checklist);
        copyChecklist.sort((a, b) => (a[1].orderHint < b[1].orderHint ? -1 : 1));

        planTemplates[templateIndex].details[d].checklist = {};

        for ( i = 0; i 0)
        {
        copyChecklist = Object.entries(planTemplates[templateIndex].details[d].checklist);
        for (let i = 0; i 0)
        {
        references = {
        [Object.entries(planTemplates[templateIndex].details[d].references)[0]]:
        {
        “@odata.type”: “#microsoft.graph.plannerExternalReference”,
        alias: Object.entries(planTemplates[templateIndex].details[d].references)[0][1].alias,
        type: Object.entries(planTemplates[templateIndex].details[d].references)[0][1].type,
        previewPriority: ” !”,
        }
        }
        }

        Combine into a single request body.

        // Update Both References and Checklist
        // Description and PreviewType are derived from the task details.
        plannerDetails = {
        previewType: previewType,
        description: description,
        references: references,
        checklist: newChecklist
        }
        // In order to get the etag. Once you load the tasks in previous steps you need to get the new etag.

        let createdDetails = await postDetails(plannerDetails, id, etag);

        Like

Leave a comment