Knowledge Base

Preserving for the future: Shell scripts, AoC, and more

Powershell library for Bitbucket Cloud and Server

Here is a small library I wrote to help my Bitbucket Cloud to Bitbucket Server migration. I have no sharable notes about what order to use these functions in because I wrote them as I needed them or needed to alter them. I was also building on my credlib.ps1 which is not shared yet, but will come someday. You can also see this script in my gitlab: https://gitlab.com/bgstack15/former- gists/-/blob/master/bitbucketlib.ps1/bitbucketlib.ps1

# Startdate: 2020-05-18
# Title: Library for Bitbucket functions

# References:
#    https://developer.atlassian.com/bitbucket/api/2/reference/meta/authentication
#    https://developer.atlassian.com/server/bitbucket/how-tos/command-line-rest/
#    https://github.com/AtlassianPS/BitbucketPS
#    https://stackoverflow.com/questions/9825060/powershell-filtering-for-unique-values/9825218#9825218

# Useful starting values in procedural function at end.

# call: $CloudHeaders = Get-Bitbucket-Auth-Headers
Function Get-Bitbucket-Auth-Headers {
    <# .Synopsis Either username and password, or credential are required. #>
    param(
        [Parameter(Mandatory = $false)][string]$Username,
        [Parameter(Mandatory = $false)][string]$Password,
        [Parameter(Mandatory = $false)][System.Management.Automation.PSCredential]$Credential
    )

    . '\\rdputils1\e$\scripts\Functions\credlib.ps1'
    $internal_continue=$True
    $Headers = @{}

    if ($Username -ne $null -And $Password -ne $Null -And $Username -ne "" -And $Password -ne "") {
        $secret = $password | ConvertTo-SecureString -AsPlainText -Force
        $Credential = New-Object -TypeName "System.Management.Automation.PSCredential" -ArgumentList ${username}, ${secret}
    } elseif ($Credential -ne $null) {
        # pass through safely
    } else {
        if ($Username.tolower() -eq "prompt") {
            $Credential = Get-Credential
        } else {
            Write-Error "Provide -Username and -Password, or -Credential."
            $internal_continue = $False
            break
        }
    }

    if ($internal_continue) {
        $Headers += Get-Basic-Base64-Auth-Headers -Credential $credential
        $Headers
    }
}

Function _Iterate-Values-From-BB-API {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $False)][Hashtable]$AuthHeaders = ${global:CloudHeaders},
        [Parameter(Mandatory = $True )][string]$StartUri,
        [Parameter(Mandatory = $False)][string]$Location = "cloud", # "cloud" or "local" which sets page defaults
        [Parameter(Mandatory = $False)][string]$Descriptor = "item",
        [Parameter(Mandatory = $False)][boolean]$SkipCount = $False
    )
    # StartUri could be any of these, for example:
    # https://api.bitbucket.org/2.0/repositories/${org}
    # https://api.bitbucket.org/2.0/workspaces/${org}/projects
    # https://git.example.com/rest/api/1.0/repos
    # https://git.example.com/rest/api/1.0/projects
    # https://api.bitbucket.org/2.0/teams/${org}/permissions/repositories?q=permission%3D%22write%22
    Begin {
        $done = $False
        $output = @()
        $size = -1
        $count = 0
        $Baseuri = $StartUri
        $keepcounting = $false

        # All this just to get object count for the local Bitbucket, because its API does not list total object count on each return page.
        if (!($SkipCount)) {
            if ($Location -like "*local*" ) {
                $pageSize = 250
                $CountUri = "$($Baseuri)?limit=$($pageSize)"
                $localcount = 0
                While (!$done) {
                    Write-Progress -Activity "Counting objects" -Status "$($localcount)"
                    $interim = Invoke-RestMethod $CountUri -Method "GET" -Headers $AuthHeaders
                    if ( !($interim.isLastPage) ) {
                        $localcount += $pageSize
                        $CountUri = "$($Baseuri)?limit=$($pageSize)&start=$($localcount)"
                    } else {
                        $done = $True
                        $localcount += $interim.size
                    }
                }
                $done = $False
                $size = $localcount
            } Elseif ($Location -like "*cloud*" ) {
                $CountUri = "$($Baseuri)"
                $cloudcount = 0
                While (!$done) {
                    Write-Progress -Activity "Counting $($Descriptor)s" -Status "$($cloudcount)"
                    $interim = Invoke-RestMethod $CountUri -Method "GET" -Headers $AuthHeaders
                    # short circuit if size is provided
                    If ($interim.size -ne $null) {
                        $cloudcount = $interim.size
                        $done = $True
                    } Elseif ($interim.next -eq $null) {
                        $cloudcount += $interim.pagelen
                        $done = $True
                    } Else {
                        $cloudcount += $interim.pagelen
                        $Counturi = $interim.next
                    }
                }
                $done = $False
                $size = $cloudcount
            }
        } Else {
            # skip the count!
            $size = 10
        }
    }

    Process {
        Write-Verbose "Will look for $($size) $($descriptor)s"
        $Uri = $StartUri
        While (!$done) {
            $interim = Invoke-RestMethod -Uri $Uri -Method "GET" -Headers $AuthHeaders
            if (!($SkipCount) -And $size -eq -1) { # only run once because it will always be the same
                if ($interim.size -ne $null) { $size = $interim.size }
                    Else { $keepcounting = $True; $size += $interim.values.count }
            }
            if ($keepcounting) { $size += $interim.values.count }
            $interim.values | % {
                $output += $_ ;
                $count += 1 ;
                [int]$percent = ($count/$size)*100
                $percent = (@($percent, 100) | Measure -Minimum).Minimum
                $percent = (@($percent, 0) | Measure -Maximum).Maximum
                Write-Progress -Activity "Listing $($descriptor)" -Status "$count/$size" -PercentComplete $percent
            }
            # Bitbucket Cloud uses property "next" but on-prem server uses "nextPageStart"
            If ( $interim.next -ne $Null ) {
                $Uri = $interim.next
            } Elseif ( $interim.nextPageStart -ne $Null -And (!($interim.isLastPage)) ) {
                $Uri = "$($Baseuri)?start=$($interim.nextPageStart)"
            } else { $done = $True }
        }
    }

    End { $output }
}

