functions/add-adoworkitemattachment.ps1
<# .SYNOPSIS Uploads (creates) a work item attachment. .DESCRIPTION Uses the Azure DevOps Work Item Tracking REST API (Attachments - Create) to upload an attachment. Supports three content sources (separate parameter sets): - File: Provide a local file path via -FilePath - Content: Provide text via -Content (converted to UTF8 bytes) - Stream: Provide an open System.IO.Stream via -Stream Also supports initiating a chunked upload session with -UploadType Chunked (no data chunks are uploaded in this initial request). For chunked transfers you must later call the chunk upload APIs (not implemented here). Returns the attachment reference object (id, url) as type ADO.TOOLS.WorkItem.Attachment. .OUTPUTS ADO.TOOLS.WorkItem.Attachment .PARAMETER Organization Azure DevOps organization name (e.g. contoso). .PARAMETER Project (Optional) Project name or id. If omitted, the attachment is uploaded at the account level. .PARAMETER Token Personal Access Token (PAT) with vso.work_write (or broader) scope. .PARAMETER FilePath Path to an existing local file to upload (File parameter set). .PARAMETER Content Plain text content to upload (Content parameter set). Encoded as UTF8. .PARAMETER Stream Open readable System.IO.Stream to upload (Stream parameter set). Entire stream is buffered. .PARAMETER FileName Target file name to store in Azure DevOps. Defaults to the leaf name of FilePath, 'content.txt' for -Content, or a generated name for -Stream when not specified. .PARAMETER UploadType simple | chunked. Default simple. chunked only starts a chunked upload session (no payload). .PARAMETER AreaPath Optional area path (areaPath query parameter) to associate with the upload. .PARAMETER ApiVersion API version (default 7.1). .EXAMPLE PS> Add-ADOWorkItemAttachment -Organization contoso -Project WebApp -Token $pat -FilePath .\readme.md Uploads readme.md using simple upload and returns the attachment reference. .EXAMPLE PS> Add-ADOWorkItemAttachment -Organization contoso -Project WebApp -Token $pat -Content "Log $(Get-Date -Format o)" -FileName runlog.txt Uploads generated text as runlog.txt. .EXAMPLE PS> $fs = [System.IO.File]::OpenRead('diagram.png') PS> Add-ADOWorkItemAttachment -Organization contoso -Project WebApp -Token $pat -Stream $fs -FileName diagram.png Uploads the stream content (diagram.png) then returns the attachment reference. .EXAMPLE PS> Add-ADOWorkItemAttachment -Organization contoso -Token $pat -FilePath .\large.zip -UploadType Chunked Initiates a chunked upload session for large.zip (no data chunks uploaded here). .LINK https://learn.microsoft.com/azure/devops .NOTES Author: Oleksandr Nikolaiev (@onikolaiev) #> function Add-ADOWorkItemAttachment { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions","")] [CmdletBinding(DefaultParameterSetName='File')] [OutputType('ADO.TOOLS.WorkItem.Attachment')] param( [Parameter(Mandatory=$true)] [string]$Organization, [Parameter(Mandatory=$false)] [string]$Project, [Parameter(Mandatory=$true)] [string]$Token, [Parameter(Mandatory=$true, ParameterSetName='File')] [ValidateScript({ Test-Path $_ -PathType Leaf })] [string]$FilePath, [Parameter(Mandatory=$true, ParameterSetName='Content')] [string]$Content, [Parameter(Mandatory=$true, ParameterSetName='Stream')] [System.IO.Stream]$Stream, [Parameter(Mandatory=$false)] [string]$FileName, [Parameter(Mandatory=$false)] [ValidateSet('Simple','Chunked')] [string]$UploadType = 'Simple', [Parameter(Mandatory=$false)] [string]$AreaPath, [Parameter(Mandatory=$false)] [string]$ApiVersion = '7.1' ) begin { Write-PSFMessage -Level Verbose -Message "Starting attachment upload (Type: $UploadType) (Org: $Organization / Project: $Project)" Invoke-TimeSignal -Start # --- Basic PAT sanity checks (common cause of HTML login response) --- if ([string]::IsNullOrWhiteSpace($Token)) { Stop-PSFFunction -Message "Empty -Token value supplied. Provide a valid Azure DevOps PAT." -Target 'Add-ADOWorkItemAttachment' return } if ($Token -eq $Organization) { Stop-PSFFunction -Message "The -Token value equals the organization name ('$Organization'). You passed the org instead of a PAT." -Target 'Add-ADOWorkItemAttachment' return } if ($Token -match '\s') { Stop-PSFFunction -Message "The -Token contains whitespace which is invalid for a PAT." -Target 'Add-ADOWorkItemAttachment' return } if ($Token.Length -lt 30) { Write-PSFMessage -Level Warning -Message "PAT length ($($Token.Length)) looks short; a full PAT is usually > 30 chars. This may fail authentication." } } process { if (Test-PSFFunctionInterrupt) { return } try { # ---------------- derive FileName ---------------- if (-not $FileName) { switch ($PSCmdlet.ParameterSetName) { 'File' { $FileName = [System.IO.Path]::GetFileName($FilePath) } 'Content' { $FileName = 'content.txt' } 'Stream' { $FileName = "stream-$([guid]::NewGuid()).bin" } } } # ---------------- build relative API path & query ---------------- $basePath = if ($Project) { "$Project/_apis/wit/attachments" } else { "_apis/wit/attachments" } $query = @{} if ($FileName) { $query['fileName'] = [System.Uri]::EscapeDataString($FileName) } if ($UploadType) { $query['uploadType'] = $UploadType.ToLower() } if ($AreaPath) { $query['areaPath'] = [System.Uri]::EscapeDataString($AreaPath) } $apiUri = $basePath if ($query.Count -gt 0) { $pairs = foreach ($kv in $query.GetEnumerator()) { "{0}={1}" -f $kv.Key, $kv.Value } $apiUri += '?' + ($pairs -join '&') } # Append api-version $apiUri += ($(if ($apiUri -match '\?') { '&' } else { '?' }) + "api-version=$ApiVersion") Write-PSFMessage -Level Verbose -Message "API relative URI: $apiUri" $baseUrl = "https://dev.azure.com/$Organization/" $fullUrl = "$baseUrl$apiUri" Write-PSFMessage -Level Verbose -Message "Full URL: $fullUrl" # ---------------- prepare body (simple upload only) ---------------- $bodyBytes = $null if ($UploadType -ieq 'Simple') { switch ($PSCmdlet.ParameterSetName) { 'File' { $resolved = (Resolve-Path $FilePath -ErrorAction Stop).ProviderPath if (-not (Test-Path $resolved -PathType Leaf)) { throw "File not found: $resolved" } $bodyBytes = [System.IO.File]::ReadAllBytes($resolved) } 'Content' { $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($Content) } 'Stream' { if (-not $Stream.CanRead) { throw "Provided stream is not readable." } $ms = New-Object System.IO.MemoryStream $Stream.CopyTo($ms) $bodyBytes = $ms.ToArray() $ms.Dispose() } } if (-not $bodyBytes -or $bodyBytes.Length -eq 0) { throw "Upload content is empty. Provide a non-empty file, content or stream." } Write-PSFMessage -Level Verbose -Message "Payload size (bytes): $($bodyBytes.Length)" } else { Write-PSFMessage -Level Verbose -Message "Initiating chunked upload session (no payload sent)." } # ---------------- perform request ---------------- $authHeader = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$Token")) $headers = @{ Authorization = "Basic $authHeader" } if ($UploadType -ieq 'Simple') { # Use Invoke-RestMethod to let PowerShell handle response parsing $resultJson = Invoke-RestMethod -Method Post -Uri $fullUrl -Headers $headers -Body $bodyBytes -ContentType 'application/octet-stream' -ErrorAction Stop } else { # Start chunked session (no body) $resultJson = Invoke-RestMethod -Method Post -Uri $fullUrl -Headers $headers -ErrorAction Stop } if (-not $resultJson) { throw "Empty response received." } # If Invoke-RestMethod returned HTML (auth or sign-in page), treat as error if ($resultJson -is [string] -and $resultJson -match '<html' ) { $snippet = ($resultJson -replace '\r','' -replace '\n',' ' ) if ($snippet.Length -gt 200) { $snippet = $snippet.Substring(0,200) + '...' } throw "Unexpected HTML response (probable authentication failure or wrong PAT). Verify -Token (PAT), -Organization, and scopes. Snippet: $snippet" } if (-not $resultJson.id -or -not $resultJson.url) { throw "Response missing expected properties (id/url). Raw: $($resultJson | ConvertTo-Json -Depth 4)" } Write-PSFMessage -Level Verbose -Message "Attachment upload succeeded (Id: $($resultJson.id))" $resultJson | Select-PSFObject * -TypeName 'ADO.TOOLS.WorkItem.Attachment' } catch { Write-PSFMessage -Level Error -Message "Attachment upload failed: $($_.Exception.Message)" Stop-PSFFunction -Message "Stopping because of errors" -Target $PSCmdlet.MyInvocation.MyCommand.Name -ErrorRecord $_ } } end { Write-PSFMessage -Level Verbose -Message "Completed attachment upload operation" Invoke-TimeSignal -End } } |