简体   繁体   English

以编程方式为本地 Azure DevOps Server 工作项注释中的 Active Directory 用户帐户添加 @提及(2021 年 1 月)

[英]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.我管理在封闭网络上运行的 Azure DevOps Server (ADS) 2019 1.1(补丁 7)的本地实例。 The ADS instance is running in a Windows Active Directory (AD) domain. ADS 实例在 Windows Active Directory (AD) 域中运行。 All ADS users are granted access based on their AD user account.所有 ADS 用户都根据其 AD 用户帐户获得访问权限。 Each AD user account specifies their intranet email address.每个 AD 用户帐户指定其内网 email 地址。

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.我需要在每个月的第一个星期一向“分配给”人员的 AD email 地址发送通知,以获取特定项目中的特定用户故事。

The hard part is getting the @mention to resolve to the AD user account so that ADS sends the notification.困难的部分是让 @mention 解析为 AD 用户帐户,以便 ADS 发送通知。

How do I get ADS take my @mention and resolve it to an Active Directory user id?如何让 ADS 获取我的 @mention 并将其解析为 Active Directory 用户 ID?

See my MRE in my answer below在下面的答案中查看我的 MRE

These three SO items address aspects of the issue, but my minimal, reproducible example below pulls it all together into a sample working solution这三个 SO 项目解决了问题的各个方面,但我在下面的最小的、可重现的示例将它们全部整合到一个示例工作解决方案中

Past SO Q&A过去的 SO Q&A

Mentioning a user in the System.History (July, 2017) 在 System.History 中提及用户(2017 年 7 月)

VSTS - uploading via an excel macro and getting @mentions to work (March 2018) VSTS - 通过 excel 宏上传并让@提及工作(2018 年 3 月)

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

I decided to implement this requirement such that ADS sends the notification based on an @mention added programmatically like this:我决定实现此要求,以便 ADS 根据以编程方式添加的@mention 发送通知,如下所示:

  • On the ADS Application server, create a scheduled task that runs on the first of each month在 ADS 应用程序服务器上,创建在每个月的第一天运行的计划任务

  • 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.计划任务运行一个程序(C# + ADS REST api 控制台应用程序安装在应用服务器上),该程序定位相关用户故事,并以编程方式将@提及添加到用户故事的“分配给”用户帐户的新评论。 The program runs under a domain admin account that is also a "full control" ADS instance admin account.该程序在域管理员帐户下运行,该帐户也是“完全控制”的 ADS 实例管理员帐户。

My Minimum Reproducible Example我的最小可重现示例

Output Output

And, the email notification is sent as expected.并且,email 通知按预期发送。

在此处输入图像描述

Code代码

Program.cs程序.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 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 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; }
        }
    }
}

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM