Skip to content

Instantly share code, notes, and snippets.

@Tridy
Created March 26, 2025 18:24
Show Gist options
  • Save Tridy/a2e253b422a52ccbce58e8142e95cdea to your computer and use it in GitHub Desktop.
Save Tridy/a2e253b422a52ccbce58e8142e95cdea to your computer and use it in GitHub Desktop.
Azure DevOps Rest API Downloading All Attachments using C#

Downloading all attachments from Azure DevOps project using REST API Version 7.1

Using C#, Refit

It worked for me when I was exporting work items from one tenant to another.

I am using Refit with this in C#

  1. Get the workitems that have attachments and attachment ids:
public record AttachmentInfo(int WorkItemId, string FileName, string AttachmentGuid);


// ---

IAzureDevOpsApi devOpsApi = GetAzureDevOpsApi(); // Refit methods for Azure DevOps API

var wiqlQueryForWorkItemsWithAttachments = new
{
    query = "SELECT [System.Id] FROM workitems WHERE [System.AttachedFileCount] > 0"
};

ApiResponse<WorkItemsQueryResult> wiqlResponse = await devOpsApi.QueryWorkItemsWithWiqlAsync(wiqlQueryForWorkItemsWithAttachments, authorization: $"Basic {_basicAuth}").ConfigureAwait(false);

if (!wiqlResponse.IsSuccessStatusCode) throw new Exception("Failed to fetch work items via WIQL.");

WorkItemsQueryResult workItems = wiqlResponse.Content!;

IEnumerable<int> workItemIds = workItems.workItems.Select(wi => wi.id);

List<int>[] chunks = workItemIds
    .Select((value, index) => new
    {
        value,
        index
    })
    .GroupBy(x => x.index / 200)
    .Select(group => group.Select(x => x.value).ToList())
    .ToArray();

List<AttachmentInfo> attachments = new();

foreach (List<int> chunk in chunks)
{
    WorkItemBatchRequest batchRequest = new()
    {
        Ids = chunk,
        Expand = "Relations"
    };

    ApiResponse<WorkItemBatchResponse> detailedResponse = await devOpsApi.GetWorkItemsBatchAsync(batchRequest, authorization: $"Basic {_basicAuth}").ConfigureAwait(false);

    if (!detailedResponse.IsSuccessStatusCode) throw new Exception("Failed to fetch a work item via WIQL.");

    WorkItemBatchResponse detailedWorkItem = detailedResponse.Content!;

    foreach (var workItem in detailedWorkItem.Value)
    {
        IEnumerable<Relation> relations = workItem.Relations.Where(r => r.Rel == "AttachedFile");

        foreach (Relation attachment in relations)
        {
            string fileName = attachment.Attributes.Name;
            string url = attachment.Url;
            string attachmentGuid = url.Split('/').Last();

            attachments.Add(new AttachmentInfo(workItem.Id, fileName, attachmentGuid));
        }
    }
}
  1. Download the attachments (I am adding a workitems as a prefix for the filename):
foreach (AttachmentInfo info in attachmentsInfo)
{
    ApiResponse<Stream> result = await api.DownloadAttachmentAsync(info.AttachmentGuid, authorization: $"Basic {_basicAuth}");
    if (!result.IsSuccessStatusCode) throw new Exception("Failed to download attachment.");
    
    var contentStream = result.Content!;
    
    string fileName = info.WorkItemId + "_" + info.FileName;
    string outputPath = Path.Combine(_attachmentsDirectory, fileName);

    await using (FileStream fileStream = new(outputPath, FileMode.Create, FileAccess.Write, FileShare.None))
    {
        await contentStream.CopyToAsync(fileStream);
        Console.WriteLine($"Attachment downloaded successfully to {outputPath}");
    }
}
  1. Here is the Refit for the DevOps Rest API
public interface IAzureDevOpsApi
{
    [Post("/_apis/wit/wiql?api-version=7.1")]
    Task<ApiResponse<WorkItemsQueryResult>> QueryWorkItemsWithWiqlAsync([Body] object wiqlQuery, [Header("Authorization")] string authorization);

    [Post("/_apis/wit/workitemsbatch?api-version=7.1")]
    Task<ApiResponse<WorkItemBatchResponse>> GetWorkItemsBatchAsync([Body] object requestBody, [Header("Authorization")] string authorization);
    
    [Get("/_apis/wit/attachments/{attachmentId}?api-version=7.1")]
    Task<ApiResponse<Stream>> DownloadAttachmentAsync(string attachmentId, [Header("Authorization")] string authorization);
    
    [Post("/_apis/wit/wiql?api-version=7.1")]
    Task<ApiResponse<string>> QueryAllWorkItemsWithWiqlAsync([Body] object wiqlQuery, [Header("Authorization")] string authorization);

    [Patch("/_apis/wit/workitems/{id}?api-version=7.1")]
    [Headers("Content-Type: application/json-patch+json")]
    Task<ApiResponse<string>> AddRelationAsync(int id, object[] operations, [Header("Authorization")] string authorization);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment