It worked for me when I was exporting work items from one tenant to another.
I am using Refit with this in C#
- 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));
}
}
}
- 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}");
}
}
- 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);
}