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

Posted

技术标签:

【中文标题】以编程方式在本地 Azure DevOps 服务器工作项注释中为 Active Directory 用户帐户添加 @提及(2021 年 1 月)【英文标题】:Programmatically add @mention for Active Directory User Account in on-prem Azure DevOps Server work item comment (Jan, 2021) 【发布时间】:2021-05-05 03:07:02 【问题描述】:

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

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

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

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

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

【问题讨论】:

【参考方案1】:

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

过去的 S.O.问答

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)

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

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

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

我的最小可重现示例

输出

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

代码

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:\n0", 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://***.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://***.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; 
        
    

【讨论】:

以上是关于以编程方式在本地 Azure DevOps 服务器工作项注释中为 Active Directory 用户帐户添加 @提及(2021 年 1 月)的主要内容,如果未能解决你的问题,请参考以下文章

Azure Devops OnPremise,致命:克隆 Git 存储库时身份验证失败

如何在 azure devops server 2019 的新工作项 Web 布局中编程工作项水平选项卡?

使用本地 TFS 发布管理器实施 Azure DevOps 服务

Azure DevOps Server:还原被删除的分支

Azure DevOps Server:还原被删除的分支

Azure devops Server 2019中的Analytics Widget-寻找Rest api和class libraby用于Analytics Widget