Duplicate Microsoft Planner Plans with Power Automate (Flow)

I needed to create a Power Automate solution which clones Microsoft Teams team and planner inside it. And at last add the plan as tab to general channel.

Cloning the team is easy and here are the instructions for cloning Teams team.

  • Create Azure App Registration with permissions shown in below picture

Then I found the steps needed to clone/copy Planner plan for MS Graph with Power Shell.

  • Get the Template Plan and New Plan id
  • Copy the template plan categories (for the labels)
  • Get all the buckets
  • Get all the task for each bucket
  • Adding the new buckets in the right order
  • Adding the task to each bucket in the right order
  • Add the descriptions for each task
  • Add checklists if they exist

Now I just needed to do above steps with Power Automate. First I figured out, that I need service account because app permissions are not allowed. Service account needs to be in the source team as a member.

It does only clone team and it’s channels, then dedicated Planner plan and then set tabs for general channel to show the plan. This example does not set labels to tasks but copies categories to plan. This example does not clone assigned to information or other metadata for tasks but sets titles, descriptions and checklists for tasks. All metadata is possible to set according how descriptions and checklists are copied from source tasks and patched to target tasks, please extend solution when needed.

Power Automate flow – trigger and variables

Flow starts with manually triggering – I will later on attach it to some other trigger

Then I will initialize string variables needed, varCheckListItems is an array

Clone Teams team

Then I have created scope to bundle up the cloning of the team

Containing the following actions opened

 {    "type": "object",    "properties": {        "cache-control": {            "type": "string"        },        "client-request-id": {            "type": "string"        },        "content-length": {            "type": "integer"        },        "content-type": {            "type": "string"        },        "location": {            "type": "string"        },        "request-id": {            "type": "string"        }    }} 
GetTeamRequest has the same advanced options as above HTTP call
 {    "type": "object",    "properties": {        "@@odata.context": {            "type": "string"        },        "id": {            "type": "string"        },        "operationType": {            "type": "string"        },        "createdDateTime": {            "type": "string"        },        "status": {            "type": "string"        },        "lastActionDateTime": {            "type": "string"        },        "attemptsCount": {            "type": "integer"        },        "targetResourceId": {            "type": "string"        },        "targetResourceLocation": {            "type": "string"        },        "Value": {            "type": "string"        },        "error": {}    }} 

And then the rest of scope ”Clone Team”

So it will look like below, and then you need to create scope ”Clone Planner”

which will contain scopes ”Handle user”, ”Clone categories (labels)” and Do until loop ”Create plan” like below picture represents. We will clone buckets and tasks last after creating the team tabs since buckets and tasks cloning takes most of the time

Clone Planner plan

Then we will use HTTP call to get the access token with our service account information. In customer tenant we had error ”Error validating credentials due to invalid username or password.” with code 50126. We found solution that customer company need to use password hashes.

 {    "type": "object",    "properties": {        "token_type": {            "type": "string"        },        "expires_in": {            "type": "string"        },        "ext_expires_in": {            "type": "string"        },        "expires_on": {            "type": "string"        },        "not_before": {            "type": "string"        },        "resource": {            "type": "string"        },        "access_token": {            "type": "string"        }    }} 

Below HTTP call then needs to use the access token, remember to change the authentication according to this. Rest of the HTTP calls will use delegated permissions with the service user access token

 {    "type": "object",    "properties": {        "@@odata.context": {            "type": "string"        },        "@@odata.etag": {            "type": "string"        },        "createdDateTime": {            "type": "string"        },        "owner": {            "type": "string"        },        "title": {            "type": "string"        },        "id": {            "type": "string"        },        "createdBy": {            "type": "object",            "properties": {                "user": {                    "type": "object",                    "properties": {                        "displayName": {},                        "id": {                            "type": "string"                        }                    }                },                "application": {                    "type": "object",                    "properties": {                        "displayName": {},                        "id": {                            "type": "string"                        }                    }                }            }        }    }} 

Then we start to implement the ”Clone categories (labels)” scope

 {    "type": "object",    "properties": {        "sharedWith": {            "type": "object",            "properties": {                "f3febc4b-8633-4ff9-9dba-16134e2d1aa4": {                    "type": "boolean"                },                "67505d9e-743d-44b3-a82f-3121fd28152c": {                    "type": "boolean"                }            }        },        "categoryDescriptions": {            "type": "object",            "properties": {                "category1": {                    "type": [                        "string",                        "null"                    ]                },                "category2": {                    "type": [                        "string",                        "null"                    ]                },                "category3": {                    "type": [                        "string",                        "null"                    ]                },                "category4": {                    "type": [                        "string",                        "null"                    ]                },                "category5": {                    "type": [                        "string",                        "null"                    ]                },                "category6": {                    "type": [                        "string",                        "null"                    ]                }            }        },        "id": {            "type": "string"        },        "@@odata.context": {            "type": "string"        },        "@@odata.etag": {            "type": "string"        }    }} 

