I administer an on-premises instance of Azure DevOps Server (ADS) 2019 1.1 (Patch 7) running on a closed network. The ADS instance is running in a Windows Active Directory (AD) domain. All ADS users are granted access based on their AD user account. Each AD user account specifies their intranet email address.
I have a requirement to send a notification to the "Assigned To" person's AD email address for specific user stories in a specific project on the first Monday of each month.
The hard part is getting the @mention to resolve to the AD user account so that ADS sends the notification.
How do I get ADS take my @mention and resolve it to an Active Directory user id?
See my MRE in my answer below
These three S.O. items address aspects of the issue, but my minimal, reproducible example below pulls it all together into a sample working solution
Past S.O. Q&A
Mentioning a user in the System.History (July, 2017)
VSTS - uploading via an excel macro and getting @mentions to work (March 2018)
Ping (@) user in Azure DevOps comment (Oct 2019)
I decided to implement this requirement such that ADS sends the notification based on an @mention added programmatically like this:
On the ADS Application server, create a scheduled task that runs on the first of each month
The scheduled task runs a program (C# + ADS REST api console app installed on the app server) that locates the relevant user stories and programmatically adds an @mention to a new comment for the user story's "Assigned To" user account. The program runs under a domain admin account that is also a "full control" ADS instance admin account.
My Minimum Reproducible Example
Output
And, the email notification is sent as expected.
Code
Program.cs
using System;
using System.Net;
using System.Text;
namespace AdsAtMentionMre
{
class Program
{
// This MRE was tested using a "free" ($150/month credit) Microsoft Azure environment provided by my Visual Studio Enterprise Subscription.
// I estabished a Windows Active Directory Domain in my Microsoft Azure environment and then installed and configured ADS on-prem.
// The domain is composed of a domain controller server, an ADS application server, and an ADS database server.
const string ADS_COLLECTION_NAME_URL = "http://##.##.##.###/aaaa%20bbbb%20cccc%20dddd";
const string ADS_PROJECT_NAME = "ddd eeeeee";
static void Main(string[] args)
{
try
{
if (!TestEndPoint())
{
Environment.Exit(99);
}
// GET RELEVANT USER STORY WORK IDS
ClsUserStoryWorkIds objUserStoryWorkIds = new ClsUserStoryWorkIds(ADS_COLLECTION_NAME_URL, ADS_PROJECT_NAME);
// FOR EACH USER STORY ID RETRIEVED, ADD @MENTION COMMENT TO ASSIGNED PERSON
if (objUserStoryWorkIds.IdList.WorkItems.Count > 0)
{
ClsAdsComment objAdsComment = new ClsAdsComment(ADS_COLLECTION_NAME_URL, ADS_PROJECT_NAME);
foreach (ClsUserStoryWorkIds.WorkItem workItem in objUserStoryWorkIds.IdList.WorkItems)
{
if (objAdsComment.Add(workItem))
{
Console.WriteLine(string.Format("Comment added to ID {0}", workItem.Id));
}
else
{
Console.WriteLine(string.Format("Comment NOT added to ID {0}", workItem.Id));
}
}
}
Console.ReadKey();
Environment.Exit(0);
}
catch (Exception e)
{
StringBuilder msg = new StringBuilder();
Exception innerException = e.InnerException;
msg.AppendLine(e.Message);
msg.AppendLine(e.StackTrace);
while (innerException != null)
{
msg.AppendLine("");
msg.AppendLine("InnerException:");
msg.AppendLine(innerException.Message);
msg.AppendLine(innerException.StackTrace);
innerException = innerException.InnerException;
}
Console.Error.WriteLine(string.Format("An exception occured:\n{0}", msg.ToString()));
Console.ReadKey();
Environment.Exit(1);
}
}
private static bool TestEndPoint()
{
bool retVal = false;
// This is a just a quick and dirty way to test the ADS collection endpoint.
// No authentication is attempted.
// The exception "The remote server returned an error: (401) Unauthorized."
// represents success because it means the endpoint is responding
try
{
HttpWebRequest request = (HttpWebRequest)HttpWebRequest.Create(ADS_COLLECTION_NAME_URL);
request.AllowAutoRedirect = false; // find out if this site is up and BTW, don't follow a redirector
request.Method = System.Net.WebRequestMethods.Http.Head;
request.Timeout = 30000;
WebResponse response = request.GetResponse();
}
catch (Exception e1)
{
if (!e1.Message.Equals("The remote server returned an error: (401) Unauthorized."))
{
throw;
}
retVal = true;
}
return retVal;
}
}
}
ClsUserStoryWorkIds.cs
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
namespace AdsAtMentionMre
{
public class ClsUserStoryWorkIds
{
ClsResponse idList = null;
/// <summary>
/// Get all the users story ids for user stories that match the wiql query criteria
/// </summary>
/// <param name="adsCollectionUrl"></param>
/// <param name="adsProjectName"></param>
public ClsUserStoryWorkIds(string adsCollectionUrl, string adsProjectName)
{
string httpPostRequest = string.Format("{0}/{1}/_apis/wit/wiql?api-version=5.1", adsCollectionUrl, adsProjectName);
// In my case, I'm working with an ADS project that is based on a customized Agile process template.
// I used the ADS web portal to create a customized process inherited from the standard ADS Agile process.
// The customization includes custom fields added to the user story:
// [Category for DC and MR] (picklist)
// [Recurrence] (picklist)
ClsRequest objJsonRequestBody_WiqlQuery = new ClsRequest
{
Query = string.Format("Select [System.Id] From WorkItems Where [System.WorkItemType] = 'User Story' and [System.TeamProject] = '{0}' and [Category for DC and MR] = 'Data Call' and [Recurrence] = 'Monthly' and [System.State] = 'Active'", adsProjectName)
};
string json = JsonConvert.SerializeObject(objJsonRequestBody_WiqlQuery);
// ServerCertificateCustomValidationCallback: In my environment, we use self-signed certs, so I
// need to allow an untrusted SSL Certificates with HttpClient
// https://stackoverflow.com/questions/12553277/allowing-untrusted-ssl-certificates-with-httpclient
//
// UseDefaultCredentials = true: Before running the progran as the domain admin, I use Windows Credential
// Manager to create a Windows credential for the domain admin:
// Internet address: IP of the ADS app server
// User Name: Windows domain + Windows user account, i.e., domainName\domainAdminUserName
// Password: password for domain admin's Windows user account
using (HttpClient HttpClient = new HttpClient(new HttpClientHandler()
{
UseDefaultCredentials = true,
ClientCertificateOptions = ClientCertificateOption.Manual,
ServerCertificateCustomValidationCallback =
(httpRequestMessage, cert, cetChain, policyErrors) =>
{
return true;
}
}))
{
HttpClient.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
//todo I guess I should make this a GET, not a POST, but the POST works
HttpRequestMessage httpRequestMessage = new HttpRequestMessage(new HttpMethod("POST"), httpPostRequest)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
using (HttpResponseMessage httpResponseMessage = HttpClient.SendAsync(httpRequestMessage).Result)
{
httpResponseMessage.EnsureSuccessStatusCode();
string jsonResponse = httpResponseMessage.Content.ReadAsStringAsync().Result;
this.IdList = JsonConvert.DeserializeObject<ClsResponse>(jsonResponse);
}
}
}
public ClsResponse IdList { get => idList; set => idList = value; }
/// <summary>
/// <para>This is the json request body for a WIQL query as defined by</para>
/// <para>https://learn.microsoft.com/en-us/rest/api/azure/devops/wit/wiql/query%20by%20wiql?view=azure-devops-rest-5.1</para>
/// <para>Use https://json2csharp.com/ to create class from json request body sample</para>
/// </summary>
public class ClsRequest
{
[JsonProperty("query")]
public string Query { get; set; }
}
/// <summary>
/// <para>This is the json response body for the WIQL query used in this class.</para>
/// <para>This class was derived by capturing the string returned by: </para>
/// <para>httpResponseMessage.Content.ReadAsStringAsync().Result</para>
/// <para> in the CTOR above and using https://json2csharp.com/ to create the ClsResponse class.</para>
/// </summary>
public class ClsResponse
{
[JsonProperty("queryType")]
public string QueryType { get; set; }
[JsonProperty("queryResultType")]
public string QueryResultType { get; set; }
[JsonProperty("asOf")]
public DateTime AsOf { get; set; }
[JsonProperty("columns")]
public List<Column> Columns { get; set; }
[JsonProperty("workItems")]
public List<WorkItem> WorkItems { get; set; }
}
public class Column
{
[JsonProperty("referenceName")]
public string ReferenceName { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("url")]
public string Url { get; set; }
}
public class WorkItem
{
[JsonProperty("id")]
public int Id { get; set; }
[JsonProperty("url")]
public string Url { get; set; }
}
}
}
ClsAdsComment.cs
using Newtonsoft.Json;
using System;
using System.Net.Http;
using System.Text;
namespace AdsAtMentionMre
{
class ClsAdsComment
{
readonly string adsCollectionUrl;
readonly string adsProjectName
public ClsAdsComment(string adsCollectionUrl, string adsProjectName)
{
this.adsCollectionUrl = adsCollectionUrl;
this.adsProjectName = adsProjectName;
}
public bool Add(ClsUserStoryWorkIds.WorkItem workItem)
{
bool retVal = false;
string httpPostRequest = string.Empty;
string httpGetRequest = string.Empty;
string json = string.Empty;
string emailAddress = string.Empty;
string emailAddressId = string.Empty;
#region GET ASSIGNED TO METADATA BY GETTING WORK ITEM
httpGetRequest = string.Format("{0}/{1}/_apis/wit/workitems/{2}?fields=System.AssignedTo&api-version=5.1", this.adsCollectionUrl, this.adsProjectName, workItem.Id);
using (HttpClient httpClient = new HttpClient(new HttpClientHandler()
{
UseDefaultCredentials = true,
ClientCertificateOptions = ClientCertificateOption.Manual,
ServerCertificateCustomValidationCallback =
(httpRequestMessage, cert, cetChain, policyErrors) =>
{
return true;
}
}))
{
using (HttpResponseMessage response = httpClient.GetAsync(httpGetRequest).Result)
{
response.EnsureSuccessStatusCode();
string responseBody = response.Content.ReadAsStringAsync().Result;
ClsJsonResponse_GetWorkItem objJsonResponse_GetWorkItem = JsonConvert.DeserializeObject<ClsJsonResponse_GetWorkItem>(responseBody);
if (objJsonResponse_GetWorkItem.Fields.SystemAssignedTo == null)
{
// If there is not a assigned user, skip it
return retVal;
}
// FYI: Even if the A.D. user id that is in the assigned to field has been disabled or deleted
// in A.D., it will still show up ok. The @mention will be added and ADS will attempt to
// send the email notification
emailAddress = objJsonResponse_GetWorkItem.Fields.SystemAssignedTo.UniqueName;
emailAddressId = objJsonResponse_GetWorkItem.Fields.SystemAssignedTo.Id;
}
}
#endregion GET ASSIGNED TO METADATA BY GETTING WORK ITEM
#region ADD COMMENT
StringBuilder sbComment = new StringBuilder();
sbComment.Append(string.Format("<div><a href=\"#\" data-vss-mention=\"version:2.0,{0}\">@{1}</a>: This is a programatically added comment.</div>", emailAddressId, emailAddress));
sbComment.Append("<br>");
sbComment.Append(DateTime.Now.ToString("yyyy-MM-dd hh-mm-ss tt"));
httpPostRequest = string.Format("{0}/{1}/_apis/wit/workitems/{2}/comments?api-version=5.1-preview.3", this.adsCollectionUrl, this.adsProjectName, workItem.Id);
ClsJsonRequest_AddComment objJsonRequestBody_AddComment = new ClsJsonRequest_AddComment
{
Text = sbComment.ToString()
};
json = JsonConvert.SerializeObject(objJsonRequestBody_AddComment);
// Allowing Untrusted SSL Certificates with HttpClient
// https://stackoverflow.com/questions/12553277/allowing-untrusted-ssl-certificates-with-httpclient
using (HttpClient httpClient = new HttpClient(new HttpClientHandler()
{
UseDefaultCredentials = true,
ClientCertificateOptions = ClientCertificateOption.Manual,
ServerCertificateCustomValidationCallback =
(httpRequestMessage, cert, cetChain, policyErrors) =>
{
return true;
}
}))
{
httpClient.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
HttpRequestMessage httpRequestMessage = new HttpRequestMessage(new HttpMethod("POST"), httpPostRequest)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
using (HttpResponseMessage httpResponseMessge = httpClient.SendAsync(httpRequestMessage).Result)
{
httpResponseMessge.EnsureSuccessStatusCode();
// Don't need the response, but get it anyway
string jsonResponse = httpResponseMessge.Content.ReadAsStringAsync().Result;
retVal = true;
}
}
#endregion ADD COMMENT
return retVal;
}
// This is the json request body for "Add comment" as defined by
// https://learn.microsoft.com/en-us/rest/api/azure/devops/wit/comments/add?view=azure-devops-rest-5.1
// Use https://json2csharp.com/ to create class from json body sample
public class ClsJsonRequest_AddComment
{
[JsonProperty("text")]
public string Text { get; set; }
}
/// <summary>
/// <para>This is the json response body for the get work item query used in the Add method above.</para>
/// <para>This class was derived by capturing the string returned by: </para>
/// <para>string responseBody = response.Content.ReadAsStringAsync().Result;</para>
/// <para> in the Add method above and using https://json2csharp.com/ to create the ClsJsonResponse_GetWorkItem class.</para>
/// </summary>
public class ClsJsonResponse_GetWorkItem
{
[JsonProperty("id")]
public int Id { get; set; }
[JsonProperty("rev")]
public int Rev { get; set; }
[JsonProperty("fields")]
public Fields Fields { get; set; }
[JsonProperty("_links")]
public Links Links { get; set; }
[JsonProperty("url")]
public string Url { get; set; }
}
public class Avatar
{
[JsonProperty("href")]
public string Href { get; set; }
}
public class Links
{
[JsonProperty("avatar")]
public Avatar Avatar { get; set; }
[JsonProperty("self")]
public Self Self { get; set; }
[JsonProperty("workItemUpdates")]
public WorkItemUpdates WorkItemUpdates { get; set; }
[JsonProperty("workItemRevisions")]
public WorkItemRevisions WorkItemRevisions { get; set; }
[JsonProperty("workItemComments")]
public WorkItemComments WorkItemComments { get; set; }
[JsonProperty("html")]
public Html Html { get; set; }
[JsonProperty("workItemType")]
public WorkItemType WorkItemType { get; set; }
[JsonProperty("fields")]
public Fields Fields { get; set; }
}
public class SystemAssignedTo
{
[JsonProperty("displayName")]
public string DisplayName { get; set; }
[JsonProperty("url")]
public string Url { get; set; }
[JsonProperty("_links")]
public Links Links { get; set; }
[JsonProperty("id")]
public string Id { get; set; }
[JsonProperty("uniqueName")]
public string UniqueName { get; set; }
[JsonProperty("imageUrl")]
public string ImageUrl { get; set; }
[JsonProperty("descriptor")]
public string Descriptor { get; set; }
}
public class Fields
{
[JsonProperty("System.AssignedTo")]
public SystemAssignedTo SystemAssignedTo { get; set; }
[JsonProperty("href")]
public string Href { get; set; }
}
public class Self
{
[JsonProperty("href")]
public string Href { get; set; }
}
public class WorkItemUpdates
{
[JsonProperty("href")]
public string Href { get; set; }
}
public class WorkItemRevisions
{
[JsonProperty("href")]
public string Href { get; set; }
}
public class WorkItemComments
{
[JsonProperty("href")]
public string Href { get; set; }
}
public class Html
{
[JsonProperty("href")]
public string Href { get; set; }
}
public class WorkItemType
{
[JsonProperty("href")]
public string Href { get; set; }
}
}
}