AzDOCmd.psm1


<#
.SYNOPSIS
Gets information on an agent pool (or pools) in Azure Pipelines.
 
.DESCRIPTION
Gets information on an agent pool (or pools) in Azure Pipelines.
 
.PARAMETER Name
Name of the pool to get information on. All pools will be returned if nothing is specified.
 
.PARAMETER Pat
Personal access token authorized to administer builds. Defaults to $env:SYSTEM_ACCESSTOKEN for use in Azure
Pipelines.
 
.EXAMPLE
Get-AzDOAgentPool -Name 'Azure Pipelines'
 
.LINK
https://docs.microsoft.com/en-us/rest/api/azure/devops/distributedtask/pools/get%20agent%20pools
 
.NOTES
The Cmdlet will work as-is in a UI Pipeline with the default $Pat parameter as long as OAUTH access has been
enabled for the pipeline/job. If using a YAML build, the system.accesstoken variable needs to be explicitly
mapped to the steps environment like the following example:
 
steps:
- powershell: Invoke-WebRequest -Uri $Uri -Headers ( Initialize-AzDORestApi )
  env:
    SYSTEM_ACCESSTOKEN: $(system.accesstoken)
#>

function Get-AzDOAgentPool {
    [CmdletBinding()]
    param (
        [Parameter(Position = 0)]
        [String[]]$Name,
        [Switch]$NoRetry,
        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [String]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '6.1'
        }
    }

    process {
        # TODO: figure out how to get agent pools from projects
        $restArgs = @{
            Method   = 'Get'
            Endpoint = 'distributedtask/pools'
            NoRetry  = $NoRetry
        }

        if ($Name) {
            foreach ($filter in $Name) {
                Write-Verbose -Message "Getting information for the $filter agent pool..."
                $restArgs['Params'] = "poolName=$filter"
                Invoke-AzDORestApiMethod @script:AzApiHeaders @restArgs
            }
        }
        else {
            Write-Verbose -Message 'Getting information for all agent pools...'
            Invoke-AzDORestApiMethod @script:AzApiHeaders @restArgs
        }
    }
}

<#
.SYNOPSIS
Gets information for an Azure DevOps project.
 
.DESCRIPTION
Gets information for an Azure DevOps project.
 
.PARAMETER Name
Name of the project.
 
.PARAMETER CollectionUri
https://dev.azure.com/[organization]
 
.PARAMETER Pat
A personal access token authorized as a reader for the collection.
 
.EXAMPLE
Get-AzDOProject -Name MyProject
 
.LINK
https://docs.microsoft.com/en-us/rest/api/azure/devops/core/projects/get
 
.NOTES
N/A
#>

function Get-AzDOProject {
    [CmdletBinding()]
    param (
        [Parameter(Position = 0)]
        [String[]]$Name,
        [Switch]$NoRetry,
        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [String]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '6.1'
        }

        $restParams = @{
            Method  = 'Get'
            Params  = @('includeCapabilities=true')
            NoRetry = $NoRetry
        }
    }

    process {
        if ($Name) {
            foreach ($ref in $Name) {
                Invoke-AzDORestApiMethod `
                    @script:AzApiHeaders `
                    @restParams `
                    -Endpoint "projects/$ref"
            }
        }
        else {
            Invoke-AzDORestApiMethod `
                @script:AzApiHeaders `
                @restParams `
                -Endpoint 'projects'
        }
    }
}

<#
.SYNOPSIS
Gets information about an Azure DevOps package feed.
 
.DESCRIPTION
Gets information about an Azure DevOps package feed.
 
.PARAMETER Name
Name of the feed.
 
.PARAMETER Project
Project that the feed is scoped to. If nothing is specified, it will look for Organization-scoped feeds.
 
.PARAMETER CollectionUri
https://dev.azure.com/[organization]
 
.PARAMETER Pat
A personal access token authorized to access feeds.
 
.EXAMPLE
Get-AzDOPackageFeed -Name PulseFeed, ScmFeed
 
.NOTES
General notes
#>

