以编程方式在本地 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 2019中的Analytics Widget-寻找Rest api和class libraby用于Analytics Widget