Private/New-AvmPullRequestBranch.ps1

function New-AvmPullRequestBranch {
    <#
    .SYNOPSIS
        Creates or refreshes a branch with approved AVM updates and opens (or updates) a PR
        via the GitHub or Azure DevOps CLI.

    .DESCRIPTION
        Shared helper used by Approve-AvmUpdate's 'github' and 'azuredevops' modes. Assumes
        the caller has already changed to the target working directory (Push-Location) and
        is currently positioned on the base branch/ref that the new branch should be created
        from.

        Idempotent by design (Renovate/Dependabot pattern): the branch is always rebuilt
        fresh from the current base ref and force-pushed, so re-running with the same
        pending updates (same deterministic -BranchName) converges on the same branch/PR
        instead of accumulating new ones. This also transparently handles a branch that
        exists on the remote with no matching open PR — it is simply overwritten.

        When -ExistingPr is supplied, no new PR is created — the existing PR's title/body
        are updated in place ('gh pr edit' / 'az repos pr update') and its URL is returned.

    .PARAMETER ApprovalMode
        'github' or 'azuredevops' — selects which CLI is used to open/update the PR.

    .PARAMETER BranchName
        Deterministic name of the branch to create/refresh (see Get-AvmUpdateBranchName).

    .PARAMETER Items
        Approved plan items to apply on this branch.

    .PARAMETER Title
        PR title.

    .PARAMETER BodyFilePath
        Path to a Markdown file containing the PR body/description. Using a file avoids OS
        argument-length limits and shell-quoting issues with large reports (github mode
        passes this straight through to '--body-file'; azuredevops mode reads and truncates
        the content, since 'az repos pr create/update' has no file-based description option).

    .PARAMETER ExistingPr
        Optional PSCustomObject (number, url, branchName) for an already-open PR on this
        exact branch. When supplied, the PR is updated in place instead of a new one being
        created.

    .PARAMETER CommitMessage
        Commit message used for the update commit.

    .PARAMETER AzureDevOpsConfig
        Optional hashtable with organization/project/repository, passed to 'az repos pr
        create'/'update' when set (see Resolve-AvmAzureDevOpsContext).

    .OUTPUTS
        String PR URL, or $null if PR creation/update was skipped/failed (the push may still
        have succeeded even when the PR URL is $null).
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('github', 'azuredevops')]
        [string]$ApprovalMode,

        [Parameter(Mandatory)]
        [string]$BranchName,

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$Items,

        [Parameter(Mandatory)]
        [string]$Title,

        [Parameter()]
        [string]$BodyFilePath,

        [Parameter()]
        [PSCustomObject]$ExistingPr,

        [Parameter()]
        [string]$CommitMessage = "chore(avm): update AVM module versions [$(Get-Date -Format 'yyyy-MM-dd')]",

        [Parameter()]
        [hashtable]$AzureDevOpsConfig
    )

    Write-Verbose "Creating/refreshing branch: $BranchName"

    if (-not $PSCmdlet.ShouldProcess($BranchName, 'Create/refresh branch, force-push, and create/update PR')) {
        return $null
    }

    # Always rebuild the branch fresh from the current ref (idempotent) rather than trying
    # to incrementally patch whatever is already on the remote.
    git branch -D $BranchName 2>&1 | Write-Verbose
    git checkout -b $BranchName 2>&1 | Write-Verbose

    $null = Update-AvmModuleVersion -ApprovedPlan ([PSCustomObject]@{ approvedItems = $Items })

    git add -A 2>&1 | Write-Verbose
    git diff --cached --quiet
    if ($LASTEXITCODE -ne 0) {
        git commit -m $CommitMessage 2>&1 | Write-Verbose
    } else {
        Write-Verbose "No file changes to commit on '$BranchName'."
    }

    $pushOutput = git push --force -u origin $BranchName 2>&1
    Write-Verbose ($pushOutput -join "`n")
    if ($LASTEXITCODE -ne 0) {
        throw "git push failed: $($pushOutput -join ' ')"
    }

    $bodyText = if ($BodyFilePath -and (Test-Path $BodyFilePath)) { Get-Content $BodyFilePath -Raw } else { 'AVM update plan — see artifacts.' }

    $prUrl = $null
    switch ($ApprovalMode) {
        'github' {
            if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
                Write-Warning "gh CLI not found; PR creation skipped. Push to '$BranchName' succeeded."
                break
            }

            if ($ExistingPr) {
                $editArgs = @('pr', 'edit', "$($ExistingPr.number)", '--title', $Title)
                if ($BodyFilePath -and (Test-Path $BodyFilePath)) { $editArgs += @('--body-file', $BodyFilePath) }
                else { $editArgs += @('--body', $bodyText) }

                $editOutput = & gh @editArgs 2>&1
                if ($LASTEXITCODE -ne 0) {
                    Write-Warning "gh pr edit failed: $($editOutput -join ' ')"
                } else {
                    Write-Host " Updated existing PR in place: $($ExistingPr.url)" -ForegroundColor DarkGray
                }
                $prUrl = $ExistingPr.url
            } else {
                $createArgs = @('pr', 'create', '--title', $Title)
                if ($BodyFilePath -and (Test-Path $BodyFilePath)) { $createArgs += @('--body-file', $BodyFilePath) }
                else { $createArgs += @('--body', $bodyText) }

                $createOutput = & gh @createArgs 2>&1
                if ($LASTEXITCODE -ne 0) {
                    Write-Warning "gh pr create failed: $($createOutput -join ' ')"
                } else {
                    $prUrl = ($createOutput | Select-Object -Last 1).Trim()
                }
            }
        }
        'azuredevops' {
            if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
                Write-Warning "az CLI not found; PR creation skipped. Push to '$BranchName' succeeded."
                break
            }

            $description = ConvertTo-AvmAzureDevOpsDescription -Body $bodyText

            $adoArgs = @()
            if ($AzureDevOpsConfig) {
                if ($AzureDevOpsConfig.organization) { $adoArgs += @('--organization', $AzureDevOpsConfig.organization) }
                if ($AzureDevOpsConfig.project)      { $adoArgs += @('--project', $AzureDevOpsConfig.project) }
                if ($AzureDevOpsConfig.repository)   { $adoArgs += @('--repository', $AzureDevOpsConfig.repository) }
            }

            if ($ExistingPr) {
                $updateArgs = @('repos', 'pr', 'update', '--id', "$($ExistingPr.number)", '--title', $Title, '--description', $description) + $adoArgs
                $updateOutput = & az @updateArgs 2>&1
                if ($LASTEXITCODE -ne 0) {
                    Write-Warning "az repos pr update failed: $($updateOutput -join ' ')"
                } else {
                    Write-Host " Updated existing PR in place: $($ExistingPr.url)" -ForegroundColor DarkGray
                }
                $prUrl = $ExistingPr.url
            } else {
                $createArgs = @('repos', 'pr', 'create', '--title', $Title, '--description', $description, '--source-branch', $BranchName, '-o', 'json') + $adoArgs
                $createOutput = & az @createArgs 2>&1
                if ($LASTEXITCODE -ne 0) {
                    Write-Warning "az repos pr create failed: $($createOutput -join ' ')"
                } else {
                    $parsed = $createOutput | ConvertFrom-Json -ErrorAction SilentlyContinue
                    $prUrl = if ($parsed -and $parsed.PSObject.Properties['url']) { $parsed.url } else { ($createOutput -join ' ') }
                }
            }
        }
    }

    return $prUrl
}