Automate Azure DevOps Work Item Updates with Azure Functions and the Azure DevOps API
Table of Contents
Objective
I use Azure DevOps Boards for agile project management, allowing me to plan, track, and discuss automation and DevOps tasks. I also create reporting dashboards from the data within the work items. One such metric I report on is how much time we save when implementing a new automation. To do this, I have a field called “Estimated Manual Time (min)” and “Annual Occurrences“. The first field is how long it takes someone to do the task manual right now, and the second field is how many times per year this certain task is done. The last field is “Time Saved Annually (hours),” which takes the first field, multiply it by the second field, and then divides that number by 60.
In this article, I will show you how I automated the “Time Saved Annually (hours)” value whenever a new work item is created or the first or second field’s value changes.
Get A Personal Access Token From Azure DevOps
First, we must get a personal access token (PAT) from Azure DevOps (AzDo) to use it to interact with the API.
- Log into your Azure DevOps, and in the top right, click the person icon with a gear.
2. From there, click “Personal Access Tokens.”
3. Give your PAT a name, which organization it will have access to, and an expiration. For permissions, give it Read & Write for work items.
4. Take note of the PAT and copy it for later.
Setting up Work Item Fields
In my example, I created two custom fields:
- Estimated Manual Time (min)
- Annual Occurrences
The automation will retrieve both field values, multiply them, and divide by 60 to get how much time is saved annually in hours. To do this, I need to get the field name. The field name (don’t confuse the field name and the label name) will most likely be “custom.[fieldnamenospace]”
If I go to my Project Properties > Settings > Processes, I can view the name of each field. The example below shows the Estimated Manual Time (min) field name:
The label, however, is Estimated Manual Time (min)
So, in my case, my field names are:
- Custom.TimeSavedAnually
- Custom.TimeSaved
- Custom.AnnualOcurrences
Another way to get the field name is to use something like Postman and the PAT you got earlier and view the properties of a work item where you have those fields present. The GET URL would be:
https://dev.azure.com/{{organization}}/{{project}}/_apis/wit/workitems/{{workitemID}}?api-version=7.1-preview.3
(replace the organization, project, and workitemID)
The API uses basic authentication, so put your PAT encoded in Base64 as the username.
NOTE: Make the two fields mandatory so you can ensure they always contain values.
Create an Azure Function
Create a new PowerShell Function
Next, we need to create an Azure Function that will be used to intercept incoming webhooks from Azure DevOps and then, in turn, interact with the Azure DevOps API.
- I chose a Consumption Azure Function. For Runtime, select PowerShell Core and whatever version is the latest.
2. Next, we need to create the actual function:
3. Select HTTP trigger
4. Give your function a name, and for authorization, select Function; this means the function requires the function key to use.
Edit Function Code
Edit your new function and replace the code with the code below:
Note: You will need to change the field names to match yours. For now, leave the $PAT as it is.
using namespace System.Net
# Input bindings are passed in via param block.
param($Request, $TriggerMetadata)
[string]$PAT = $env:PAT
$TimeSaveAnuallyField = "Custom.TimeSavedAnually"
$TimeSavedField = "Custom.TimeSaved"
$AnnualOcurrencesField = "Custom.AnnualOcurrences"
function Get-AzDoItemDetails {
param(
[Parameter(Mandatory)]
[string]$PAT,
[Parameter(Mandatory)]
[string]$URL
)
begin {
# Encode the PAT correctly
$base64PAT = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$($PAT)"))
$headers = @{
Authorization = "Basic $base64PAT"
}
}
process {
try {
# Log request initiation
Write-Verbose "Sending request to Azure DevOps API..."
# Send the API request
$response = Invoke-RestMethod -Uri $url -Headers $headers -Method Get
# Log successful response
Write-Verbose "Received response: $($response | ConvertTo-Json -Depth 100)"
# Return the response
$response
}
catch {
# Log the error details
Write-Error "Failed to retrieve work item. Error details: $_"
throw $_
}
}
}
function Set-AzDoWorkItemField {
param(
[Parameter(Mandatory)]
[string]$PAT,
[Parameter(Mandatory)]
[string]$Field,
[Parameter(Mandatory)]
[int]$Value,
[Parameter(Mandatory)]
[string]$url
)
begin {
# Encode the PAT correctly
$base64PAT = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$($PAT)"))
$headers = @{
Authorization = "Basic $base64PAT"
"Content-Type" = "application/json-patch+json"
Accept = "*/*"
}
# Define the URL for the API request with api-version
$url = "$($url)?api-version=7.1-preview.3"
# Define the body of the API request as an array of operations
$body = @"
[
{
`"op`": `"add`",
`"path`": `"/fields/$field`",
`"value`": $value
}
]
"@
}
process {
try {
# Log request initiation
Write-Verbose "Sending request to Azure DevOps API..."
# Send the API request
$response = Invoke-RestMethod -Uri $url -Headers $headers -Method Patch -Body $body
# Log successful response
Write-Verbose "Received response: $($response | ConvertTo-Json -Depth 10)"
# Return the response
$response
}
catch {
# Log the error details
Write-Error "Failed to update work item field. Error details: $_"
throw $_
}
}
}
try {
$body = $Request.Body | ConvertTo-Json -Depth 100
# Convert the body content from JSON to a PowerShell object
$requestBody = $body | ConvertFrom-Json -Depth 100
# Get the update URL
$url = $requestBody.Resource.url
# If the $url contains /updates/ then remove everything after the work item id
if ($url -match "/updates/") {
Write-Verbose "URL contains /updates/, removing everything after the work item id"
$url = $url -replace "/updates/.*", ""
}
$workItem = Get-AzDoItemDetails -PAT $PAT -URL $url
# Get the time saved annually and round the number
$TimeSavedAnually = (($workItem.fields."$TimeSavedField" * $workItem.fields."$AnnualOcurrencesField") / 60)
# Round the number
$TimeSavedAnually = [math]::Round($TimeSavedAnually, 0)
Set-AzDoWorkItemField -url $url -PAT $PAT -Field $TimeSaveAnuallyField -Value $TimeSavedAnually
# Associate values to output bindings by calling 'Push-OutputBinding'.
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
Body = $body
})
}
catch {
# Associate values to output bindings by calling 'Push-OutputBinding' in case of error.
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::BadRequest
Body = "Failed to process request. Error details: $_"
})
}
Environment Variables
In your Azure Function, go under Settings to Environment variables
Create a new one called PAT, and the value should be the Azure DevOps Personal Access Token that we got above (not encoded).
Note: For additional security, consider linking an Azure Key Vault instead
Get Function URL
Return to your Azure Function under Code + Test, click Get Function URL, and copy the URL for the default. Copy the URL for later.
Create Azure DevOps Service Hook
Lastly, we must create a Service Hook with our Azure DevOps project. This will send a webhook to our Azure Function if a new work item is created or a specific field(s) is updated.
- In Azure DevOps, go to your Project Settings
- Under General, click Service Hooks
- Create a new one and select Web Hooks
4. For the trigger, select Work Item Created
5. For the action, paste the URL for your Azure Function
I would not click on the Test button because we are creating custom fields, and the test data will not contain that. Instead, press Finish and then create a new work item. You can see it performed in the Azure Function’s Logs window.
7. Repeat the same steps as above, but instead of Work Item Created, select Work Item Updated and select the field to monitor. In my instance, I chose “Time Saved,” which is the Estimated Manual Time (min) field.
Finally, I created one for the field of annual occurrences.
Now I can see all three of my web-hooks.
Final Thoughts
I now have automated figuring out how many hours are saved on a certain work item by performing math operations on two fields within the work item itself. The different service hooks allow anyone to edit the fields anytime because the Azure Function will perform its operations on the field change.
This may not be the largest time saver in the world, but as you scale and add more work items to your organization, it begins to show its return. It will also allow you to always have the correct numbers to report if they are changed.
Using this automation logic, you can take it a step further and begin to automate other aspects of work items, bringing in data from outside Azure DevOps and aligning it with other external services.
My name is Bradley Wyatt; I am a 5x Microsoft Most Valuable Professional (MVP) in Microsoft Azure and Microsoft 365. I have given talks at many different conferences, user groups, and companies throughout the United States, ranging from PowerShell to DevOps Security best practices, and I am the 2022 North American Outstanding Contribution to the Microsoft Community winner.