In the below ”id” and ”@odata.etag” is from the ”Parse JSON – Get created plan”

 {    "type": "object",    "properties": {        "sharedWith": {            "type": "object",            "properties": {                "f3febc4b-8633-4ff9-9dba-16134e2d1aa4": {                    "type": "boolean"                },                "67505d9e-743d-44b3-a82f-3121fd28152c": {                    "type": "boolean"                }            }        },        "categoryDescriptions": {            "type": "object",            "properties": {                "category1": {                    "type": [                        "string",                        "null"                    ]                },                "category2": {                    "type": [                        "string",                        "null"                    ]                },                "category3": {                    "type": [                        "string",                        "null"                    ]                },                "category4": {                    "type": [                        "string",                        "null"                    ]                },                "category5": {                    "type": [                        "string",                        "null"                    ]                },                "category6": {                    "type": [                        "string",                        "null"                    ]                }            }        },        "id": {            "type": "string"        },        "@@odata.context": {            "type": "string"        },        "@@odata.etag": {            "type": "string"        }    }} 

Add Planner tab to team general channel

It was easy to find how to create a tab but configuring planner tab I found also instructions. You can extend this to update tabs, create more or delete. It depends how does the source team look like.

Let’s create the scope for tabs and add there first call to get channels, note that now we are using again app permissions

{    "type": "object",    "properties": {        "value": {            "type": "array",            "items": {                "type": "object",                "properties": {                    "description": {                        "type": [                            "string",                            "null"                        ]                    },                    "displayName": {                        "type": "string"                    },                    "id": {                        "type": "string"                    },                    "email": {                        "type": "string"                    },                    "webUrl": {                        "type": "string"                    }                },                "required": [                    "id",                    "displayName",                    "description",                    "email",                    "webUrl"                ]            }        },        "@@odata.context": {            "type": "string"        },        "@@odata.count": {            "type": "integer"        }    }}

Then create a apply each for the channels and get the general channel in condition

Below is the HTTP call to create a tab. Use created team id (targetResource…) and id for channel in each loop. EntityId id is the from ”Parse JSON – Get created plan” action. Replace ”distaging.onmicrosoft.com” with your tenant.

Clone plan buckets and tasks

Here is a lot of stuff and the most challenging was the checklist. I figured out this way to parse JSON to remove some of it. There is maybe a better way, this way it works.

From now on we change again to raw authentication for the rest HTTP calls since there Graph APIs do not support app permissions. We need to get source plan buckets and tasks.

{    "type": "object",    "properties": {        "@@odata.context": {            "type": "string"        },        "@@odata.count": {            "type": "integer"        },        "value": {            "type": "array",            "items": {                "type": "object",                "properties": {                    "@@odata.etag": {                        "type": "string"                    },                    "name": {                        "type": "string"                    },                    "planId": {                        "type": "string"                    },                    "orderHint": {                        "type": "string"                    },                    "id": {                        "type": "string"                    }                },                "required": [                    "@@odata.etag",                    "name",                    "planId",                    "orderHint",                    "id"                ]            }        }    }}

{    "type": "object",    "properties": {        "@@odata.context": {            "type": "string"        },        "@@odata.count": {            "type": "integer"        },        "value": {            "type": "array",            "items": {                "type": "object",                "properties": {                    "@@odata.etag": {                        "type": "string"                    },                    "planId": {                        "type": "string"                    },                    "bucketId": {                        "type": "string"                    },                    "title": {                        "type": "string"                    },                    "orderHint": {                        "type": "string"                    },                    "assigneePriority": {                        "type": "string"                    },                    "percentComplete": {                        "type": "integer"                    },                    "startDateTime": {},                    "createdDateTime": {                        "type": "string"                    },                    "dueDateTime": {},                    "hasDescription": {                        "type": "boolean"                    },                    "previewType": {                        "type": "string"                    },                    "completedDateTime": {},                    "completedBy": {},                    "referenceCount": {                        "type": "integer"                    },                    "checklistItemCount": {                        "type": "integer"                    },                    "activeChecklistItemCount": {                        "type": "integer"                    },                    "conversationThreadId": {},                    "id": {                        "type": "string"                    },                    "createdBy": {                        "type": "object",                        "properties": {                            "user": {                                "type": "object",                                "properties": {                                    "displayName": {},                                    "id": {                                        "type": "string"                                    }                                }                            }                        }                    },                    "appliedCategories": {                        "type": "object",                        "properties": {}                    },                    "assignments": {                        "type": "object",                        "properties": {}                    }                },                "required": [                    "@@odata.etag",                    "planId",                    "bucketId",                    "title",                    "orderHint",                    "assigneePriority",                    "percentComplete",                    "startDateTime",                    "createdDateTime",                    "dueDateTime",                    "hasDescription",                    "previewType",                    "completedDateTime",                    "completedBy",                    "referenceCount",                    "checklistItemCount",                    "activeChecklistItemCount",                    "conversationThreadId",                    "id",                    "createdBy",                    "appliedCategories",                    "assignments"                ]            }        }    }}