Function List-All-Cloud-Projects {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $True )][Hashtable]$CloudHeaders,
        [Parameter(Mandatory = $False)][string]$Server = "api.bitbucket.org",
        [Parameter(Mandatory = $False)][string]$Instance = "example"
    )
    _Iterate-Values-From-BB-API -AuthHeaders $CloudHeaders -StartUri "https://${Server}/2.0/workspaces/${Instance}/projects" -Descriptor "project"
}

Function List-All-Cloud-Repos {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $True )][Hashtable]$CloudHeaders,
        [Parameter(Mandatory = $False)][string]$Server = "api.bitbucket.org",
        [Parameter(Mandatory = $False)][string]$Instance = "example"
    )
    _Iterate-Values-From-BB-API -AuthHeaders $CloudHeaders -StartUri "https://${Server}/2.0/repositories/${Instance}" -Descriptor "repo"
}

Function List-All-Local-Projects {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $True )][Hashtable]$LocalHeaders,
        [Parameter(Mandatory = $False)][string]$Server = "gittest.example.com"
    )
    _Iterate-Values-From-BB-API -AuthHeaders $LocalHeaders -StartUri "https://${Server}/rest/api/1.0/projects" -Descriptor "project" -Location "local"
}

Function List-All-Local-Repos {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $True )][Hashtable]$LocalHeaders,
        [Parameter(Mandatory = $False)][string]$Server = "gittest.example.com",
        [Parameter(Mandatory = $False)][string]$Instance = "example"
    )
    _Iterate-Values-From-BB-API -AuthHeaders $LocalHeaders -StartUri "https://${Server}/rest/api/1.0/repos" -Descriptor "repo" -Location "local"
}