function Get-AzDOPackageFeed {
    [CmdletBinding()]
    param (
        [String[]]$Name,
        [String[]]$Project = @(''),
        [Switch]$NoRetry,
        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [String]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '5.1-preview.1'
        }
    }

    process {
        foreach ($projectName in $Project) {
            $allFeeds = @()
            $allFeeds += Invoke-AzDORestApiMethod `
                @script:AzApiHeaders `
                -Method Get `
                -SubDomain 'feeds' `
                -Project $projectName `
                -Endpoint 'packaging/feeds' `
                -NoRetry:$NoRetry
            foreach ($feed in $allFeeds) {
                $feed | Add-Member `
                    -MemberType NoteProperty `
                    -Name location `
                    -Value "$($CollectionUri)/_packaging/$($feed.name)/nuget/v3/index.json"
            }

            $orgName = $CollectionUri -replace 'https://dev.azure.com/', ''
            if (!$allFeeds) {
                $message = 'No feeds found in $orgName '
                if (![String]::isNullOrEmpty($projectName)) {
                    $message += "for project $projectName"
                }
                Write-Warning -Message $message
            }
            elseif ($Name) {
                $namedFeeds = $allFeeds | ForEach-Object -Process {
                    foreach ($feedName in $Name) {
                        if ($feedName -eq $_.name) {
                            $_
                        }
                    }
                }
                if ($namedFeeds) {
                    foreach ($namedFeed in $namedFeeds) {
                        $namedFeed
                    }
                }
                else {
                    $message = "No feeds named $($Name -join ', ') found in $orgName "
                    if (![String]::isNullOrEmpty($projectName)) {
                        $message += "for project $projectName"
                    }
                    Write-Warning -Message $message
                }
            }
            else {
                foreach ($feed in $allFeeds) {
                    $feed
                }
            }
        }
    }
}

<#
.SYNOPSIS
Exports a pipeline definition's json file.
 
.DESCRIPTION
Exports a pipeline definition's json file.
 
.PARAMETER PipelineDefinition
A pipeline definition passed via the pipeline from Get-BuildPipeline.
 
.PARAMETER Destination
Destination folder of the json backup files.
 
.PARAMETER Pat
Personal access token authorized to administer pipelines and releases. Defaults to $env:SYSTEM_ACCESSTOKEN for use
in Azure Pipelines.
 
.EXAMPLE
Get-AzDOPipeline -Project Packages -Name AzurePipeline* | Export-AzDOPipeline
 
.NOTES
The Cmdlet will work as-is in a UI Pipeline with the default $Pat parameter as long as OAUTH access has been
enabled for the pipeline/job. If using a YAML build, the system.accesstoken variable needs to be explicitly
mapped to the steps environment like the following example:
 
steps:
- powershell: Invoke-WebRequest -Uri $Uri -Headers ( Initialize-AzDORestApi )
  env:
    SYSTEM_ACCESSTOKEN: $(system.accesstoken)
#>


function Export-AzDOPipeline {
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true)]
        [System.Object[]]$PipelineDefinition,
        [string]$Destination = 'azure-pipelines',
        [Switch]$NoRetry,
        [String[]]$Project = $env:SYSTEM_TEAMPROJECT,
        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [string]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '7.2-preview.4'
        }

        $null = New-Item -Path $Destination -ItemType Directory -Force
    }

    process {
        foreach ($definition in $PipelineDefinition) {
            $outFileName = "$Destination/$($definition.name).json"
            Invoke-WebRequest -Uri $definition.url -Headers $script:AzApiHeaders['Headers'] -UseBasicParsing |
                Select-Object -ExpandProperty Content |
                Out-File -FilePath $outFileName -Encoding UTF8 -Force
            Get-Item -Path $outFileName
        }
    }
}

<#
.SYNOPSIS
Gets a build definition object from Azure Pipelines.
 
.DESCRIPTION
Gets a build definition object from Azure Pipelines using a project and name filter.
 
.PARAMETER Name
A filter to search for pipeline names.
 
.PARAMETER Id
The pipeline ID to get.
 
.PARAMETER Project
Project that the pipelines reside in.
 
.PARAMETER CollectionUri
The project collection URL (https://dev.azure.com/[orgranization]).
 
.PARAMETER Pat
Personal access token authorized to administer builds. Defaults to $env:SYSTEM_ACCESSTOKEN for use in Azure
Pipelines.
 
.EXAMPLE
Get-AzDOPipeline -Project Packages -Name AzurePipeline*
 
.LINK
https://docs.microsoft.com/en-us/rest/api/azure/devops/pipelines/pipelines/get
 
.LINK
https://docs.microsoft.com/en-us/rest/api/azure/devops/pipelines/pipelines/list
 
.NOTES
The Cmdlet will work as-is in a UI Pipeline with the default $Pat parameter as long as OAUTH access has been
enabled for the pipeline/job. If using a YAML build, the system.accesstoken variable needs to be explicitly
mapped to the steps environment like the following example:
 
steps:
- powershell: Invoke-WebRequest -Uri $Uri -Headers ( Initialize-AzDORestApi )
  env:
    SYSTEM_ACCESSTOKEN: $(system.accesstoken)
#>


function Get-AzDOPipeline {
    [CmdletBinding(DefaultParameterSetName = 'Name')]
    param (
        [Parameter(ParameterSetName = 'Name', Position = 0)]
        [String[]]$Name,
        [Parameter(ParameterSetName = 'Id', Position = 0)]
        [Int[]]$Id,
        [Switch]$NoRetry,
        [String[]]$Project = $env:SYSTEM_TEAMPROJECT,
        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [string]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '6.1'
        }
    }

    process {
        if ($Id) {
            foreach ($projectName in $Project) {
                $pipeline = Invoke-AzDORestApiMethod `
                    @script:AzApiHeaders `
                    -Method Get `
                    -Project $projectName `
                    -Endpoint 'build/definitions' `
                    -Params "definitionIds=$($Id -join ',')" `
                    -NoRetry:$NoRetry `
                    -WhatIf:$false
                if ($pipeline) {
                    $pipeline
                }
                else {
                    Write-Warning -Message "Pipeline $Id not found in $projectName."
                }
            }
        }
        elseif ($Name) {
            foreach ($filter in $Name) {
                foreach ($projectName in $Project) {
                    $pipelineResponse = Invoke-AzDORestApiMethod `
                        @script:AzApiHeaders `
                        -Method Get `
                        -Project $projectName `
                        -Endpoint 'build/definitions' `
                        -Params "name=$filter" `
                        -NoRetry:$NoRetry `
                        -WhatIf:$false
                    if ($pipelineResponse) {
                        $pipelineResponse
                    }
                    else {
                        Write-Warning -Message "No pipelines found matching '$filter' in $projectName."
                    }
                }
            }
        }
        else {
            foreach ($projectName in $Project) {
                $pipelineResponse = Invoke-AzDORestApiMethod `
                    @script:AzApiHeaders `
                    -Method Get `
                    -Project $projectName `
                    -Endpoint 'build/definitions' `
                    -NoRetry:$NoRetry `
                    -WhatIf:$false
                if ($pipelineResponse) {
                    $pipelineResponse
                }
                else {
                    Write-Warning -Message "No pipelines found in $projectName."
                }
            }
        }
    }
}

<#
.SYNOPSIS
Gets details for a specific build by ID or user email.
 
.DESCRIPTION
Gets details for a specific build by ID or user email, with filters for build result, status, and reason.
 
.PARAMETER BuildId
The ID of the build to get.
 
.PARAMETER User
The email of the user that the build(s) to get was requested for.
 
.PARAMETER Result
Only return builds with the specified result.
 
.PARAMETER Status
Only return builds with the specified status.
 
.PARAMETER Reason
Only return builds with the specified reason.
 
.PARAMETER MaxBuilds
The maximum number of builds per project to get. Defaults to 10.
 
.PARAMETER Project
Project that the build's pipeline resides in.
 
.PARAMETER Pat
Personal access token authorized to administer builds. Defaults to $env:SYSTEM_ACCESSTOKEN for use in Azure
Pipelines.
 
.EXAMPLE
Get-AzDOPipelineRun -BuildId 111111 -Project MyProject
Gets the build with the specified ID.
 
.EXAMPLE
Get-AzDOPipelineRun -User myorg@dev.azure.com -Status completed -Reason buildCompletion
Gets 10 completed builds that were triggered by another build's completion started by myorg@dev.azure.com.
 
.NOTES
The Cmdlet will work as-is in a UI Pipeline with the default $Pat parameter as long as OAUTH access has been
enabled for the pipeline/job. If using a YAML build, the system.accesstoken variable needs to be explicitly
mapped to the steps environment like the following example:
 
steps:
- powershell: Invoke-WebRequest -Uri $Uri -Headers ( Initialize-AzDORestApi )
  env:
    SYSTEM_ACCESSTOKEN: $(system.accesstoken)
.LINK
https://docs.microsoft.com/en-us/rest/api/azure/devops/build/builds/get?view=azure-devops-rest-6.0
#>


function Get-AzDOPipelineRun {
    [CmdletBinding(DefaultParameterSetName = 'BuildId')]
    param (
        [Parameter(
            ValueFromPipelineByPropertyName = $true,
            Mandatory = $true,
            Position = 1,
            ParameterSetName = 'BuildId'
        )]
        [Alias('id')]
        [Int[]]
        $BuildId,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'User'
        )]
        [Alias('requestedFor')]
        [String[]]
        $User,

        [Parameter(
            ParameterSetName = 'User'
        )]
        [ValidateSet(
            'canceled',
            'failed',
            'none',
            'partiallySucceeded',
            'succeeded'
        )]
        [String[]]
        $Result,

        [Parameter(
            ParameterSetName = 'User'
        )]
        [ValidateSet(
            'all',
            'cancelling',
            'completed',
            'inProgress',
            'none',
            'notStarted',
            'postponed'
        )]
        [String[]]
        $Status,

        [Parameter(
            ParameterSetName = 'User'
        )]
        [ValidateSet(
            'all',
            'batchedCI',
            'buildCompletion',
            'checkInShelveset',
            'individualCI',
            'manual',
            'none',
            'pullRequest',
            'resourceTrigger',
            'schedule',
            'scheduleForced',
            'triggered',
            'userCreated',
            'validateShelveset'
        )]
        [String[]]
        $Reason,

        [Parameter(
            ParameterSetName = 'User'
        )]
        [String]
        $MaxBuilds = 10,

        [Switch]
        $NoRetry,

        [Parameter(
            ValueFromPipelineByPropertyName = $true
        )]
        [System.Object[]]
        $Project = $env:SYSTEM_TEAMPROJECT,

        [String]
        $CollectionUri = $env:SYSTEM_COLLECTIONURI,

        [string]
        $Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '6.1'
        }
    }

    process {
        . $PSScriptRoot/private/Get-AzApiProjectName.ps1
        $Project = $Project | Get-AzApiProjectName

        foreach ($id in $BuildId) {
            foreach ($projectName in $Project) {
                try {
                    $buildInfo = Invoke-AzDORestApiMethod `
                        @script:AzApiHeaders `
                        -Method Get `
                        -Project $projectName `
                        -Endpoint "build/builds/$id" `
                        -NoRetry `
                        -WhatIf:$false `
                        -ErrorAction SilentlyContinue
                }
                catch {
                    Write-Verbose -Message (
                        $_ | Out-String
                    )
                }
                if ($buildInfo) {
                    $buildInfo
                }
                else {
                    Write-Warning -Message "Build $id not found in $projectName."
                }
            }
        }

        foreach ($email in $User) {
            $params = @(
                "requestedFor=$email",
                "`$top=$MaxBuilds",
                'buildQueryOrder=queueTimeDescending'
            )
            if ($Result) {
                $params += "resultFilter=$($Result -join ',')"
            }
            if ($Status) {
                $params += "statusFilter=$($Status -join ',')"
            }
            if ($Reason) {
                $params += "reasonFilter=$($Reason -join ',')"
            }

            foreach ($projectName in $Project) {
                $buildInfo = Invoke-AzDORestApiMethod `
                    @script:AzApiHeaders `
                    -Method Get `
                    -Project $projectName `
                    -Endpoint 'build/builds' `
                    -Params $params `
                    -NoRetry:$NoRetry `
                    -WhatIf:$false

                if ($buildInfo) {
                    $buildInfo
                }
                else {
                    Write-Warning -Message (
                        "$($Result -join '/')/$($Status -join '/')/$($Reason -join '/') " +
                        "builds for $User not found in $projectName."
                    )
                }
            }
        }
    }
}

<#
.SYNOPSIS
Short description
 
.DESCRIPTION
Long description
 
.PARAMETER BuildPipeline
A build pipeline object returned from Get-BuildPipeline.
 
.PARAMETER Branch
Only return builds from a given branch.
 
.PARAMETER MaxBuilds
The number of most recent builds to get. Defaults to 10.
 
.PARAMETER HistoryInDays
Only return builds from this number of days in the past (including today).
 
.PARAMETER IncludePr
Include PR builds in the list (disabled by default).
 
.PARAMETER HasResult
Only return builds that have failed, succeeded or partially succeeded.
 
.PARAMETER Succeeded
Include only completed and succeeded builds.
 
.PARAMETER Pat
Personal access token authorized to administer builds. Defaults to $env:SYSTEM_ACCESSTOKEN for use in Azure
Pipelines.
 
.EXAMPLE
Get-BuildPipeline -Name utils-integration-checkin -Project MyProject | Get-AzDOPipelineRunList -MaxBuilds 3
 
.NOTES
The Cmdlet will work as-is in a UI Pipeline with the default $Pat parameter as long as OAUTH access has been
enabled for the pipeline/job. If using a YAML build, the system.accesstoken variable needs to be explicitly
mapped to the steps environment like the following example:
 
steps:
- powershell: Invoke-WebRequest -Uri $Uri -Headers ( Initialize-AzDORestApi )
  env:
    SYSTEM_ACCESSTOKEN: $(system.accesstoken)
 
.LINK
https://docs.microsoft.com/en-us/rest/api/azure/devops/build/builds/list
#>

function Get-AzDOPipelineRunList {
    [CmdletBinding(DefaultParameterSetName = 'Max')]
    param (
        [Parameter(ValueFromPipeline = $true, Mandatory = $true)]
        [System.Object[]]$BuildPipeline,
        [String]$Branch,
        [Parameter(ParameterSetName = 'Max')]
        [Int]$MaxBuilds,
        [Parameter(ParameterSetName = 'Date')]
        [Int]$HistoryInDays,
        [Switch]$IncludePr,
        [Switch]$InProgress,
        [Switch]$HasResult,
        [Alias('Completed')]
        [Switch]$Succeeded,
        [Switch]$NoRetry,
        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [String]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '6.1'
        }
    }

    process {
        foreach ($pipeline in $BuildPipeline) {
            do {
                $params = @(
                    "definitions=$($pipeline.id -join ',')"
                )

                if ($HistoryInDays) {
                    $minTime = [DateTime]::Today.AddDays(-$HistoryInDays).ToUniversalTime() |
                        Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.ffK'
                    $params += @(
                        "minTime=$minTime"
                    )
                }

                $buildReasons = (
                    'reasonFilter=batchedCI,buildCompletion,resourceTrigger,individualCI,manual,schedule,triggered'
                )
                if ($IncludePr) {
                    $buildReasons += ',pullRequest'
                }
                $params += $buildReasons

                if ($InProgress) {
                    $params += @(
                        'statusFilter=inProgress,NotStarted'
                    )
                }
                elseif ($HasResult) {
                    $params += @(
                        'statusFilter=completed',
                        'resultFilter=canceled,failed,partiallySucceeded,succeeded'
                    )
                }
                elseif ($Succeeded) {
                    $params += @(
                        'statusFilter=completed',
                        'resultFilter=succeeded'
                    )
                }

                if ($MaxBuilds -and !$HistoryInDays) {
                    # Add the amount of erroneous PR builds included in the result and had to be removed
                    if ($null -ne $finalBuildInfo) {
                        $addMore = $top - $finalBuildInfo.Count
                    }
                    else {
                        $addMore = 0
                    }
                    $top = $MaxBuilds + $addMore
                    $params += @(
                        "`$top=$top"
                    )
                }

                if ($Branch) {
                    $params += "branchName=refs/heads/$Branch"
                }


                $buildInfo = @()
                $buildInfo += Invoke-AzDORestApiMethod `
                    @script:AzApiHeaders `
                    -Method Get `
                    -Project $pipeline.project.name `
                    -Endpoint 'build/builds' `
                    -Params $params `
                    -NoRetry:$NoRetry |
                    Sort-Object -Descending -Property id
                if ($buildInfo -and !$IncludePr) {
                    # The reasonFilter parameter seems to be broken so we
                    # need to manually filter pullRequest builds
                    $finalBuildInfo = @()
                    $finalBuildInfo += $buildInfo | Where-Object -Property reason -NE 'pullRequest'
                }
                elseif ($buildInfo) {
                    $finalBuildInfo = $buildInfo
                }
                # Since the pullRequest reason filter isn't working, it's possible that
                # MaxBuilds won't find a single build with the specified parameter, so we
                # need to loop through until it's met.
            } while (
                $buildInfo -and
                $finalBuildInfo.Count -lt $MaxBuilds -and
                ($top -lt 100 -or $top -le ($MaxBuilds * 2))
            )
            if ($finalBuildInfo) {
                $finalBuildInfo
            }
            else {
                Write-Warning -Message (
                    "No builds found in the $($pipeline.name) pipeline with " +
                    "the specified parameters:`n`t" +
                    ($params -join "`n`t")
                )
            }
        }
    }
}

