首页 > 解决方案 > 以编程方式在本地 Azure DevOps 服务器工作项注释中为 Active Directory 用户帐户添加 @提及(2021 年 1 月)

问题描述

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

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

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

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

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

标签: c#active-directoryazure-devops-rest-apiazure-devops-server-2019mention

解决方案


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

过去的 SO Q&A

在 System.History 中提及用户(2017 年 7 月)

VSTS - 通过 excel 宏上传并让 @提及工作(2018 年 3 月)

Azure DevOps 评论中的 Ping (@) 用户(2019 年 10 月)

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

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

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

我的最小可重现示例

输出

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

在此处输入图像描述

代码

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

推荐阅读