functions/get-adoworkitemattachment.ps1

<#
    .SYNOPSIS
        Downloads a work item attachment.
    .DESCRIPTION
        Wraps the Work Item Tracking Attachments - Get endpoint. Supports returning bytes (default),
        text (UTF8), or saving directly to a file. Optional query parameters: fileName, download=true.
    .OUTPUTS
        System.IO.FileInfo (when -OutFile)
        System.String (when -AsString)
        System.Byte[] (default or -AsBytes)
    .PARAMETER Organization
        Azure DevOps organization name.
    .PARAMETER Project
        (Optional) Project name or id.
    .PARAMETER Token
        Personal Access Token (PAT) (vso.work scope).
    .PARAMETER Id
        Attachment Id (GUID).
    .PARAMETER FileName
        Desired file name (adds fileName query parameter; does not auto-save unless -OutFile specified).
    .PARAMETER Download
        Force download semantics (adds download=true query parameter).
    .PARAMETER OutFile
        Path to write attachment content. Creates directories if needed. Returns FileInfo.
    .PARAMETER AsString
        Return UTF8 string content (cannot be combined with -OutFile or -AsBytes).
    .PARAMETER AsBytes
        Return raw byte[] (default behavior; explicit for clarity).
    .PARAMETER ApiVersion
        API version (default 7.1).
    .EXAMPLE
        PS> Get-ADOWorkItemAttachment -Organization org -Token $pat -Id 11111111-2222-3333-4444-555555555555 -OutFile .\image.png
         
        Downloads the attachment to image.png.
    .EXAMPLE
        PS> Get-ADOWorkItemAttachment -Organization org -Token $pat -Id $attId -AsString
         
        Returns the attachment content as a UTF8 string.
    .EXAMPLE
        PS> Get-ADOWorkItemAttachment -Organization org -Project proj -Token $pat -Id $attId -FileName report.txt -Download -AsBytes
         
        Returns the attachment as byte[] (default) using specified fileName and download parameters.
    .NOTES
        Author: Oleksandr Nikolaiev (@onikolaiev)
#>

function Get-ADOWorkItemAttachment {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions","")]
    [CmdletBinding(DefaultParameterSetName='Default')]
    [OutputType([System.IO.FileInfo])]
    [OutputType([string])]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory=$true)]
        [string]$Organization,

        [Parameter()]
        [string]$Project,

        [Parameter(Mandatory=$true)]
        [string]$Token,

        [Parameter(Mandatory=$true)]
        [Guid]$Id,

        [Parameter()]
        [string]$FileName,

        [Parameter()]
        [switch]$Download,

        [Parameter()]
        [string]$OutFile,

        [Parameter(ParameterSetName='String')]
        [switch]$AsString,

        [Parameter(ParameterSetName='Bytes')]
        [switch]$AsBytes,

        [Parameter()]
        [string]$ApiVersion = '7.1'
    )

    begin {
        Write-PSFMessage -Level Verbose -Message "Starting download of attachment $Id (Org: $Organization / Project: $Project)"
        Invoke-TimeSignal -Start
        if ($AsString -and $OutFile) { throw "-AsString and -OutFile cannot be combined." }
        if ($AsString -and $AsBytes) { throw "-AsString and -AsBytes cannot be combined." }
        if ($AsBytes -and $OutFile)  { throw "-AsBytes and -OutFile cannot be combined (bytes implied when not saving)." }
    }

    process {
        if (Test-PSFFunctionInterrupt) { return }
        try {
            # -------- Build URL (direct REST call to avoid encoding issues) --------
            $rel = if ($Project) { "$Project/_apis/wit/attachments/$Id" } else { "_apis/wit/attachments/$Id" }
            $q = @{}
            if ($FileName) { $q['fileName'] = [System.Uri]::EscapeDataString($FileName) }
            if ($Download) { $q['download'] = 'true' }
            $q['api-version'] = $ApiVersion
            $pairs = foreach ($kv in $q.GetEnumerator()) { "{0}={1}" -f $kv.Key,$kv.Value }
            $url = "https://dev.azure.com/$Organization/$rel" + '?' + ($pairs -join '&')
            Write-PSFMessage -Level Verbose -Message "Downloading from $url"

            # -------- Auth header (Basic with PAT) --------
            $auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$Token"))
            $headers = @{ Authorization = "Basic $auth" }

            if ($OutFile) {
                $dir = Split-Path -Path $OutFile -Parent
                if ($dir -and -not (Test-Path $dir)) {
                    New-Item -ItemType Directory -Path $dir -Force | Out-Null
                }
                $resp = Invoke-WebRequest -Uri $url -Headers $headers -Method Get -OutFile $OutFile -UseBasicParsing -ErrorAction Stop
                # Validate file not HTML error
                $firstBytes = [System.IO.File]::ReadAllBytes($OutFile)[0..([Math]::Min(255, ([System.IO.File]::ReadAllBytes($OutFile).Length - 1)))]
                $asTextSample = [System.Text.Encoding]::UTF8.GetString($firstBytes)
                if ($asTextSample -match '<html' -and $asTextSample -match 'Sign In') {
                    Remove-Item -LiteralPath $OutFile -ErrorAction SilentlyContinue
                    throw "Received HTML sign-in page. Check PAT scope/org/project."
                }
                $fi = Get-Item -LiteralPath $OutFile
                Write-PSFMessage -Level Verbose -Message "Saved attachment to $OutFile (Size: $($fi.Length) bytes)"
                return $fi
            }
            else {
                $resp = Invoke-WebRequest -Uri $url -Headers $headers -Method Get -UseBasicParsing -ErrorAction Stop
                # RawContentStream -> MemoryStream -> bytes
                $rawStream = $resp.RawContentStream
                $ms = New-Object System.IO.MemoryStream
                $rawStream.Position = 0
                $rawStream.CopyTo($ms)
                $bytes = $ms.ToArray()
                $ms.Dispose()

                if ($bytes.Length -eq 0) { throw "Downloaded content is empty." }

                # Detect HTML (auth failure)
                $sample = [System.Text.Encoding]::UTF8.GetString($bytes,0,[Math]::Min(300,$bytes.Length))
                if ($sample -match '<html' -and $sample -match 'Sign In') {
                    throw "Received HTML sign-in page. Authentication failed (invalid PAT or missing scope)."
                }

                if ($AsString) {
                    $text = [System.Text.Encoding]::UTF8.GetString($bytes)
                    Write-PSFMessage -Level Verbose -Message "Returning attachment as string (Length: $($text.Length))"
                    return $text
                }
                # default / -AsBytes
                Write-PSFMessage -Level Verbose -Message "Returning attachment as byte[] (Length: $($bytes.Length))"
                return $bytes
            }
        }
        catch {
            Write-PSFMessage -Level Error -Message "Failed to download attachment $Id : $($_.Exception.Message)" -Exception $PSItem.Exception
            Stop-PSFFunction -Message "Stopping because of errors"
        }
    }

    end {
        Write-PSFMessage -Level Verbose -Message "Completed download of attachment $Id"
        Invoke-TimeSignal -End
    }
}