<#
.SYNOPSIS
Gets a release pipeline definition object from Azure Pipelines.
 
.DESCRIPTION
Gets a release pipeline definition object from Azure Pipelines using a project and name filter.
 
.PARAMETER Name
A filter to search for release pipeline names.
 
.PARAMETER Id
The release pipeline ID to get.
 
.PARAMETER Project
Project that the release pipelines reside in.
 
.PARAMETER CollectionUri
The project collection URL (https://dev.azure.com/[orgranization]).
 
.PARAMETER Pat
Personal access token authorized to administer releases. Defaults to $env:SYSTEM_ACCESSTOKEN for use in Azure
Pipelines.
 
.EXAMPLE
Get-AzDOReleasePipeline -Project Packages -Name ReleasePipeline*
 
.LINK
https://learn.microsoft.com/en-us/rest/api/azure/devops/release/definitions/get
 
.LINK
https://docs.microsoft.com/en-us/rest/api/azure/devops/release/pipelines/list
 
.NOTES
The Cmdlet will work as-is in a UI Pipeline with the default $Pat parameter as long as OAUTH access has been
enabled for the pipeline/job. If using a YAML build, the system.accesstoken variable needs to be explicitly
mapped to the steps environment like the following example:
 
steps:
- powershell: Invoke-WebRequest -Uri $Uri -Headers ( Initialize-AzDORestApi )
  env:
    SYSTEM_ACCESSTOKEN: $(system.accesstoken)
#>


function Get-AzDOReleasePipeline {
    [CmdletBinding(DefaultParameterSetName = 'Name')]
    param (
        [Parameter(ParameterSetName = 'Name', Position = 0)]
        [String[]]$Name,
        [Parameter(ParameterSetName = 'Id', Position = 0)]
        [Int[]]$Id,
        [Switch]$NoRetry,
        [String[]]$Project = $env:SYSTEM_TEAMPROJECT,
        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [string]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        . "$PSScriptRoot/../../private/Add-AzDOProject.ps1"

        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '7.2-preview.4'
        }
    }

    process {
        if ($Id) {
            foreach ($projectName in $Project) {
                $pipeline = Invoke-AzDORestApiMethod `
                    @script:AzApiHeaders `
                    -Method Get `
                    -SubDomain vsrm `
                    -Project $projectName `
                    -Endpoint 'release/definitions' `
                    -Params @(
                        "definitionIds=$($Id -join ',')"
                        'propertyFilters=variables,environments'
                     ) `
                    -NoRetry:$NoRetry `
                    -WhatIf:$false
                if ($pipeline) {
                    $pipeline
                }
                else {
                    Write-Warning -Message "Pipeline $Id not found in $projectName."
                }
            }
        }
        elseif ($Name) {
            foreach ($filter in $Name) {
                foreach ($projectName in $Project) {
                    $pipelineResponse = Invoke-AzDORestApiMethod `
                        @script:AzApiHeaders `
                        -Method Get `
                        -SubDomain vsrm `
                        -Project $projectName `
                        -Endpoint 'release/definitions' `
                        -Params @(
                            "searchText=$filter"
                            'propertyFilters=variables,environments'
                         ) `
                        -NoRetry:$NoRetry `
                        -WhatIf:$false
                    if ($pipelineResponse) {
                        $pipelineResponse | Add-AzDOProject -NoRetry:$NoRetry -CollectionUri $CollectionUri -Pat $Pat
                    }
                    else {
                        Write-Warning -Message "No pipelines found matching '$filter' in $projectName."
                    }
                }
            }
        }
        else {
            foreach ($projectName in $Project) {
                $pipelineResponse = Invoke-AzDORestApiMethod `
                    @script:AzApiHeaders `
                    -Method Get `
                    -SubDomain vsrm `
                    -Project $projectName `
                    -Endpoint 'release/definitions' `
                    -Params 'propertyFilters=variables,environments' `
                    -NoRetry:$NoRetry `
                    -WhatIf:$false
                if ($pipelineResponse) {
                    $pipelineResponse | Add-AzDOProject -NoRetry:$NoRetry -CollectionUri $CollectionUri -Pat $Pat
                }
                else {
                    Write-Warning -Message "No pipelines found in $projectName."
                }
            }
        }
    }
}

<#
.SYNOPSIS
Updates a release pipeline definition.
 
.DESCRIPTION
Updates a release pipeline definition using a provided json file.
 
.PARAMETER PipelineId
ID of the pipeline to update. Accepts values from the pipeline.
 
.PARAMETER Project
Project that the pipelines reside in.
 
.PARAMETER JsonFilePath
FilePath of the release definition json with updated values.
 
.PARAMETER Pat
Personal access token authorized to administer releases. Defaults to $env:SYSTEM_ACCESSTOKEN for use in
AzurePipelines.
 
.EXAMPLE
Get-AzDOReleasePipeline -Name 'MyRelease' -Project 'MyProject' |
    Set-AzDOReleaseRetention -DaysToKeep 30 -ReleasesToKeep 3
 
id name retentionPolicy
-- ---- ---------------
 1 Stage 1 @{daysToKeep=30; releasesToKeep=3; retainBuild=True}
 
