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 "Parameters -AsString and -OutFile cannot be combined." }
        if ($AsString -and $AsBytes) { throw "Parameters -AsString and -AsBytes cannot be combined." }
        if ($AsBytes -and $OutFile)  { throw "Parameters -AsBytes and -OutFile cannot be combined (byte[] is implied without -OutFile)." }
    }

    process {
        if (Test-PSFFunctionInterrupt) { return }
        try {
            $basePath = 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' }

            $apiUri = $basePath
            if ($q.Count -gt 0) {
                $apiUri += '?' + ($q.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" } -join '&')
            }

            Write-PSFMessage -Level Verbose -Message "API URI: $apiUri"

            # Use Invoke-ADOApiRequest expecting raw byte content. If helper returns object, fall back to manual.
            $response = Invoke-ADOApiRequest -Organization $Organization `
                                             -Token $Token `
                                             -ApiUri $apiUri `
                                             -Method 'GET' `
                                             -Headers @{ 'Accept' = 'application/octet-stream' } `
                                             -ApiVersion $ApiVersion

            $data = $null
            if ($response -and $response.Results -is [byte[]]) {
                $data = $response.Results
            }
            elseif ($response -and $response.Results -and ($response.Results.GetType().Name -eq 'String')) {
                # Might already be a string (text attachment)
                $data = [System.Text.Encoding]::UTF8.GetBytes([string]$response.Results)
            }
            elseif ($response -and $response.Results) {
                # Try to coerce unknown object to string then bytes
                $data = [System.Text.Encoding]::UTF8.GetBytes([string]$response.Results)
            }
            else {
                # Fallback: attempt direct download using WebRequest if data missing
                Write-PSFMessage -Level Verbose -Message "Fallback direct download for attachment $Id"
                $baseUrl = "https://dev.azure.com/$Organization/"
                $authToken = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$Token"))
                $headers = @{ Authorization = "Basic $authToken"; Accept = 'application/octet-stream' }
                $fullUrl = "$baseUrl$apiUri?api-version=$ApiVersion"
                $raw = Invoke-WebRequest -Uri $fullUrl -Headers $headers -Method GET -UseBasicParsing
                $data = $raw.ContentBytes
            }

            if ($OutFile) {
                $dir = Split-Path -Path $OutFile -Parent
                if ($dir -and -not (Test-Path $dir)) {
                    Write-PSFMessage -Level Verbose -Message "Creating directory $dir"
                    New-Item -ItemType Directory -Force -Path $dir | Out-Null
                }
                [System.IO.File]::WriteAllBytes($OutFile, $data)
                $fi = Get-Item -LiteralPath $OutFile
                Write-PSFMessage -Level Verbose -Message "Attachment saved to $OutFile"
                return $fi
            }

            if ($AsString) {
                $text = [System.Text.Encoding]::UTF8.GetString($data)
                Write-PSFMessage -Level Verbose -Message "Returning attachment as string"
                return $text
            }

            # Default: bytes
            Write-PSFMessage -Level Verbose -Message "Returning attachment as byte array (Length=$($data.Length))"
            return $data
        }
        catch {
            Write-PSFMessage -Level Error -Message "Failed to download attachment $Id : $($_.ErrorDetails.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
    }
}