以编程方式在本地 Azure DevOps Server 工作项评论中为 Active Directory 用户帐户添加 @mention(2021 年 1 月)

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

我在封闭网络上管理 Azure DevOps Server (ADS) 2019 1.1(补丁 7)运行 的本地实例。 ADS 实例在 Windows Active Directory (AD) 域中 运行。所有 ADS 用户都根据其 AD 用户帐户授予访问权限。每个 AD 用户帐户指定其内部网电子邮件地址。

我需要在每个月的第一个星期一将特定项目中特定用户故事的通知发送到“分配给”人员的 AD 电子邮件地址。

困难的部分是让@mention 解析为 AD 用户帐户,以便 ADS 发送通知。

如何让 ADS 接收我的@mention 并将其解析为 Active Directory 用户 ID?

在下面的回答中查看我的 MRE

这三个S.O。项目解决了问题的各个方面,但我在下面的最小的、可重现的示例中将它们全部整合到一个示例工作解决方案中

过去S.O。问答

我决定实施此要求,以便 ADS 发送基于以编程方式添加的 @mention 的通知,如下所示:

  • 在 ADS 应用程序服务器上,创建一个在每个月的第一天运行的计划任务

  • 计划任务运行一个程序(C# + ADS REST api 安装在应用服务器上的控制台应用程序)定位相关的用户故事并以编程方式将@mention 添加到新评论对于用户故事的“分配给”用户帐户。该程序在域管理员帐户下运行,该帐户也是“完全控制”ADS 实例管理员帐户。

我的最小可重现示例

输出

并且,电子邮件通知按预期发送。

代码

Program.cs

using System;
using System.Net;
using System.Text;

namespace AdsAtMentionMre
{

    class Program
    {
        // This MRE was tested using a "free" (0/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
            // 
            //
            // 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://docs.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
            // 

            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://docs.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; }
        }
    }
}