.LINK
https://learn.microsoft.com/en-us/rest/api/azure/devops/release/definitions/update
 
.NOTES
The Cmdlet will work as-is in a UI Pipeline with the default $Pat parameter as long as OAUTH access has been
enabled for the pipeline/job. If using a YAML build, the system.accesstoken variable needs to be explicitly
mapped to the steps environment like the following example:
 
steps:
- powershell: Invoke-WebRequest -Uri $Uri -Headers ( Initialize-AzDORestApi )
  env:
    SYSTEM_ACCESSTOKEN: $(system.accesstoken)
#>


function Set-AzDOReleaseRetention {
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [Alias('id')]
        [String]$PipelineId,
        [String[]]$Environment,
        [Int]$DaysToKeep = 30,
        [Int]$ReleasesToKeep = 3,
        [Switch]$NoRetry,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [System.Object]$Project = $env:SYSTEM_TEAMPROJECT,
        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [string]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '7.2-preview.4'
        }
    }

    process {
        . $PSScriptRoot/private/Get-AzDOApiProjectName.ps1
        $Project = $Project | Get-AzDOApiProjectName

        $releaseDefinition = Get-AzDOReleasePipeline `
            -Id $PipelineId `
            -NoRetry:$NoRetry `
            -Project $Project `
            -CollectionUri $CollectionUri `
            -Pat $Pat

        $exportedDefinitionFile = $releaseDefinition |
            Export-AzDOPipeline `
                -Destination $env:TEMP `
                -NoRetry:$NoRetry `
                -Project $Project `
                -CollectionUri $CollectionUri `
                -Pat $Pat
        $exportedDefinition = $exportedDefinitionFile |
            Get-Content -Raw -Encoding utf8 |
            ConvertFrom-Json -Depth 10
        $exportedDefinitionFile | Remove-Item -Force

        $environmentsToSet = if ($Environment) {
            foreach ($env in $Environment) {
                $exportedDefinition.environments.name | Where-Object -FilterScript { $_ -eq $env }
            }
        }
        else {
            $exportedDefinition.environments.name
        }

        foreach ($env in $environmentsToSet) {
            $exportedDefinition.environments |
                Where-Object -Property name -eq $env |
                ForEach-Object -Process {
                    $_.retentionPolicy.daysToKeep = $DaysToKeep
                    $_.retentionPolicy.releasesToKeep = $ReleasesToKeep
                    $_.retentionPolicy.retainBuild = $true
                }
        }

        Invoke-AzDORestApiMethod `
            @script:AzApiHeaders `
            -Method Put `
            -SubDomain vsrm `
            -Project $Project `
            -Endpoint "release/definitions/$PipelineId" `
            -Body ( $exportedDefinition | ConvertTo-Json -Depth 10 -Compress ) `
            -NoRetry:$NoRetry |
            Select-Object -ExpandProperty environments |
            Where-Object -FilterScript { $environmentsToSet -contains $_.name } |
            Select-Object -Property id, name, retentionPolicy
    }
}

<#
.SYNOPSIS
Updates a build pipeline definition.
 
.DESCRIPTION
Updates a build pipeline definition using a provided json file.
 
.PARAMETER PipelineId
ID of the pipeline to update. Accepts values from the pipeline.
 
.PARAMETER Project
Project that the pipelines reside in.
 
.PARAMETER JsonFilePath
FilePath of the build definition json with updated values.
 
.PARAMETER Pat
Personal access token authorized to administer builds. Defaults to $env:SYSTEM_ACCESSTOKEN for use in
AzurePipelines.
 
.EXAMPLE
Update-AzDOPipeline -PipelineId 5992 -Project Packages -JsonFilePath ./azure-pipelines/AzurePipelines-CI.json
 
.NOTES
In order to update a build definition, the `"processParameters": {}` attribute must be included.
 
The Cmdlet will work as-is in a UI Pipeline with the default $Pat parameter as long as OAUTH access has been
enabled for the pipeline/job. If using a YAML build, the system.accesstoken variable needs to be explicitly
mapped to the steps environment like the following example:
 
steps:
- powershell: Invoke-WebRequest -Uri $Uri -Headers ( Initialize-AzDORestApi )
  env:
    SYSTEM_ACCESSTOKEN: $(system.accesstoken)
#>


function Update-AzDOPipeline {
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [Alias('id')]
        [String]$PipelineId,
        [String]$JsonFilePath,
        [Switch]$NoRetry,
        [String[]]$Project = $env:SYSTEM_TEAMPROJECT,
        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [string]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '6.1'
        }
    }

    process {
        . $PSScriptRoot/private/Get-AzDOApiProjectName.ps1
        $Project = $Project | Get-AzDOApiProjectName

        Invoke-AzDORestApiMethod `
            @script:AzApiHeaders `
            -Method Put `
            -Project $Project `
            -Endpoint "build/definitions/$PipelineId" `
            -Body ( Get-Content -Path $JsonFilePath -Encoding UTF8 | Out-String ) `
            -NoRetry:$NoRetry
    }
}

<#
.SYNOPSIS
Updates a release pipeline definition.
 
.DESCRIPTION
Updates a release pipeline definition using a provided json file.
 
.PARAMETER PipelineId
ID of the pipeline to update. Accepts values from the pipeline.
 
.PARAMETER Project
Project that the pipelines reside in.
 
.PARAMETER JsonFilePath
FilePath of the release definition json with updated values.
 
.PARAMETER Pat
Personal access token authorized to administer releases. Defaults to $env:SYSTEM_ACCESSTOKEN for use in
AzurePipelines.
 
.EXAMPLE
Update-AzDOReleasePipeline -PipelineId 5992 -Project Packages -JsonFilePath ./azure-pipelines/AzurePipelines-CI.json
 
.LINK
https://learn.microsoft.com/en-us/rest/api/azure/devops/release/definitions/update
 
.NOTES
The Cmdlet will work as-is in a UI Pipeline with the default $Pat parameter as long as OAUTH access has been
enabled for the pipeline/job. If using a YAML build, the system.accesstoken variable needs to be explicitly
mapped to the steps environment like the following example:
 
steps:
- powershell: Invoke-WebRequest -Uri $Uri -Headers ( Initialize-AzDORestApi )
  env:
    SYSTEM_ACCESSTOKEN: $(system.accesstoken)
#>


function Update-AzDOReleasePipeline {
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [Alias('id')]
        [String]$PipelineId,
        [String]$JsonFilePath,
        [Switch]$NoRetry,
        [String[]]$Project = $env:SYSTEM_TEAMPROJECT,
        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [string]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '7.2-preview.4'
        }
    }

    process {
        . $PSScriptRoot/private/Get-AzDOApiProjectName.ps1
        $Project = $Project | Get-AzDOApiProjectName

        Invoke-AzDORestApiMethod `
            @script:AzApiHeaders `
            -Method Put `
            -Project $Project `
            -Endpoint "release/definitions/$PipelineId" `
            -Body ( Get-Content -Path $JsonFilePath -Encoding UTF8 | Out-String ) `
            -NoRetry:$NoRetry
    }
}

<#
.SYNOPSIS
Gets info for an Azure Repos repository.
 
.DESCRIPTION
Gets info for an Azure Repos repository.
 
.PARAMETER Name
Name of the repo.
 
.PARAMETER Project
Project that the repo resides in.
 
.PARAMETER CollectionUri
The project collection URL (https://dev.azure.com/[orgranization]).
 
.PARAMETER Pat
An Azure DevOps Personal Access Token authorized to read code.
 
.EXAMPLE
Get-AzDORepository -Name AzDO -Project MyProject
 
.LINK
https://docs.microsoft.com/en-us/rest/api/azure/devops/git/repositories/get%20repository
 
.NOTES
N/A
#>

function Get-AzDORepository {
    [CmdletBinding()]
    param (
        [String]$Name,
        [Switch]$NoRetry,
        [String]$Project = $env:SYSTEM_TEAMPROJECT,
        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [String]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '7.1-preview.1'
        }
    }

    process {
        Invoke-AzDORestApiMethod `
            @script:AzApiHeaders `
            -Method Get `
            -Project $Project `
            -Endpoint "git/repositories/$Name" `
            -NoRetry:$NoRetry
    }
}

<#
.SYNOPSIS
Initializes environment variables needed to connect to Azure DevOps.
 
.DESCRIPTION
This function initializes environment variables needed to connect to Azure DevOps. If an existing connection is
found, the user is prompted to overwrite the existing connection.
 
.PARAMETER Project
The default Azure DevOps project to use.
 
.PARAMETER CollectionUri
The Azure DevOps project collection URI.
 
.PARAMETER Pat
The Azure DevOps Personal Access Token (PAT) to use.
 
.EXAMPLE
Connect-AzDO
 
.NOTES
N/A
#>

