Recently, the Graph team introduced the ability to access Planner as an application, rather than as a human. That means we were able to give PowerShell Universal the ability to read plans, tasks etc and surface them as an API endpoint.

The trick is to give the Azure Managed Identity for PowerShell Universal the Tasks.Read.All permission. Once it has that, it can read any plan in your tenant.

Here’s our Get-GroupPlanner.ps1 script in its entirety! It lets us get all of the Planner boards (and the buckets & tasks therein) for a given Microsoft 365 group.

param (
    [Parameter()][string] $GroupId 
)

Connect-MgGraph -Identity | Out-Null
try {
    # First, get all the users in the group. We need their display name and dept.
    $users = Get-MgGroupMember -GroupId $GroupId -All -Property id,displayName,department

    # Now get all of the Planner boards in the group.
    $plans = Get-MgGroupPlannerPlan -GroupId $GroupId | Foreach-Object {
        [PSCustomObject] @{
            Id = $_.Id
            Title = $_.Title
            Buckets = $null
        }
    }

    $plans | Foreach-Object {
        # Grab all of this plan's tasks in one call.
        $tasks = Get-MgPlannerPlanTask -PlannerPlanId $_.id

        # set display name on all the assigned users.
        $tasks.Assignments.AdditionalProperties | Foreach-Object {
            $a = $_

            if ($a) {
                $a.Keys | Foreach-Object {
                    $u = ($users | Where-Object id -eq $_).additionalProperties
                    $a[$_].displayName = $u.displayName
                    $a[$_].department = $u.department
                }
            }
        }
        
        # Grab the descriptions of the plan's categories (labels).
        $categories = (Get-MgPlannerPlanDetail -PlannerPlanId $_.id).CategoryDescriptions

        # Get all the plan's buckets.
        $_.Buckets = @(Get-MgPlannerPlanBucket -PlannerPlanId $_.id | Foreach-Object {
            [PSCustomObject] @{
                Id = $_.Id
                Name = $_.Name
                Tasks = @($tasks | Where-Object BucketId -eq $_.Id | Foreach-Object {
                    [PSCustomObject] @{
                        Id = $_.Id
                        Title = $_.Title
                        StartDateTime = $_.StartDateTime
                        DueDateTime = $_.DueDateTime
                        CompletedDateTime = $_.CompletedDateTime
                        Categories = @($_.AppliedCategories.AdditionalProperties.Keys | Foreach-Object {
                            $categories.$_
                        }) 
                        Assignees = @($_.Assignments.AdditionalProperties.Values | Foreach-Object {
                            [PSCustomObject] @{
                                DisplayName = $_.displayName
                                Department = $_.department
                            }
                        })
                    }
                })
            }
        })
    }

    $plans
}
finally {
    Disconnect-MgGraph | Out-Null
}
Note
Notice how the value we assign to categories and assignees is wrapped in @()? That’s because PowerShell has this “helpful” behaviour where a list of one item is treated the same as the item itself. So if there is only one category applied to a task, the categories property ends up being a string rather than an array of strings. Wrapping the value in @() forces PowerShell to treat it as an array.

When we call this script from an API endpoint, we get a nice JSON object representing all of the plans in the given Microsoft 365 group. It looks kinda like this:

[
    {
        "id" : "<plan id>",
        "title" : "My Planner Board",
        "buckets" : [
            {
                "id" : "<bucket id>",
                "name" : "My Bucket",
                "tasks" : [
                    {
                        "id" : "<task id>",
                        "title" : "My Task",
                        // other properties
                        "categories" : [ "stuff", "things", "other" ],
                        "assignees" : [ 
                            { 
                                "displayName" : "Matt Hamilton", 
                                "department" : "IT" 
                            }, // and all the other assignees
                        ]
                    }, // and all the other tasks
                ]
            }, // and all the other buckets
        ]
    }, // and all the other plans
]

Yes, the JSON is restructured somewhat from the data returned from the Graph endpoints. That’s intentional - the Graph endpoints return a lot of metadata that we simply didn’t need. But you can certainly use this as a starting point for a script that suits your needs! Go nuts!