# idea: $foo = List-All-Cloud-Repos -CloudHeaders $CloudHeaders
#       $out1 = $foo | Transform-Cloud-Repos-To-Useful
#       MapFile is CSV with columns Old,New to transform project names
#       Good mapfile is "U:\2020\05\project-cloud-to-onprem.csv"
Function Transform-Cloud-Repos-To-Useful {
    <# .Synopsis add relevant columns #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $False,ValueFromPipeLine = $true,ValueFromPipelineByPropertyName = $true)]$repo,
        [Parameter(Mandatory = $True)]$MapFile
    )
    Begin {
        $output = @() ;
        If (!(Test-Path $MapFile)) {
            Write-Error "MapFile must be a valid CSV file with columns named Old and New."
            break
        }
        $newDict = @{} ; Import-Csv $MapFile | % { $newDict[$_.Old]=$_.New }
    }

    Process {
        # a shallow copy is good enough for this object type. We do not have links to other objects in here, that we will care about or change or use.
        # https://stackoverflow.com/questions/9581568/how-to-create-new-clone-instance-of-psobject-object/13275775#13275775
        $newitem = $_.PsObject.Copy()
        $newitem | Add-Member -NotePropertyName projectkey -NotePropertyValue $_.project.key
        $newitem | Add-Member -NotePropertyName projectname -NotePropertyValue $_.project.name
        $newitem | Add-Member -NotePropertyName projecttype -NotePropertyValue $_.project.type
        $newitem | Add-Member -NotePropertyName NewProjectKey `
            -NotePropertyValue $newDict[$newitem.projectkey]
        $output += $newitem
    }
    End { $output ; }
}

# Useful for pulling columns from cloud repo definitions. Use after Transform-Repos-To-Useful
Function Select-Useful-Cloud-Repo-Columns {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $False,ValueFromPipeLine = $true,ValueFromPipelineByPropertyName = $true)]$repo
    )

    Process { $repo | Select-Object updated_on,size,slug,description, `
        projectkey,projectname,NewProjectKey,fork_policy,uuid,full_name, `
        name,language,created_on,has_issues }

}

Function Select-Useful-Local-Repo-Columns {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $False,ValueFromPipeLine = $true,ValueFromPipelineByPropertyName = $true)]$repo
    )

    Process { $repo | Select-Object slug,name,forkable,
        @{L='ProjectKey';E={($_.Project.key)};},
        @{L='ProjectName';E={($_.project.name)};},
        @{L='fullName';E={("$($_.project.key)/$($_.slug)")};}
        }
}

# low-level function for making a single repo with values
Function Act-Repo {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $False)][string]$Server = "gittest.example.com",
        [Parameter(Mandatory = $True )][string]$Name,
        [Parameter(Mandatory = $True )][string]$ProjectSlug,
        [Parameter(Mandatory = $True )][string]$Action, # either Create or Delete
        [Parameter(Mandatory = $False)][Hashtable]$LocalHeaders = ${global:LocalHeaders}
    )
    if (!($Action -in @("Create","Delete"))) {
        Throw "Action must be either Create or Delete"
        $null
    }
    if ($Action -eq "Create") {
        $data = @{
            name = $Name
            scmID = "git"
        } | ConvertTo-Json -Depth 3
        $Uri = "https://$($Server)/rest/api/1.0/projects/$($ProjectSlug)/repos"
        $Method = "POST"
    } elseif ($Action -eq "Delete") {
        $Uri = "https://$($Server)/rest/api/1.0/projects/$($ProjectSlug)/repos/$($Name)"
        $Method = "DELETE"
    }
    try {
        Write-Host "Trying to $Action repo $ProjectSlug/$Name"
        $RestParams = @{
            Uri = $Uri
            Method = $Method
            Headers = $LocalHeaders
            ContentType = "application/json"
        }       
        if ($data -ne $Null) { $RestParams += @{ Body = $Data } }
        $response = Invoke-RestMethod @RestParams
    }
    <# catch [System.InvalidOperationException] { # we will get an invalid operation if the project already exists! Write-Warning "Repo $($ProjectSlug)/$($Name) already exists. Resuming..." Continue } #>
    catch {
        Write-Error $PSItem
    }
    $response
}

# Goal: create repositories based on CSV fields
# Act-All-Repos -MapFile "U:\2020\05\repos-testfull.csv" -Action "Create"
Function Act-All-Repos {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $False)][string]$Server = "gittest.example.com",
        [Parameter(Mandatory = $True )][string]$MapFile,
        [Parameter(Mandatory = $True )][string]$Action, # either Create or Delete
        [Parameter(Mandatory = $False)][Hashtable]$LocalHeaders = ${global:LocalHeaders}
    )

    # DELETE is not implemented yet.
    Begin {
        If (!(Test-Path $MapFile)) {
            Write-Error "MapFile must be a valid CSV file with columns named slug,NewProjectKey."
            break
        }
        if (!($Action -in @("Create","Delete"))) {
            Throw "Action must be either Create or Delete"
        }
        $map = Import-Csv $MapFile
    }

    Process {
        # for every item in $map
        $map | % {
            Write-Host "Act-Repo -Name $($_.slug) -ProjectSlug $($_.NewProjectKey) -Action $Action -LocalHeaders LocalHeaders"
            Write-Progress "Trying to $Action repo $($_.NewProjectKey)/$($_.slug)"
            Act-Repo -Name "$($_.slug)" -ProjectSlug "$($_.NewProjectKey)" -Action $Action -LocalHeaders $LocalHeaders
        }
    }
    End {
    }
}

Function Act-Group {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory = $False)][string]$Server = "gittest.example.com",
        [Parameter(Mandatory = $True )][string]$Name,
        [Parameter(Mandatory = $True )][string]$Action, # either Create or Delete
        [Parameter(Mandatory = $False)][Hashtable]$LocalHeaders = ${global:LocalHeaders}
    )

    if (!($Action -in @("Create","Delete"))) {
        Throw "Action must be either Create or Delete"
    }

    $Uri = "https://${Server}/rest/api/1.0/admin/groups?name=${Name}"
    $Method = "POST" ; if ($Action -eq "Delete") { $Method = "DELETE" }
    Try {
        If ($PsCmdlet.ShouldProcess("$($Action) group $($Name)")) {
            $response = Invoke-RestMethod -Uri $Uri -Method $Method -Headers $LocalHeaders -ContentType "application/json" -Verbose:$False
        }
    } Catch {
        if ($_.Exception.Response.StatusCode.value__ -eq 409) {
            # just be silent when trying to create an extant group. 409: conflict.
            #Write-Warning "Group ""${name}"" already exists, continuing."
        } else {
            Write-Error $_
        }
    }
    # Bitbucket will return .name and .deletable if the object was successfully created.
}

Function Act-User-In-Group {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory = $False)][string]$Server = "gittest.example.com",
        [Parameter(Mandatory = $False)][Hashtable]$AuthHeaders = ${global:CloudHeaders},
        [Parameter(Mandatory = $True )][string]$GroupName,
        [Parameter(Mandatory = $True )][string]$UserName, # safer to pass in uuid when location=cloud
        [Parameter(Mandatory = $False)][string]$Instance = "example",
        [Parameter(Mandatory = $True )][string]$Action, # either Add or Remove
        [Parameter(Mandatory = $False)][string]$Location = "local" # "cloud" or "local"
    )
    Begin {
        If (!($Action -in @("Add","Remove"))) {
            Write-Error "Action must be in set @(""Add"",""Remove""). Aborted."
            break
        }
        If (!($Location -in @("local","cloud"))) {
            Write-Error "Location must be in set @(""local"",""cloud""). Aborted."
            break
        }
    }
    Process {
        If ($Action -eq "Add") {
            If ($Location -eq "local") {
                $data = @{
                    user = $UserName
                    groups = @( $GroupName )
                } | ConvertTo-Json -Depth 5
                $Restparams = @{
                    Uri = "https://${Server}/rest/api/1.0/admin/users/add-groups"
                    Method = "POST"
                }
            } ElseIf ($Location -eq "cloud") {
                # Reference: https://confluence.atlassian.com/bitbucket/groups-endpoint-296093143.html
                # WORKS: Invoke-RestMethod -ContentType "application/json" -Uri "https://api.bitbucket.org/1.0/groups/example/devops/members/exampleteam/" -Body "{}" -method "PUT" -Headers $CloudHeaders
                $data = "{}"
                $Restparams = @{
                    Uri = "https://${Server}/1.0/groups/${Instance}/${GroupName}/members/${UserName}/"
                    Method = "PUT"
                }
            }
        } Elseif ($Action -eq "Remove") {
            If ($Location -eq "local") {
                $data = @{
                    context = $UserName
                    itemName = $GroupName
                } | ConvertTo-Json -Depth 5
                $Restparams = @{
                    Uri = "https://${Server}/rest/api/1.0/admin/users/remove-group"
                    Method = "POST"
                }
            } ElseIf ($Location -eq "cloud") {
                $data = "{}"
                $Restparams = @{
                    Uri = "https://${Server}/1.0/groups/${Instance}/${GroupName}/members/${UserName}/"
                    Method = "DELETE"
                }
            }
        }

        # Generic headers, and the invocation
        $Restparams += @{ ContentType = "application/json" }
        $Restparams += @{ Body = $data }
        $Restparams += @{ Headers = $AuthHeaders }
        #$Restparams
        #$Restparams.body
        #$response = Invoke-RestMethod -Method "POST" "https://${Server}/rest/api/1.0/admin/users/add-groups" -Body $data -ContentType "application/json" -Headers $AuthHeaders
        Try {
            #Write-Verbose "$($Action) user $($Username) to group $($GroupName)"
            #$Restparams.Uri
            # WORKHERE
            If ($PsCmdlet.ShouldProcess("$($Action) user $($UserName) to group $($GroupName)")) {
                $response = Invoke-RestMethod @Restparams -Verbose:$False
            }
        }
        Catch {
            If ($Action -eq "Remove" -And $_.Exception.Response.StatusCode.value__ -eq 404) {
                # just be silent when trying to delete a non-existent group.
                #Write-Warning "Group ""${name}"" already exists, continuing."
            } ElseIf ($Action -eq "Add" -And $_.Exception.Response.StatusCode.value__ -eq 409) {
                # be silent when trying to add a user to a group he is already in
            } Else {
                Write-Error $_
                Write-Error $data
            }
        }
    }
}

Function WritePermissionsCsv_Message {
    Write-Error "WritePermissionsCsv must be a valid CSV file with columns named Repo, Name. Probably use useful-write-permissions.csv from Build-Useful-Write-Permissions-List."
}

Function ReposCsv_Message {
    Write-Error "ReposCsv must be a valid CSV with columns slug, NewProjectKey. Probably use bbcloudrepos.csv for real migration, or gittest-importtest-repos.csv."
}

# This is the individual step for making a $REPONAME-write group
Function Act-RepoPermission-Group {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory = $False)][string]$Server = "gittest.example.com",
        [Parameter(Mandatory = $True )][string]$Name,
        [Parameter(Mandatory = $True )][string]$ProjectSlug,
        [Parameter(Mandatory = $True )][string]$Action, # either Grant or Revoke
        [Parameter(Mandatory = $False)][Hashtable]$LocalHeaders = ${global:LocalHeaders},
        [Parameter(Mandatory = $False)][boolean]$Populate = $false, # if True, then add users based on PermissionsCsv
        [Parameter(Mandatory = $False)][string]$WritePermissionsCsv #
    )
    # assumptions: repo already exists.

    Begin {
        If ($Populate) {
            Try {
                If (!(Test-Path $WritePermissionsCsv)) { WritePermissionsCsv_Message ; break ; }
                $WritePermissions = Import-Csv $WritePermissionsCsv
                $ColumnNames = $WritePermissions[0].PsObject.Properties.Name
                If (!("Repo" -in $ColumnNames) -Or !("Name" -In $ColumnNames)) { WritePermissionsCsv_Message ; break ; }
            }
            Catch { WritePermissionsCsv_Message ; break ; }
        }
        If (!($Action -in @("Grant","Revoke"))) {
            Throw "Action must be either Grant or Revoke"
        }
    }

    Process {
        $Uri = "https://$($Server)/rest/api/1.0/projects/$($ProjectSlug)/repos/$($Name)/permissions/groups"
        $WriteGroup = "$($Name)-write"
        $WritePermissionName = "REPO_WRITE"

        If ($Action -eq "Grant") {
            # flow: create group and grant it permission
            #If ($PsCmdlet.ShouldProcess("Create group $($WriteGroup)")) { Act-Group -Action "Create" -Name "$($WriteGroup)" -LocalHeaders $LocalHeaders -Server $Server ; }
            Act-Group -Action "Create" -Name "$($WriteGroup)" -LocalHeaders $LocalHeaders -Server $Server
            $Restparams = @{
                Uri = "$($Uri)?permission=$($WritePermissionName)&name=$($WriteGroup)"
                Method = "PUT"
                Headers = $LocalHeaders
                ContentType = "application/json"
            }
            #$RestParams.Uri
            If ($PsCmdlet.ShouldProcess("$($Action) group $($WriteGroup) write perms to repo $($Name)")) {
                Invoke-RestMethod @RestParams -Verbose:$False
            }
            # And now do the populate step.
            If ($Populate -And $PsCmdlet.ShouldProcess("Populate group $($WriteGroup)")) {
                # For every permission entry where Repo column = $Name parameter for this function,
                $countUsersAdded = 0
                $WritePermissions | ? { $_.Repo -eq "$($Name)" } | % {
                    # add user to the group
                    #Write-Host "Please add user $($_.name) to $WriteGroup"
                    If ( "$($_.name)" -ne "") {
                        Try {
                            Act-User-In-Group -AuthHeaders $LocalHeaders -Server $Server -GroupName $WriteGroup -UserName $_.name -Action "Add" -Location "local" -WhatIf:$False -Confirm:$False
                            $countUsersAdded += 1
                        }
                        Catch {
                            Write-Error $PSItem
                            $countUsersAdded -= 1
                        }
                    } Else { # samaccountname is blank
                        Write-Warning "Unable to add user $_"
                    }
                }
                Write-Verbose "Added $countUsersAdded to group ""$WriteGroup"""
            }
        } elseif ($Action -eq "Revoke") {
            # flow: remove permission to group and delete it
            $Restparams = @{
                Uri = "$($Uri)?name=$($WriteGroup)"
                Method = "DELETE"
                Headers = $LocalHeaders
                ContentType = "application/json"
            }
            If ($PsCmdlet.ShouldProcess("$($Action) group $($WriteGroup) write perms to repo $($Name)")) { Invoke-RestMethod @RestParams -Verbose:$False ; }
            Act-Group -Action "Delete" -Name "$($WriteGroup)" -LocalHeaders $LocalHeaders -Server $Server
        }
    }
}

# goal: given the input file $WritePermissionsCsv, call Act-RepoPermission-Group for each repo.
Function Act-All-RepoPermission-Groups {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory = $False)][string]$Server = "gittest.example.com",
        [Parameter(Mandatory = $True )][string]$Action, # either Grant or Revoke
        [Parameter(Mandatory = $False)][Hashtable]$LocalHeaders = ${global:LocalHeaders},
        [Parameter(Mandatory = $False)][boolean]$Populate = $false, # if True, then add users based on PermissionsCsv
        [Parameter(Mandatory = $False)][string]$WritePermissionsCsv,
        [Parameter(Mandatory = $True )][string]$ReposCsv # columns slug, NewProjectKey, probably gittest-importest-repos.csv or bbcloudrepos.csv
    )
    Begin {
        Try {
            If (!(Test-Path $WritePermissionsCsv)) { WritePermissionsCsv_Message ; break ; }
            $WritePermissions = Import-Csv $WritePermissionsCsv
            $ColumnNames = $WritePermissions[0].PsObject.Properties.Name
            If (!("Repo" -in $ColumnNames) -Or !("Name" -In $ColumnNames)) { WritePermissionsCsv_Message ; break ; }
        }
        Catch { WritePermissionsCsv_Message ; break ; }

        Try {
            If (!(Test-Path $ReposCsv)) { ReposCsv_Message ; break ; }
            $Repos = Import-Csv $ReposCsv
            $ColumnNames = $Repos[0].PsObject.Properties.Name
            If (!("slug" -in $ColumnNames) -Or !("NewProjectKey" -In $ColumnNames)) { ReposCsv_Message ; break ; }
        }
        Catch { ReposCsv_Message ; break ; }

        If (!($Action -in @("Grant","Revoke"))) {
            Throw "Action must be either Grant or Revoke"
        }
        $count = 0
        $size = ($Repos | Group-Object { $_.Repo }).count
    }

    Process {
        $Repos | % {
            # for each repository listing
            Try {
                Act-RepoPermission-Group -Name $_.slug -ProjectSlug $_.NewProjectKey -Action $Action -LocalHeaders $Localheaders -Server $Server -Populate $Populate -WritePermissionsCsv $WritePermissionsCsv
                $count += 1 ;
                [int]$percent = ($count/$size)*100
                $percent = (@($percent, 100) | Measure -Minimum).Minimum
                $percent = (@($percent, 0) | Measure -Maximum).Maximum
                Write-Progress -Activity "$($Action) for repo" -Status "$count/$size" -PercentComplete $percent

            }
            Catch {
                $RepoCount -= 1
            }
        }
    }
}

# List users who have write permission on this one repo.
Function List-Write-Users-For-Cloud-Repo {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $True )][Hashtable]$CloudHeaders,
        [Parameter(Mandatory = $False)][string]$Server = "api.bitbucket.org",
        [Parameter(Mandatory = $False)][string]$Instance = "example",
        [Parameter(Mandatory = $True )][string]$Name,
        [Parameter(Mandatory = $False)][bool]$SkipCount = $False,
        [Parameter(Mandatory = $False)][string]$Property = "uuid"
    )
    $interim = _Iterate-Values-From-BB-API -StartUri "https://${Server}/2.0/teams/${Instance}/permissions/repositories/${Name}?q=permission%3D%22write%22" -Location "cloud" -SkipCount $SkipCount -Descriptor "for repo ""$Name"" write permission"
    $output = @()
    $interim | % {
        <# # In case I want to have a multi-field object in the array. $output += @{ username = $_.user.display_name repo = $_.repository.full_name }#>
        #$output += $_.user.display_name
        $output += $_.user.$($Property)
    }
    $output
}

# Return array of @{ Repo = "example/foobar", User = "bgstack15"} objects
# Usage:
#    $cloudRepos = List-All-Cloud-Repos -CloudHeaders $CloudHeaders -Verbose
#    $allWritePermissions = $cloudRepos | List-Write-Users-For-All-Cloud-Repos -CloudHeaders $CloudHeaders
#    $allWritePermissions | Export-Csv U:\2002\05\all-write-permissions.csv -NoTypeInformation
# Next steps:
#    use a function that makes ${Repo}-write group and adds the users
Function List-Write-Users-For-All-Cloud-Repos {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $True )][Hashtable]$CloudHeaders,
        [Parameter(Mandatory = $False)][string]$Server = "api.bitbucket.org",
        [Parameter(Mandatory = $False)][string]$Instance = "example",
        [Parameter(Mandatory = $False,ValueFromPipeLine = $true,ValueFromPipelineByPropertyName = $true)]$Repos
    )
    Begin {
        # list all repos
        #$Repos.count
        $repoCount = 0

        #if ($Repos -eq $Null) { $Repos = List-All-Cloud-Repos -CloudHeaders $CloudHeaders }
    }
    Process {
        # iterate through all repos
        #$repoCount = 0
        $_ | % {
            $repoCount += 1
            #$_.name
            $thisRepo = $_.name
            $interim = List-Write-Users-For-Cloud-Repo -CloudHeaders $CloudHeaders -Server $Server -Instance $Instance -Name $_.name -SkipCount $True
            Write-Host "Working on repo #$repoCount ""$thisRepo"""
            #$interim[3]
            $new = @()
            ForEach ($item in $interim) {
                #[psCustomObject]$newitem = $item.PsObject.Copy() # shallow copy is good enough
                $newItem = New-Object -TypeName psobject
                $newItem | Add-Member -MemberType NoteProperty -Name "Repo" -Value $thisRepo
                $newItem | Add-Member -MemberType NoteProperty -Name "User" -Value $item
                $new += $newitem
            }
            $new
        }
    }
}

# take a list of user ids from pipeline and convert to usable property
# I don't know where I was going with this.
Function Convert-User-To-Property-STUB {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $True )][Hashtable]$CloudHeaders,
        [Parameter(Mandatory = $False)][string]$Server = "api.bitbucket.org",
        [Parameter(Mandatory = $False)][string]$Instance = "example",
        [Parameter(Mandatory = $False,ValueFromPipeLine = $true,ValueFromPipelineByPropertyName = $true)]$UserIds
    )
    Begin {
        $userCount = 0
        Write-Error "STUB! I cannot remember where I was going with this but it looks cool."
    }

    Process {
        $_ | %{ 
            $userCount += 1

        }
    }

}

Function List-Groups {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $False)][string]$Server = "gittest.example.com",
        [Parameter(Mandatory = $False)][Hashtable]$AuthHeaders = ${global:CloudHeaders},
        [Parameter(Mandatory = $False)][string]$Instance = "example",
        [Parameter(Mandatory = $False)][string]$Location = "local", # "cloud" or "local"
        [Parameter(Mandatory = $False)][boolean]$SkipCount = $False
    )
    Begin {
        If (!($Location -in @("local","cloud"))) {
            Write-Error "Location must be in set @(""local"",""cloud""). Aborted."
            break
        }
    }
    Process {
        If ($Location -eq "cloud") {
            # we can cheat here because I know there are only 18 groups defined in bitbucket cloud, so no concerns about pagination. Plus this api endpoint is very poorly documented because it's a deprecated one despite the fact the 2.0 groups endpoint does not exist; so pagination methodology is undetermined.
            $Uri = "https://${Server}/1.0/groups/${Instance}/"
            Invoke-RestMethod -ContentType "application/json" -Uri $Uri -method "GET" -Headers $AuthHeaders
        } ElseIf ($Location -eq "local") {
            $Uri = "https://${Server}/rest/api/1.0/admin/groups"
            _Iterate-Values-From-BB-API -AuthHeaders $AuthHeaders -StartUri $Uri -Location $Location -Descriptor $Group -SkipCount $SkipCount

        }
    }
}

# Usage:
#    $cloudgroups = List-Groups -Location "cloud" -Server "api.bitbucket.org"
#    $cloudmemberships = $cloudgroups | List-Memberships-From-Cloud-Groups
#    $cloudmemberships | Export-Csv -NoTypeInformation "U:\2020\05\cloud-memberships.csv"
Function List-Memberships-From-Cloud-Groups {
    # $cloudgroups = List-Groups -Location "cloud" -Server "api.bitbucket.org"
    # $cloudmemberships = $cloudgroups | Select-Object name,members,slug | Select-Object -Property slug,name -ExpandProperty members | Select-Object -Property display_name,account_id,uuid,nickname,@{l="groupname";e={$_.name}},@{l="groupslug";e={$_.slug}}
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $False,ValueFromPipeLine = $true,ValueFromPipelineByPropertyName = $true)]$groups
    )
    Begin {
    }
    Process {
        $_ | Select-Object name,members,slug | Select-Object -Property name,slug -ExpandProperty members -Erroraction "silentlycontinue" | Select-Object -Property display_name,account_id,uuid,nickname,@{l="groupname";e={$_.name}},@{l="groupslug";e={$_.slug}}
    }
}

# goal: take $cloudmemberships (with attributes uuid, groupname) and take $Action on them
Function Act-All-Cloud-Memberships-From-List {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $False)][string]$Server = "api.bitbucket.org",
        [Parameter(Mandatory = $False)][Hashtable]$AuthHeaders = ${global:CloudHeaders},
        [Parameter(Mandatory = $False)][string]$Instance = "example",
        [Parameter(Mandatory = $True )][string]$Action, # either Add or Remove
        [Parameter(Mandatory = $False)][string]$Location = "cloud", # "cloud" or "local"
        [Parameter(Mandatory = $False,ValueFromPipeLine = $true)]$MembershipsPipeLine,
        [Parameter(Mandatory = $False)][string]$MembershipsCsv,
        [Parameter(Mandatory = $False)][PSObject]$Memberships,
        [Parameter(Mandatory = $False)][string]$GroupMatchProperty = "groupname"
        # WORKHERE
    )
    Begin {
        If (!($Action -in @("Add","Remove"))) {
            Write-Error "Action must be in set @(""Add"",""Remove""). Aborted."
            break
        }
        If (!($Location -in @("local","cloud"))) {
            Write-Error "Location must be in set @(""local"",""cloud""). Aborted."
            break
        }
        If (!($GroupMatchProperty -in @("groupname","groupslug"))) {
            Write-Error "GroupMatchProperty must be in set @(""groupname"",""groupslug""). Aborted."
            break
        }
    }
    Process {
        If ($MembershipsPipeLine -eq $Null) {
            If ($Memberships -eq $Null) {
                If ("$($MembershipsCsv)" -eq "" -Or !(Test-Path $MembershipsCsv)) {
                    Write-Error "Either pipe in or provide -Memberships with uuid,display_name,groupname values or provide MembershipsCsv file with such-named columns. Aborted."
                    break
                }
                Write-Warning "using csv"
                $Memberships = Import-Csv $MembershipsCsv
            } Else {
                Write-Warning "using memberships var directly"
            }
        } Else {
            #Write-Warning "using pipeline"
            $Memberships = $MembershipsPipeLine
        }
        $Memberships | ? { $_.groupname -ne "Administrators" } | % { 
            Write-Verbose "$($Action) user $($_.display_name) ( uuid $($_.uuid) ) to group $($_.$($GroupMatchProperty))"
            Act-User-In-Group -Server $Server -AuthHeaders $AuthHeaders -GroupName "$($_.$($GroupMatchProperty))" -UserName $_.uuid -Instance $Instance -Action $Action -Location $Location
        }
    }
}

# Goal: take Repo,User list and the display_name,email list and output a repo,samaccountname,user,email table.
# The NamesToEmailsCsv is a manually curated list that is boiled down from all the Users listed from the WritePermissionsCsv ( | sort | uniq ) with email address for each user.
# Usage:
#    $list = Build-Useful-Write-Permissions-List -WritePermissionsCsv "U:\2020\05\all-write-permissions.csv" -NamesToEmailsCsv "U:\2020\05\bitbucket-emails.csv"
#    $list | Export-Csv -NoTypeInformation "U:\2020\05\useful-write-permissions.csv"
Function Build-Useful-Write-Permissions-List {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $True )][string]$WritePermissionsCsv,
        [Parameter(Mandatory = $True )][string]$NamesToEmailsCsv,
        [Parameter(Mandatory = $False)][string]$DefaultDomain = "example.com"
    )
    Begin {
        If (!(Test-Path $WritePermissionsCsv)) {
            Write-Error "WritePermissionsCsv must be a valid CSV file with columns named Repo, User."
            break
        }
        If (!(Test-Path $NamesToEmailsCsv)) {
            Write-Error "NamesToEmailsCsv must be a valid CSV file with columns named display_name,email."
            break
        }
        $Output = @()
        $count = 0
        $WritePermissions = Import-Csv $WritePermissionsCsv
        $NamesToEmails = Import-Csv $NamesToEmailsCsv
        $size = $WritePermissions.count
    }

    Process {
        ForEach ($wpitem in $WritePermissions) {
            if (!($wpitem.User -In $NamesToEmails.display_name)) {
                # just be silent
                #Write-Error "Cannot find $($wpitem.User) in NamesToEmails"
            } Else {
                $count += 1 ;
                [int]$percent = ($count/$size)*100
                $percent = (@($percent, 100) | Measure -Minimum).Minimum
                $percent = (@($percent, 0) | Measure -Maximum).Maximum
                Write-Progress -Activity "Listing perm" -Status "$count/$size" -PercentComplete $percent

                $NamesToEmails | % {
                    $NameToEmailitem = $_
                    if ($NameToEmailItem.display_name -eq $wpitem.User) {
                        If ("$($NameToEmailItem.email)" -ne "") {
                            $thisADUser = Get-ADUser -Filter "mail -eq '$($NameToEmailItem.email)'" -Properties mail
                            if (!($thisADUser -ne $null)) {
                                # if it is still empty, treat the stored email as a upn
                                $thisADUser = Get-ADUser -Filter "userprincipalname -eq '$($NameToEmailItem.email)'" -Properties mail
                            }
                            if (!($thisADUser -ne $null)) {
                                # if still empty, try it as sammaccountname@domain
                                $matchString = $($NameToEmailItem.email.split("@")[0])
                                $thisADUser = Get-ADUser -Filter "samaccountname -eq '$matchString'" -Properties mail
                            }
                            if (!($thisADUser -ne $null)) {
                                Write-Error "Unable to match $($NameToEmailItem.email) to an AD user!"
                            } else {
                                $newItem = New-Object -TypeName psobject
                                $newItem | Add-Member -MemberType NoteProperty -Name "Repo" -Value $wpitem.Repo
                                $newItem | Add-Member -MemberType NoteProperty -Name "Displayname" -Value $wpitem.User
                                $newItem | Add-Member -MemberType NoteProperty -Name "Mail" -Value $thisADUser.mail
                                $newItem | Add-Member -MemberType NoteProperty -Name "Name" -Value $thisADUser.samaccountname
                                $Output += $newItem
                                Write-Verbose $newItem
                            }
                        } Else { # so if there is no email address listed for this user
                            # well apparently it lists the non-email-mapped ones a lot, so just be quiet.
                            #Write-Error "No email for user $($NameToEmailItem.display_name)"
                        }
                    } 
                }
            }
        }
    }
    End { $Output ; }
}

# Procedural section
if ($LoadVars) {
    . '\\rdputils1\e$\scripts\Functions\credlib.ps1'
    $cloudcred = Get-Shared-Credential -User "exampleteam" -PasswordCategory exampleteam
    $CloudHeaders = Get-Bitbucket-Auth-Headers -Credential $cloudcred
    $bbprodcred = Get-Shared-Credential -User "serviceaccount"-PasswordCategory "serviceaccount"
    $bbprodHeaders = Get-Bitbucket-Auth-Headers -Credential $bbprodcred
    $localcred = $bbprodcred
    $localHeaders = Get-Bitbucket-Auth-headers -Credential $localcred
    Remove-Variable LoadVars
}

Some thoughts that might guide users

The auth-headers functions depend on the aforementioned credlib library which is not here, but the Get-Basic-Base64-Auth-Headers function basically returns a hashtable of Authorization: Basic aabbbxx== which is just the base64 encoding of the "username:password" syntax. I should document it better, but I'll save it for the credlib post. Some of the best work is in the "private" function _Iterate-Values-From-BB-API. It attempts to count the number of objects being counted, and display the percentage, unless -SkipCount $True. This one function is currently written for Bitbucket Cloud as well as Bitbucket Server 5.16.0 and above. The pagination across API versions is a little different, so the function is a little complex in how it collects the values. If a function uses -LocalHeaders, then I tested it only against the Bitbucket Server, and the same for -CloudHeaders for Bitbucket Cloud. I used -AuthHeaders when a function was tested against both. The functions tend to get fancier towards the bottom, including even from-pipeline values or by parameter, or from -CsvFile options!

Comments