function Connect-AzDO {
    param (
        [String]$Project,
        [String]$CollectionUri,
        [String]$Pat
    )

    $currentAzDOConnection = Get-AzDOConnection
    if ($null -ne ( $currentAzDOConnection.PSObject.Properties.Value | Where-Object -FilterScript {$_} )) {
        Write-Warning -Message 'An existing Azure DevOps connection was found.'
        Write-Host -Object ( $currentAzDOConnection | Format-List | Out-String )
        $response = Read-Host -Prompt 'Would you like to overwrite the existing connection? (y/n)'
        if ($response.ToLower() -ne 'y') {
            return
        }
    }

    while (!$newCollectionUri) {
        $newCollectionUri = if ($CollectionUri) {
            $CollectionUri
        }
        else {
            Read-Host -Prompt (
                "`nPlease enter a Project Collection URI. e.g. " +
                'https://dev.azure.com/[Organization]/'
            )
        }
    }
    Set-EnvironmentVariable -Name 'SYSTEM_COLLECTIONURI' -Value $newCollectionUri -Scope User -Force

    while (!$newProject) {
        $newProject = if ($Project) {
            $Project
        }
        else {
            Read-Host -Prompt "`nPlease enter a default Azure DevOps project"
        }
    }
    Set-EnvironmentVariable -Name 'SYSTEM_TEAMPROJECT' -Value $newProject -Scope User -Force

    while (!$newPat) {
        $newPat = if ($Pat) {
            $Pat
        }
        else {
            Read-Host -Prompt (
                "`n" + 'Please enter an Azure DevOps Personal Access Token (PAT) authorized to access ' +
                'Azure DevOps artifacts. Instructions can be found at:' + "`n`n`t" +
                'https://docs.microsoft.com/en-us/azure/devops/organizations/' +
                'accounts/use-personal-access-tokens-to-authenticate' + "`n`n" +
                'Personal Access Token (PAT)'
            )
        }
    }
    Set-EnvironmentVariable -Name 'SYSTEM_ACCESSTOKEN' -Value $newPat -Scope User -Force

    $currentAzDOConnection = Get-AzDOConnection
    $currentAzDOConnection | Format-List
    $currentAzDOConnection | Test-AzDOConnection
}

<#
.SYNOPSIS
Gets the environment variables being used to connect to Azure DevOps.
 
.DESCRIPTION
Gets the environment variables being used to connect to Azure DevOps.
 
.EXAMPLE
Get-AzDOConnection
 
.NOTES
N/A
#>

function Get-AzDOConnection {
    [CmdletBinding()]
    param ()

    [PSCustomObject]@{
        CollectionURI = $env:SYSTEM_COLLECTIONURI
        Project       = $env:SYSTEM_TEAMPROJECT
        Pat           = $env:SYSTEM_ACCESSTOKEN
    }
}

<#
.SYNOPSIS
Creates authorization headers for an Azure DevOps REST API call.
 
.DESCRIPTION
Creates authorization headers for an Azure DevOps REST API call.
 
.PARAMETER User
Deprecated. Not used for API calls.
 
.PARAMETER Pat
Personal access token authorized for the call being made. Defaults to $env:SYSTEM_ACCESSTOKEN for use in Azure
Pipelines.
 
.PARAMETER Authentication
Choose Basic or Bearer authentication. Note that Bearer authentication will disregard Pat and CollectionUri and
use the current Azure context returned from Get-AzContext.
 
.EXAMPLE
$headers = Initialize-AzDORestApi
 
.LINK
https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?toc=%2Fazure%2Fdevops%2Fmarketplace-extensibility%2Ftoc.json&view=azure-devops&tabs=Windows#use-a-pat
 
.LINK
https://dotnetdevlife.wordpress.com/2020/02/19/get-bearer-token-from-azure-powershell/
 
.NOTES
The Cmdlet will work as-is in a UI Pipeline with the default $Pat parameter as long as OAUTH access has been
enabled for the pipeline/job. If using a YAML build, the system.accesstoken variable needs to be explicitly
mapped to the steps environment like the following example:
 
steps:
- powershell: Invoke-WebRequest -Uri $Uri -Headers ( Initialize-AzDORestApi )
  env:
    SYSTEM_ACCESSTOKEN: $(system.accesstoken)
#>


function Initialize-AzDORestApi {
    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    param (
        [String]$User,
        [String]$Pat = $env:SYSTEM_ACCESSTOKEN,
        [ValidateSet('Basic', 'Bearer')]
        [String]$Authentication = 'Basic'
    )

    if ($User) {
        Write-Verbose -Message 'A User was specified but is not needed and will not be used.'
    }

    if ($Authentication -eq 'Basic') {
        $base64EncodedToken = $(
            [Convert]::ToBase64String(
                [Text.Encoding]::ASCII.GetBytes(":$Pat")
            )
        )

        @{
            Authorization = "Basic $base64EncodedToken"
        }
    }
    else {
        try {
            $tenantId = ( Get-AzContext -ErrorAction Stop ).Subscription.TenantId
        }
        catch {
            Connect-AzAccount
            $tenantId = ( Get-AzContext -ErrorAction Stop ).Subscription.TenantId
        }

        $azureRmProfile = [Microsoft.Azure.Commands.Common.Authentication.Abstractions.AzureRmProfileProvider]::Instance.Profile
        Write-Verbose -Message 'Current Azure Context:'
        Write-Verbose -Message $azureRmProfile.DefaultContextKey.ToString()
        Write-Verbose -Message (
            $azureRmProfile.Contexts.Keys | Where-Object -FilterScript { $_ -notmatch 'Concierge' } | Out-String
        )

        $profileClient = New-Object Microsoft.Azure.Commands.ResourceManager.Common.RMProfileClient($azureRmProfile)
        $token = $profileClient.AcquireAccessToken($tenantId).AccessToken
        if ($token) {
            Write-Verbose -Message 'Azure AD bearer token generated!'
        }
        else {
            Write-Error -Message 'Azure AD bearer token unable to be generated.'
        }

        @{
            Accept        = 'application/json'
            Authorization = "Bearer $token"
        }
    }
}

<#
.SYNOPSIS
A wrapper to invoke Azure DevOps API calls.
 
.DESCRIPTION
A wrapper to invoke Azure DevOps API calls. Authorization is provided by Initialize-AzDORestApi.
 
.PARAMETER Method
REST method. Supports GET, PATCH, DELETE, PUT, and POST right now.
 
.PARAMETER CollectionUri
The full Azure DevOps URL of an organization. Can be automatically populated in a pipeline.
 
.PARAMETER Organization
Azure DevOps organization. Used in place of CollectionUri.
 
.PARAMETER SubDomain
Subdomain prefix of dev.azure.com that the API requires.
 
.PARAMETER Project
The project the call will target. Can be automatically populated in a pipeline.
 
.PARAMETER Endpoint
Everything in between the base URI of the rest call and the parameters.
e.g. VERB https://dev.azure.com/{organization}/{team-project}/_apis/{endpoint}?api-version={version}
 
.PARAMETER Params
An array of parameter declarations.
 
.PARAMETER Body
The body of the call if needed.
 
.PARAMETER OutFile
Path to download the output of the rest call.
 
.PARAMETER NoRetry
Don't retry failed calls.
 
.PARAMETER ApiVersion
The version of the API to use.
 
