Search code examples
c#active-directoryazure-devops-rest-apiazure-devops-server-2019mention

Programmatically add @mention for Active Directory User Account in on-prem Azure DevOps Server work item comment (Jan, 2021)


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


Solution

  • 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.

    enter image description here

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