オンプレミスのAzureDevOps ServerワークアイテムコメントにActiveDirectoryユーザーアカウントの@メンションをプログラムで追加します(2021年1月)

ウィリアム・チャールトン

クローズドネットワークで実行されているAzureDevOps Server(ADS)2019 1.1(パッチ7)のオンプレミスインスタンスを管理しています。ADSインスタンスは、Windows Active Directory(AD)ドメインで実行されています。すべてのADSユーザーには、ADユーザーアカウントに基づいてアクセスが許可されます。各ADユーザーアカウントは、イントラネットの電子メールアドレスを指定します。

毎月第1月曜日に、特定のプロジェクトの特定のユーザーストーリーについて、「担当者」のADメールアドレスに通知を送信する必要があります。

難しいのは、@メンションをADユーザーアカウントに解決して、ADSが通知を送信するようにすることです。

ADSに@メンションを取得してActiveDirectoryユーザーIDに解決するにはどうすればよいですか?

以下の私の答えで私のMREを参照してください

ウィリアム・チャールトン

これらの3つのSO項目は問題の側面に対処しますが、以下の最小限の再現可能な例では、すべてをまとめてサンプルの作業ソリューションにまとめています。

過去のSOQ&A

System.History(2017年7月)でユーザーに言及する

VSTS-Excelマクロを介してアップロードし、@メンションを機能させる(2018年3月)

Azure DevOpsコメントのping(@)ユーザー(2019年10月)

この要件を実装して、ADSが次のようにプログラムで追加された@メンションに基づいて通知を送信するようにすることにしました。

  • ADSアプリケーションサーバーで、毎月1日に実行されるスケジュールされたタスクを作成します

  • スケジュールされたタスクは、関連するユーザーストーリーを検索し、ユーザーストーリーの「割り当て先」ユーザーアカウントの新しいコメントに@メンションをプログラムで追加するプログラム(アプリサーバーにインストールされた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:\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; }
        }
    }
}

この記事はインターネットから収集されたものであり、転載の際にはソースを示してください。

侵害の場合は、連絡してください[email protected]

編集
0

コメントを追加

0

関連記事

Related 関連記事

ホットタグ

アーカイブ