.EXAMPLE
Invoke-AzDORestApiMethod `
    -Method Get `
    -Organization MyOrg `
    -Endpoint 'work/accountmyworkrecentactivity' `
    -Headers ( Initialize-AzDORestApi -Pat $Pat ) `
    -ApiVersion '5.1'
# GET https://dev.azure.com/MyOrg/_apis/work/accountmyworkrecentactivity?api-version=5.1-preview.2
 
.NOTES
The Cmdlet will work as-is in a UI Pipeline with the default $Pat parameter as long as OAUTH access has been
enabled for the pipeline/job. If using a YAML build, the system.accesstoken variable needs to be explicitly
mapped to the steps environment like the following example:
 
steps:
- powershell: Invoke-WebRequest -Uri $Uri -Headers ( Initialize-AzDORestApi )
  env:
    SYSTEM_ACCESSTOKEN: $(system.accesstoken)
#>


function Invoke-AzDORestApiMethod {
    [CmdletBinding(DefaultParameterSetName = 'Uri', SupportsShouldProcess = $true)]
    param (
        [ValidateSet('Get', 'Patch', 'Delete', 'Put', 'Post')]
        [Parameter(Mandatory = $true)]
        [string]$Method,
        [Parameter(ParameterSetName = 'Uri')]
        [string]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [Parameter(ParameterSetName = 'Org', Mandatory = $true)]
        [string]$Organization,
        [string]$SubDomain,
        [string]$Project, # = $env:SYSTEM_TEAMPROJECT
        [Parameter(Mandatory = $true)]
        [string]$Endpoint,
        [string[]]$Params,
        [string]$Body,
        [string]$OutFile,
        [Switch]$NoRetry,
        [string]$ApiVersion = '6.0',
        [hashtable]$Headers = ( Initialize-AzDORestApi )
    )

    $cachedProgressPreference = $ProgressPreference

    if ($PSCmdlet.ParameterSetName -eq 'Org') {
        $CollectionUri = "https://dev.azure.com/$Organization/"
    }
    else {
        $Organization = $CollectionUri.`
            Replace('https://', '').`
            Replace('dev.azure.com', '').`
            Replace('.visualstudio.com', '').`
            Replace('/', '')
    }
    if ($CollectionUri -match '.*\.visualstudio\.com') {
        $CollectionUri = "https://dev.azure.com/$Organization/"
    }
    if ($SubDomain) {
        if ($SubDomain -eq 'azdevopscommerce') {
            $CollectionUri = $CollectionUri.Replace(
                $Organization,
                ( Get-AzDoOrganizationId -CollectionUri $CollectionUri )
            )
        }
        $CollectionUri = $CollectionUri.Replace('dev.azure.com', "$SubDomain.dev.azure.com")
    }
    if ($CollectionUri -notmatch '/$') {
        $CollectionUri += '/'
    }
    $restUri = $CollectionUri

    if (![String]::isNullOrEmpty($Project)) {
        $restUri += [Uri]::EscapeDataString($Project) + '/'
    }

    if ($Params.Length -eq 0) {
        $paramString = "api-version=$ApiVersion"
    }
    else {
        $paramString = (($Params + "api-version=$ApiVersion") -join '&')
    }

    $restUri += ('_apis/' + $Endpoint + '?' + $paramString)
    if ($PSCmdlet.ShouldProcess($restUri, $Method)) {
        Write-Verbose -Message "Method: $Method"
        $restArgs = @{
            Method  = $Method
            Uri     = $restUri
            Headers = $Headers
        }
        switch ($Method) {
            {
                $_ -eq 'Get' -or
                $_ -eq 'Delete'
            } {
                Write-Verbose -Message 'Executing Get or Delete block'
                if ($OutFile) {
                    $restArgs['OutFile'] = $OutFile
                }
            }
            {
                $_ -eq 'Patch' -or
                $_ -eq 'Put' -or
                $_ -eq 'Post'
            } {
                Write-Verbose -Message 'Executing Patch, Put, or Post block.'
                Write-Verbose -Message "Body:`n$Body"
                if ($restUri -match '.*/workitems/.*') {
                    $restArgs['ContentType'] = 'application/json-patch+json'
                }
                else {
                    $restArgs['ContentType'] = 'application/json'
                }
                $restArgs['Body'] = [System.Text.Encoding]::UTF8.GetBytes($Body)
            }
            Default {
                Write-Error -Message 'An unsupported rest method was attempted.'
            }
        }
        $progress = @{
            Activity = $Method
            Status   = $restUri
        }
        if ($VerbosePreference -ne 'SilentlyContinue') {
            Write-Progress @progress
        }
        if ($OutFile) {
            $progress['CurrentOperation'] = "Downloading $OutFile... "
            if ($VerbosePreference -ne 'SilentlyContinue') {
                Write-Progress @progress
            }
            $ProgressPreference = 'SilentlyContinue'
        }
        if ($NoRetry) {
            $delayCounts = @(0)
        }
        else {
            $delayCounts = @(1, 2, 3, 5, 8, 13, 21)
        }
        foreach ($delay in $delayCounts) {
            try {
                $response = $null
                Write-Verbose -Message "$Method $restUri"
                $restOutput = Invoke-RestMethod @restArgs
                $ProgressPreference = $cachedProgressPreference
                if ($restOutput.value) {
                    $restOutput.value
                }
                elseif ($restOutput.count -eq 0) { }
                elseif ($restOutput -match 'Azure DevOps Services | Sign In') {
                    class AzLoginException : Exception {
                        [System.Object]$Response
                        AzLoginException($Message) : base($Message) {
                            $this.Response = [PSCustomObject]@{
                                StatusCode        = [PSCustomObject]@{
                                    value__ = 401
                                }
                                StatusDescription = $Message
                            }
                        }
                    }
                    throw [AzLoginException]::New('Not authorized.')
                }
                else {
                    $restOutput
                }
                break
            }
            catch {
                $response = $_.Exception.Response
                try {
                    $details = ( $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction Stop ).message
                }
                catch {
                    $details = $_.ErrorDetails.Message
                }

                if ($response) {
                    $message = "$($response.StatusCode.value__) | $($response.StatusDescription)"
                    if ($details) {
                        $message += " | $details"
                    }
                }
                else {
                    $message = 'Unknown REST error encountered. '
                }
                if (!$NoRetry -and $response.StatusCode.value__ -ne 400) {
                    $message += " | Retrying after $delay seconds..."
                }
                $ProgressPreference = $cachedProgressPreference
                Write-Verbose -Message $message
                $progress['CurrentOperation'] = $message
                if ($VerbosePreference -ne 'SilentlyContinue') {
                    Write-Progress @progress
                }
                if ($OutFile) {
                    $ProgressPreference = 'SilentlyContinue'
                }
                if (!$NoRetry -and $response.StatusCode.value__ -ne 400) {
                    Start-Sleep -Seconds $delay
                }
                else {
                    break
                }
            }
        }
        $ProgressPreference = $cachedProgressPreference
        if ($response) {
            Write-Error -Message "$($response.StatusCode.value__) | $($response.StatusDescription) | $details"
        }
        if ($VerbosePreference -ne 'SilentlyContinue') {
            Write-Progress @progress -Completed
        }
        if ($OutFile) {
            Get-Item -Path $OutFile
        }
    }
}

<#
.SYNOPSIS
Tests various Azure DevOps permissions.
 
.DESCRIPTION
Tests various Azure DevOps permissions.
 
.PARAMETER Project
Projects to test project-scoped permissions with.
 
.PARAMETER CollectionUri
Organization URL.
 
.PARAMETER Pat
Personal Access Token to test.
 
.EXAMPLE
Test-AzDOConnection -Project MyProject -Pat examplePat
 
.NOTES
N/A
#>

function Test-AzDOConnection {
    [CmdletBinding()]
    param (
        [Switch]$NoRetry,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [Alias('Name')]
        [String[]]$Project = $env:SYSTEM_TEAMPROJECT,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:activity = 'Testing Azure DevOps Permissions'
        $script:permissions = @(
            [PSCustomObject]@{
                Scope      = 'Organization'
                Name       = 'Agents'
                Authorized = $false
            }
            [PSCustomObject]@{
                Scope      = 'Organization'
                Name       = 'Organization Info'
                Authorized = $false
            }
            [PSCustomObject]@{
                Scope      = 'Organization'
                Name       = 'Packages'
                Authorized = $false
            }
        )

        $script:restParams = @{
            NoRetry       = $NoRetry
            CollectionUri = $CollectionUri
            Pat           = $Pat
            ErrorAction   = 'SilentlyContinue'
        }

        foreach ($orgPermission in $script:permissions) {
            $status = (
                'Testing ' +
                $CollectionUri.Split('/').Where({ $_ })[-1] +
                '/' +
                $orgPermission.Name +
                ' permissions...'
            )
            Write-Progress -Activity $script:activity -Status $status
            $authorizedPermission = $null
            $authorizedPermission = try {
                switch ($orgPermission.Name) {
                    'Agents' { Get-AzDOAgentPool @script:restParams }
                    'Organization Info' { Get-AzDOProject @script:restParams }
                    'Packages' { Get-AzDOPackageFeed @script:restParams }
                }
            }
            catch {
                Write-Verbose -Message $_.Exception.Message
            }
            if ($authorizedPermission) {
                $orgPermission.Authorized = $true
            }
        }
    }

    process {
        foreach ($scope in $Project) {
            $projectPermissions = @(
                [PSCustomObject]@{
                    Scope      = $scope
                    Name       = 'Packages'
                    Authorized = $false
                }
                [PSCustomObject]@{
                    Scope      = $scope
                    Name       = 'Pipelines'
                    Authorized = $false
                }
                [PSCustomObject]@{
                    Scope      = $scope
                    Name       = 'Repositories'
                    Authorized = $false
                }
            )

            $script:restParams['Project'] = $scope

            foreach ($permission in $projectPermissions) {
                $status = "Testing $($permission.Scope)/$($permission.Name) permissions..."
                Write-Progress -Activity $script:activity -Status $status
                $authorizedPermission = $null
                $authorizedPermission = try {
                    switch ($permission.Name) {
                        'Packages' { Get-AzDOPackageFeed @script:restParams }
                        'Pipelines' { Get-AzDOPipeline @script:restParams }
                        'Repositories' { ( Get-AzDORepository @script:restParams -Name $scope ).id }
                    }
                }
                catch {
                    Write-Verbose -Message $_.Exception.Message
                }
                if ($authorizedPermission) {
                    $permission.Authorized = $true
                }
            }

            $script:permissions += $projectPermissions
        }
        Write-Progress -Activity $script:activity -Completed
    }

    end {
        $script:permissions
        $failedPermissions = @( $script:permissions | Where-Object -Property Authorized -NE $true )
        if ($failedPermissions) {
            Write-Error -Message (
                "Not authorized for $($failedPermissions.Count)/$($script:permissions.Count) permissions!"
            )
        }
    }
}

