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; }
}
}
}