Then we create two inner loops for buckets and tasks
planID comes from ”Parse JSON – Get target plan details” action, use expression

{    "type": "object",    "properties": {        "name": {            "type": "string"        },        "planId": {            "type": "string"        },        "orderHint": {            "type": "string"        },        "id": {            "type": "string"        }    }}

Very important to set settings (…) for both apply loops to 1 parallel run

Create inner loop for tasks and set there a condition for polling the correct id for each task with the parent bucket loop id, use expression

Create a HTTP call to create task to planner – these tasks are in reverse order – live with it!

planId comes from ”Parse JSON – Get target plan details” action
bucketId comes from ”Parse JSON – Create bucket to target planner” action
title comes from each task with dynamic content without expression

Then let’s get the created task to get the etag for update the description and checklist

{    "type": "object",    "properties": {        "@@odata.context": {            "type": "string"        },        "@@odata.etag": {            "type": "string"        },        "description": {},        "previewType": {            "type": "string"        },        "id": {            "type": "string"        },        "references": {            "type": "object",            "properties": {}        },        "checklist": {            "type": "object",            "properties": {}        }    }}

We need to do the call in both cases if there is description or checklist, use expression
items(’Add_each_task’)[’hasDescription’]
items(’Add_each_task’)[’checklistItemCount’]

Then get the source planner task details

 {    "type": "object",    "properties": {        "@@odata.context": {            "type": "string"        },        "@@odata.etag": {            "type": "string"        },        "description": {},        "previewType": {            "type": "string"        },        "id": {            "type": "string"        },        "references": {            "type": "object",            "properties": {}        },        "checklist": {            "type": "object",            "properties": {}        }    }} 

Add the description, use expression
id and etag comes from ”Parse JSON – Created task to target planner” action
description and previewType comes from ”Parse JSON – Get source planner task details” action

Then there is the most fucked up shit – I struggle with this many hours just to remove some stuff from JSON, would be super easy with C# but..

Parse the items with bit different schema

{    "type": "object",    "properties": {        "@@odata.context": {            "type": "string"        },        "@@odata.etag": {            "type": "string"        },        "description": {},        "previewType": {            "type": "string"        },        "id": {            "type": "string"        },        "references": {            "type": "object",            "properties": {}        },        "checklist": {            "type": "object",            "items": {                "name": "string",                "type": "object",                "properties": {                    "@@odata.type": {                        "type": "string"                    },                    "isChecked": {                        "type": "boolean"                    },                    "title": {                        "type": "string"                    }                }            }        }    }}

Then try to loose the extra stuff but still they appear, like this

{    "items": {        "name": "string",        "type": "object",        "properties": {            "@@odata.type": {                "type": "string"            },            "isChecked": {                "type": "boolean"            },            "title": {                "type": "string"            }        }    }}

Set variables ready to loop

IndexOfNext action is ”Compose” action type
Expression: indexOf(variables(’varHelpTemp’), ’},’)

Expression: substring(variables(’varHelpTemp’), 0, indexOf(variables(’varHelpTemp’), ’,”orderHint”’))

Then the ”no” side
Expression
substring(variables(’varHelpTemp’), 0, indexOf(variables(’varHelpTemp’), ’,”orderHint”’))
replace(variables(’varHelpTemp’), substring(variables(’varHelpTemp’), 0, add(outputs(’IndexOfNext’),2)), ”)

Then we parse the JSON again (after Do until), remember to set this each loop also parallelism to 1

And finally we create the checklist

Now it is ready, the full flow looks likes below

Presentation about this in Finnish below