<#
.SYNOPSIS
Adds a comment to a work item.
 
.DESCRIPTION
Adds a comment to a work item.
 
.PARAMETER Id
ID of the work item.
 
.PARAMETER Comment
The comment to add.
 
.PARAMETER Project
Project that the work item is in.
 
.PARAMETER CollectionUri
The full Azure DevOps URL of an organization. Can be automatically populated in a pipeline.
 
.PARAMETER Pat
An Azure DevOps Personal Access Token authorized to edit work items.
 
.EXAMPLE
Get-AzDOWorkItem -Id 12345 -Project MyProject | Add-AzDOWorkItemComment -Comment 'Insert comment here'
 
.LINK
https://learn.microsoft.com/en-us/rest/api/azure/devops/wit/work-items/update
 
.NOTES
N/A
#>

function Add-AzDOWorkItemComment {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
        [String[]]$Id,
        [String]$Comment,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [System.Object]$Project = $env:SYSTEM_TEAMPROJECT,
        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [String]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    process {
        Set-AzDOWorkItemField `
            -Id $Id `
            -Name History `
            -Value $Comment `
            -Project $Project `
            -CollectionUri $CollectionUri `
            -Pat $Pat |
            ForEach-Object -Process {
                [PSCustomObject]@{
                    Id      = $_.id
                    Type    = $_.fields | Select-Object -ExpandProperty System.WorkItemType
                    Title   = $_.fields | Select-Object -ExpandProperty System.Title
                    Comment = $_.fields | Select-Object -ExpandProperty System.History
                    Url     = $_.url.Replace(
                        "_apis/wit/workItems/$($_.id)",
                        "_workitems/edit/$($_.id)"
                    )
                }
            }
    }
}

<#
.SYNOPSIS
Adds specific relationships to a work item.
 
.DESCRIPTION
The Add-AzDOWorkItemRelation function adds specific relationships to a work item in Azure DevOps.
 
.PARAMETER Id
The ID of the work item.
 
.PARAMETER RelationType
The types of the relationships to add.
 
.PARAMETER RelatedWorkItemId
The ID of the work item to link to.
 
.PARAMETER NoRetry
Switch to disable retry attempts on API calls.
 
.PARAMETER Project
The name or ID of the project.
 
.PARAMETER CollectionUri
The URI of the Azure DevOps collection.
 
.PARAMETER PAT
The Personal Access Token to authenticate with Azure DevOps.
 
.EXAMPLE
Add-AzDOWorkItemRelation -Id 123 -RelationType 'Parent','Child' -RelatedWorkItemId 456 -CollectionUri 'https://dev.azure.com/mycollection' -Project 'myproject' -PAT 'mypat'
 
.LINK
https://learn.microsoft.com/en-us/rest/api/azure/devops/wit/work-items/update
 
.NOTES
N/A
#>

function Add-AzDOWorkItemRelation {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
        [Int[]]$Id,

        [ValidateSet('Parent', 'Child', 'Successor', 'Predecessor', 'Related')]
        [String[]]$RelationType,

        [Parameter(Mandatory = $true)]
        [Int]$RelatedWorkItemId,

        [Switch]$NoRetry,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [System.Object]$Project = $env:SYSTEM_TEAMPROJECT,

        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,

        [String]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '7.1-preview.3'
        }
    }

    process {
        . $PSScriptRoot/private/Get-AzDOApiProjectName.ps1
        $Project = $Project | Get-AzDOApiProjectName

        $relationTypeMap = & $PSScriptRoot/private/AzDOWorkItemRelationTypeMap.ps1

        foreach ($workItemId in $Id) {
            foreach ($relation in $RelationType) {
                $apiRelationType = $relationTypeMap[$relation]

                if ($PSCmdlet.ShouldProcess($CollectionUri, "Add $relation link from work item $workItemId to work item $RelatedWorkItemId in project $Project")) {
                    $relatedWorkItem = Get-AzDOWorkItem -Id $RelatedWorkItemId -NoRetry:$NoRetry -CollectionUri $CollectionUri -Project $Project -Pat $Pat

                    $body = @(
                        @{
                            op    = 'add'
                            path  = "/relations/-"
                            value = @{
                                rel = $apiRelationType
                                url = $relatedWorkItem.url
                            }
                        }
                    ) | ConvertTo-Json -Compress

                    Invoke-AzDORestApiMethod `
                        @script:AzApiHeaders `
                        -Method Patch `
                        -Project $Project `
                        -Endpoint "wit/workitems/$workItemId" `
                        -Body $body `
                        -NoRetry:$NoRetry
                }
            }
        }
    }
}

<#
.SYNOPSIS
Get a Work Item's info.
 
.DESCRIPTION
Get a Work Item's info.
 
.PARAMETER Id
ID of the work item.
 
.PARAMETER Title
Title of the work item.
 
.PARAMETER Project
Project that the work item is in.
 
.PARAMETER CollectionUri
The full Azure DevOps URL of an organization. Can be automatically populated in a pipeline.
 
.PARAMETER Pat
An Azure DevOps Personal Access Token authorized to read work items.
 
.EXAMPLE
Get-AzDOWorkItem -Id 12345 -Project MyProject
 
.LINK
https://learn.microsoft.com/en-us/rest/api/azure/devops/wit/work-items/get-work-item
 
.LINK
https://learn.microsoft.com/en-us/rest/api/azure/devops/wit/wiql/query-by-wiql
 
.NOTES
N/A
#>

function Get-AzDOWorkItem {
    [CmdletBinding(DefaultParameterSetName = 'ID')]
    param (
        [Parameter(ParameterSetName = 'ID', Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [Int[]]$Id,
        [Parameter(ParameterSetName = 'Title', Mandatory = $true, Position = 0)]
        [String]$Title,
        [Switch]$NoRetry,
        [String]$Project = $env:SYSTEM_TEAMPROJECT,
        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [String]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '7.1-preview.2'
        }
    }

    process {
        if ($Title) {
            $body = @{
                query = "SELECT [System.Id] FROM workitems WHERE [System.Title] CONTAINS '$Title'"
            } | ConvertTo-Json -Compress

            $Id = @(
                Invoke-AzDORestApiMethod `
                    @script:AzApiHeaders `
                    -Method Post `
                    -Project $Project `
                    -Endpoint "wit/wiql" `
                    -Body $body `
                    -NoRetry:$NoRetry |
                    Select-Object -ExpandProperty workItems |
                    Select-Object -ExpandProperty id
            )
            if (!$Id) {
                Write-Warning -Message "No work items found with title $Title in project $Project."
            }
        }

        foreach ($item in $Id) {
            $workItem = Invoke-AzDORestApiMethod `
                @script:AzApiHeaders `
                -Method Get `
                -Project $Project `
                -Endpoint "wit/workitems/$item" `
                -NoRetry:$NoRetry
            $workItem | Add-Member `
                -MemberType NoteProperty `
                -Name project `
                -Value $workItem.fields.'System.TeamProject'
            $workItem
        }
    }
}

<#
.SYNOPSIS
Gets work item types for a project.
 
.DESCRIPTION
Gets work item types for a project.
 
.PARAMETER Type
Filter by work item type.
 
.PARAMETER NoRetry
Don't retry failed calls.
 
.PARAMETER Project
Project to list work item types for.
 
.PARAMETER CollectionUri
The full Azure DevOps URL of an organization. Can be automatically populated in a pipeline.
 
.PARAMETER Pat
An Azure DevOps Personal Access Token authorized to read work items.
 
.EXAMPLE
Get-AzDOWorkItemType -Project 'MyProject'
 
.LINK
https://learn.microsoft.com/en-us/rest/api/azure/devops/wit/work-item-types/list
 
.NOTES
N/A
#>

function Get-AzDOWorkItemType {
    [CmdletBinding()]
    [OutputType([System.Object[]])]
    param (
        [String[]]$Type,
        [Switch]$NoRetry,
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('Id')]
        [String]$Project = $env:SYSTEM_TEAMPROJECT,
        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [String]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '7.1-preview.2'
        }
    }

    process {
        $processId = Get-AzDOProject -Name $Project -NoRetry:$NoRetry -CollectionUri $CollectionUri -Pat $Pat |
            Select-Object -ExpandProperty capabilities |
            Select-Object -ExpandProperty processTemplate |
            Select-Object -ExpandProperty templateTypeId

        $types = @(
            Invoke-AzDORestApiMethod `
                @script:AzApiHeaders `
                -Method Get `
                -Endpoint "work/processes/$processId/workitemtypes" `
                -NoRetry:$NoRetry
        )
        if ($Type) {
            $types | Where-Object -FilterScript { $Type -contains $_.name }
        }
        else {
            $types
        }
    }
}

<#
.SYNOPSIS
Get a Work Item's info.
 
.DESCRIPTION
Get a Work Item's info.
 
.PARAMETER Id
ID of the work item.
 
.PARAMETER Project
Project that the work item is in.
 
.PARAMETER CollectionUri
The full Azure DevOps URL of an organization. Can be automatically populated in a pipeline.
 
.PARAMETER Pat
An Azure DevOps Personal Access Token authorized to read work items.
 
.EXAMPLE
Get-AzDOWorkItem -Id 12345 -Project MyProject
 
.LINK
https://learn.microsoft.com/en-us/rest/api/azure/devops/wit/work-items/create
 
.NOTES
N/A
#>

function New-AzDOWorkItem {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [String[]]$Title,
        [String]$Type = 'User Story',
        [String]$AreaPath,
        [String]$IterationPath,
        [String]$Description = 'Created via AzDOCmd\New-AzDOWorkItem',
        [Int]$ParentId,
        [Int]$ChildId,
        [Switch]$SuppressNotifications,
        [Switch]$NoRetry,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [Alias('Id')]
        [String]$Project = $env:SYSTEM_TEAMPROJECT,
        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [String]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '7.1-preview.3'
        }
    }

    process {
        $validType = Get-AzDOWorkItemType `
            -Type $Type `
            -NoRetry:$NoRetry `
            -Project $Project `
            -CollectionUri $CollectionUri `
            -Pat $Pat
        if (!$validType) {
            throw "Invalid work item type: $Type"
        }

        foreach ($item in $Title) {
            if (
                $PSCmdlet.ShouldProcess(
                    $CollectionUri, "Create work item $item of type $Type in project $Project"
                )
            ) {
                $descriptionFieldName = if ($Type -eq 'Bug') {
                    'Microsoft.VSTS.TCM.ReproSteps'
                }
                else {
                    'System.Description'
                }
                $body = @(
                    [PSCustomObject]@{
                        op    = 'add'
                        path  = '/fields/System.Title'
                        from  = $null
                        value = $item
                    },
                    [PSCustomObject]@{
                        op    = 'add'
                        path  = "/fields/$descriptionFieldName"
                        value = $Description
                    }
                )
                if ($AreaPath) {
                    if ($AreaPath -notlike "$Project*") {
                        $AreaPath = $Project + '\' + $AreaPath
                    }
                    $body += [PSCustomObject]@{
                        op    = 'add'
                        path  = '/fields/System.AreaPath'
                        value = $AreaPath
                    }
                }
                if ($IterationPath) {
                    if ($IterationPath -notlike "$Project*") {
                        $IterationPath = $Project + '\' + $IterationPath
                    }
                    $body += [PSCustomObject]@{
                        op    = 'add'
                        path  = '/fields/System.IterationPath'
                        value = $IterationPath
                    }
                }
                if ($ParentId) {
                    $body += [PSCustomObject]@{
                        op    = 'add'
                        path  = '/relations/-'
                        value = @{
                            rel = 'System.LinkTypes.Hierarchy-Reverse'
                            url = "$CollectionUri/$Project/_apis/wit/workItems/$ParentId"
                        }
                    }
                }
                if ($ChildId) {
                    $body += [PSCustomObject]@{
                        op    = 'add'
                        path  = '/relations/-'
                        value = @{
                            rel = 'System.LinkTypes.Hierarchy-Forward'
                            url = "$CollectionUri/$Project/_apis/wit/workItems/$ChildId"
                        }
                    }
                }

                $workItem = Invoke-AzDORestApiMethod `
                    @script:AzApiHeaders `
                    -Method Post `
                    -Project $Project `
                    -Endpoint "wit/workitems/`$$([Uri]::EscapeDataString($Type))" `
                    -Body $( $body | ConvertTo-Json -AsArray -Compress ) `
                    -Params @(
                        "suppressNotifications=$SuppressNotifications"
                        '$expand=All'
                    ) `
                    -NoRetry:$NoRetry
                $workItem | Add-Member `
                    -MemberType NoteProperty `
                    -Name project `
                    -Value $workItem.fields.'System.TeamProject'
                $workItem
            }
        }
    }
}

<#
.SYNOPSIS
Removes a specific relationship from a work item.
 
.DESCRIPTION
The Remove-AzDOWorkItemRelation function removes a specific relationship from a work item in Azure DevOps.
 
.PARAMETER Id
The ID of the work item.
 
.PARAMETER RelationType
The type of the relationship to remove.
 
.PARAMETER NoRetry
Switch to disable retry attempts on API calls.
 
.PARAMETER Project
The name or ID of the project.
 
.PARAMETER CollectionUri
The URI of the Azure DevOps collection.
 
.PARAMETER PAT
The Personal Access Token to authenticate with Azure DevOps.
 
.EXAMPLE
Remove-AzDOWorkItemRelation -Id 12345 -RelationType Parent -Project MyProject
 
.LINK
https://learn.microsoft.com/en-us/rest/api/azure/devops/wit/work-items/update
 
.NOTES
N/A
#>

function Remove-AzDOWorkItemRelation {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
        [Int[]]$Id,

        [ValidateSet('Parent', 'Child', 'Successor', 'Predecessor', 'Related')]
        [String]$RelationType = ('Parent', 'Child', 'Successor', 'Predecessor', 'Related'),

        [Switch]$NoRetry,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [System.Object]$Project = $env:SYSTEM_TEAMPROJECT,

        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,

        [String]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '7.1-preview.3'
        }
    }

    process {
        . $PSScriptRoot/private/Get-AzDOApiProjectName.ps1
        $Project = $Project | Get-AzDOApiProjectName

        $relationTypeMap = & $PSScriptRoot/private/AzDOWorkItemRelationTypeMap.ps1

        $apiRelationType = $relationTypeMap[$RelationType]

        foreach ($workItemId in $Id) {
            $workItem = Get-AzDOWorkItem -CollectionUri $CollectionUri -Project $Project -WorkItemId $workItemId -PAT $PAT

            # Find the index of the link to remove
            $linkIndex = @(
                $workItem.relations |
                    Where-Object { $_.rel -eq $apiRelationType } |
                    ForEach-Object { $workItem.relations.IndexOf($_) }
            )

            if ($linkIndex) {
                $body = @()
                $body += foreach ($index in $linkIndex) {
                    @{
                        op   = 'remove'
                        path = "/relations/$index"
                    }
                }

                if ($PSCmdlet.ShouldProcess($CollectionUri, "Remove $RelationType link(s) from work item $workItemId in project $Project")) {
                    Invoke-AzDORestApiMethod `
                        @script:AzApiHeaders `
                        -Method Patch `
                        -Project $Project `
                        -Endpoint "wit/workitems/$WorkItemId" `
                        -Body ( $body | ConvertTo-Json -Compress ) `
                        -NoRetry:$NoRetry
                }
            }
            else {
                Write-Warning -Message "No $RelationType link found on work item $workItemId in project $Project."
            }
        }
    }
}

<#
.SYNOPSIS
Updates a field in a work item.
 
.DESCRIPTION
Updates a field in a work item.
 
.PARAMETER Id
ID of the work item.
 
.PARAMETER Field
The work item field to update.
 
.PARAMETER Value
The value to populate the field with.
 
.PARAMETER Project
Project that the work item is in.
 
.PARAMETER CollectionUri
The full Azure DevOps URL of an organization. Can be automatically populated in a pipeline.
 
.PARAMETER Pat
An Azure DevOps Personal Access Token authorized to edit work items.
 
.EXAMPLE
Get-AzDOWorkItem -Id 12345 -Project MyProject | Set-AzDOWorkItemField -Name Title -Value 'A better title'
 
.LINK
https://learn.microsoft.com/en-us/rest/api/azure/devops/wit/work-items/update
 
.NOTES
General notes
#>

function Set-AzDOWorkItemField {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
        [Int[]]$Id,
        [Alias('Field')]
        [String]$Name,
        [String]$Value,
        [Switch]$NoRetry,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [System.Object]$Project = $env:SYSTEM_TEAMPROJECT,
        [String]$CollectionUri = $env:SYSTEM_COLLECTIONURI,
        [String]$Pat = $env:SYSTEM_ACCESSTOKEN
    )

    begin {
        $script:AzApiHeaders = @{
            Headers       = Initialize-AzDORestApi -Pat $Pat
            CollectionUri = $CollectionUri
            ApiVersion    = '7.1-preview.3'
        }
    }

    process {
        . $PSScriptRoot/private/Get-AzDOApiProjectName.ps1
        $Project = $Project | Get-AzDOApiProjectName

        foreach ($number in $id) {
            if ($PSCmdlet.ShouldProcess($CollectionUri, "Update work item $number with field $Name to value $Value in project $Project")) {
                $body = @(
                    @{
                        op    = 'add'
                        path  = "/fields/System.$Name"
                        value = $Value
                    }
                ) | ConvertTo-Json
                $body = "[`n$body`n]"

                Invoke-AzDORestApiMethod `
                    @script:AzApiHeaders `
                    -Method Patch `
                    -Project $Project `
                    -Endpoint "wit/workitems/$number" `
                    -Body $body `
                    -NoRetry:$NoRetry
            }
        }
    }
}