PageRenderTime 46ms CodeModel.GetById 17ms RepoModel.GetById 1ms app.codeStats 0ms

/Plugins/BuildServerIntegration/TeamCityIntegration/TeamCityAdapter.cs

https://github.com/gitextensions/gitextensions
C# | 643 lines | 545 code | 93 blank | 5 comment | 48 complexity | ba388578d7ea7321e07f7668bcdc3605 MD5 | raw file
Possible License(s): GPL-3.0
  1. using System;
  2. using System.Collections.Generic;
  3. using System.ComponentModel.Composition;
  4. using System.Diagnostics;
  5. using System.Globalization;
  6. using System.IO;
  7. using System.Linq;
  8. using System.Net;
  9. using System.Net.Http;
  10. using System.Net.Http.Headers;
  11. using System.Reactive.Concurrency;
  12. using System.Reactive.Linq;
  13. using System.Text;
  14. using System.Text.RegularExpressions;
  15. using System.Threading;
  16. using System.Threading.Tasks;
  17. using System.Xml.Linq;
  18. using System.Xml.XPath;
  19. using GitCommands.Utils;
  20. using GitUI;
  21. using GitUIPluginInterfaces;
  22. using GitUIPluginInterfaces.BuildServerIntegration;
  23. using Microsoft;
  24. using Microsoft.VisualStudio.Threading;
  25. namespace TeamCityIntegration
  26. {
  27. [MetadataAttribute]
  28. [AttributeUsage(AttributeTargets.Class)]
  29. public class TeamCityIntegrationMetadataAttribute : BuildServerAdapterMetadataAttribute
  30. {
  31. public TeamCityIntegrationMetadataAttribute(string buildServerType)
  32. : base(buildServerType)
  33. {
  34. }
  35. public override string? CanBeLoaded
  36. {
  37. get
  38. {
  39. if (EnvUtils.IsNet4FullOrHigher())
  40. {
  41. return null;
  42. }
  43. else
  44. {
  45. return ".NET Framework 4 or higher required";
  46. }
  47. }
  48. }
  49. }
  50. [Export(typeof(IBuildServerAdapter))]
  51. [TeamCityIntegrationMetadata(PluginName)]
  52. [PartCreationPolicy(CreationPolicy.NonShared)]
  53. internal class TeamCityAdapter : IBuildServerAdapter
  54. {
  55. public const string PluginName = "TeamCity";
  56. private IBuildServerWatcher? _buildServerWatcher;
  57. private HttpClientHandler? _httpClientHandler;
  58. private HttpClient? _httpClient;
  59. private string? _httpClientHostSuffix;
  60. private readonly List<JoinableTask<IEnumerable<string>>> _getBuildTypesTask = new();
  61. private CookieContainer? _teamCityNtlmAuthCookie;
  62. private string? HostName { get; set; }
  63. private string[]? ProjectNames { get; set; }
  64. private Regex? BuildIdFilter { get; set; }
  65. private CookieContainer GetTeamCityNtlmAuthCookie(string serverUrl, IBuildServerCredentials? buildServerCredentials)
  66. {
  67. if (_teamCityNtlmAuthCookie is not null)
  68. {
  69. return _teamCityNtlmAuthCookie;
  70. }
  71. string url = serverUrl + "ntlmLogin.html";
  72. CookieContainer cookieContainer = new();
  73. var request = (HttpWebRequest)WebRequest.Create(url);
  74. request.CookieContainer = cookieContainer;
  75. if (buildServerCredentials is not null
  76. && !string.IsNullOrEmpty(buildServerCredentials.Username)
  77. && !string.IsNullOrEmpty(buildServerCredentials.Password))
  78. {
  79. request.Credentials = new NetworkCredential(buildServerCredentials.Username, buildServerCredentials.Password);
  80. }
  81. else
  82. {
  83. request.Credentials = CredentialCache.DefaultCredentials;
  84. }
  85. request.PreAuthenticate = true;
  86. request.GetResponse();
  87. _teamCityNtlmAuthCookie = cookieContainer;
  88. return _teamCityNtlmAuthCookie;
  89. }
  90. public string? LogAsGuestUrlParameter { get; set; }
  91. public void Initialize(IBuildServerWatcher buildServerWatcher, ISettingsSource config, Action openSettings, Func<ObjectId, bool>? isCommitInRevisionGrid = null)
  92. {
  93. if (_buildServerWatcher is not null)
  94. {
  95. throw new InvalidOperationException("Already initialized");
  96. }
  97. _buildServerWatcher = buildServerWatcher;
  98. ProjectNames = buildServerWatcher.ReplaceVariables(config.GetString("ProjectName", ""))
  99. .Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries);
  100. var buildIdFilerSetting = config.GetString("BuildIdFilter", "");
  101. if (!BuildServerSettingsHelper.IsRegexValid(buildIdFilerSetting))
  102. {
  103. return;
  104. }
  105. BuildIdFilter = new Regex(buildIdFilerSetting, RegexOptions.Compiled);
  106. HostName = config.GetString("BuildServerUrl", null);
  107. LogAsGuestUrlParameter = config.GetBool("LogAsGuest", false) ? "&guest=1" : string.Empty;
  108. if (!string.IsNullOrEmpty(HostName))
  109. {
  110. InitializeHttpClient(HostName);
  111. if (ProjectNames.Length > 0)
  112. {
  113. _getBuildTypesTask.Clear();
  114. foreach (var name in ProjectNames)
  115. {
  116. _getBuildTypesTask.Add(ThreadHelper.JoinableTaskFactory.RunAsync(async () =>
  117. {
  118. var response = await GetProjectFromNameXmlResponseAsync(name, CancellationToken.None).ConfigureAwait(false);
  119. return from element in response.XPathSelectElements("/project/buildTypes/buildType")
  120. select element.Attribute("id").Value;
  121. }));
  122. }
  123. }
  124. }
  125. }
  126. public void InitializeHttpClient(string hostname)
  127. {
  128. CreateNewHttpClient(hostname);
  129. UpdateHttpClientOptionsGuestAuth();
  130. }
  131. private void CreateNewHttpClient(string hostName)
  132. {
  133. _httpClientHandler = new HttpClientHandler();
  134. _httpClient = new HttpClient(_httpClientHandler)
  135. {
  136. Timeout = TimeSpan.FromMinutes(2),
  137. BaseAddress = hostName.Contains("://")
  138. ? new Uri(hostName, UriKind.Absolute)
  139. : new Uri(string.Format("{0}://{1}", Uri.UriSchemeHttp, hostName), UriKind.Absolute)
  140. };
  141. _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml"));
  142. }
  143. /// <summary>
  144. /// Gets a unique key which identifies this build server.
  145. /// </summary>
  146. public string UniqueKey
  147. {
  148. get
  149. {
  150. Validates.NotNull(_httpClient);
  151. return _httpClient.BaseAddress.Host;
  152. }
  153. }
  154. public IObservable<BuildInfo> GetFinishedBuildsSince(IScheduler scheduler, DateTime? sinceDate = null)
  155. {
  156. return GetBuilds(scheduler, sinceDate, false);
  157. }
  158. public IObservable<BuildInfo> GetRunningBuilds(IScheduler scheduler)
  159. {
  160. return GetBuilds(scheduler, null, true);
  161. }
  162. public IObservable<BuildInfo> GetBuilds(IScheduler scheduler, DateTime? sinceDate = null, bool? running = null)
  163. {
  164. if (_httpClient is null || _httpClient.BaseAddress is null || ProjectNames == null || ProjectNames.Length == 0)
  165. {
  166. return Observable.Empty<BuildInfo>(scheduler);
  167. }
  168. return Observable.Create<BuildInfo>((observer, cancellationToken) =>
  169. Task.Run(
  170. () => scheduler.Schedule(() => ObserveBuilds(sinceDate, running, observer, cancellationToken))));
  171. }
  172. private void ObserveBuilds(DateTime? sinceDate, bool? running, IObserver<BuildInfo> observer, CancellationToken cancellationToken)
  173. {
  174. try
  175. {
  176. if (_getBuildTypesTask.Any(task => PropagateTaskAnomalyToObserver(task.Task, observer)))
  177. {
  178. return;
  179. }
  180. Validates.NotNull(BuildIdFilter);
  181. var localObserver = observer;
  182. var buildTypes = _getBuildTypesTask.SelectMany(t => t.Join()).Where(id => BuildIdFilter.IsMatch(id));
  183. var buildIdTasks = buildTypes.Select(buildTypeId => GetFilteredBuildsXmlResponseAsync(buildTypeId, cancellationToken, sinceDate, running)).ToArray();
  184. Task.Factory
  185. .ContinueWhenAll(
  186. buildIdTasks,
  187. completedTasks =>
  188. {
  189. var buildIds = completedTasks.Where(task => task.Status == TaskStatus.RanToCompletion)
  190. .SelectMany(
  191. buildIdTask =>
  192. buildIdTask.CompletedResult()
  193. .XPathSelectElements("/builds/build")
  194. .Select(x => x.Attribute("id").Value))
  195. .ToArray();
  196. NotifyObserverOfBuilds(buildIds, observer, cancellationToken);
  197. },
  198. cancellationToken,
  199. TaskContinuationOptions.AttachedToParent | TaskContinuationOptions.ExecuteSynchronously,
  200. TaskScheduler.Current)
  201. .ContinueWith(
  202. task => localObserver.OnError(task.Exception),
  203. CancellationToken.None,
  204. TaskContinuationOptions.AttachedToParent | TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnFaulted,
  205. TaskScheduler.Current);
  206. }
  207. catch (OperationCanceledException)
  208. {
  209. // Do nothing, the observer is already stopped
  210. }
  211. catch (Exception ex)
  212. {
  213. observer.OnError(ex);
  214. }
  215. }
  216. private void NotifyObserverOfBuilds(string[] buildIds, IObserver<BuildInfo> observer, CancellationToken cancellationToken)
  217. {
  218. List<Task> tasks = new(8);
  219. var buildsLeft = buildIds.Length;
  220. foreach (var buildId in buildIds.OrderByDescending(int.Parse))
  221. {
  222. var notifyObserverTask =
  223. GetBuildFromIdXmlResponseAsync(buildId, cancellationToken)
  224. .ContinueWith(
  225. task =>
  226. {
  227. if (task.Status == TaskStatus.RanToCompletion)
  228. {
  229. var buildDetails = task.CompletedResult();
  230. var buildInfo = CreateBuildInfo(buildDetails);
  231. if (buildInfo.CommitHashList.Any())
  232. {
  233. observer.OnNext(buildInfo);
  234. }
  235. }
  236. },
  237. cancellationToken,
  238. TaskContinuationOptions.AttachedToParent | TaskContinuationOptions.ExecuteSynchronously,
  239. TaskScheduler.Current);
  240. tasks.Add(notifyObserverTask);
  241. --buildsLeft;
  242. if (tasks.Count == tasks.Capacity || buildsLeft == 0)
  243. {
  244. var batchTasks = tasks.ToArray();
  245. tasks.Clear();
  246. try
  247. {
  248. #pragma warning disable VSTHRD002
  249. Task.WaitAll(batchTasks, cancellationToken);
  250. #pragma warning restore VSTHRD002
  251. }
  252. catch (Exception e)
  253. {
  254. observer.OnError(e);
  255. return;
  256. }
  257. }
  258. }
  259. observer.OnCompleted();
  260. }
  261. private static bool PropagateTaskAnomalyToObserver(Task task, IObserver<BuildInfo> observer)
  262. {
  263. if (task.IsCanceled)
  264. {
  265. observer.OnCompleted();
  266. return true;
  267. }
  268. if (task.IsFaulted)
  269. {
  270. Debug.Assert(task.Exception is not null, "task.Exception is not null");
  271. observer.OnError(task.Exception);
  272. return true;
  273. }
  274. return false;
  275. }
  276. private BuildInfo CreateBuildInfo(XDocument buildXmlDocument)
  277. {
  278. var buildXElement = buildXmlDocument.Element("build");
  279. var idValue = buildXElement.Attribute("id").Value;
  280. var statusValue = buildXElement.Attribute("status").Value;
  281. var startDateText = buildXElement.Element("startDate").Value;
  282. var statusText = buildXElement.Element("statusText").Value;
  283. var webUrl = buildXElement.Attribute("webUrl").Value + LogAsGuestUrlParameter;
  284. var revisionsElements = buildXElement.XPathSelectElements("revisions/revision");
  285. var commitHashList = revisionsElements.Select(x => ObjectId.Parse(x.Attribute("version").Value)).ToList();
  286. var runningAttribute = buildXElement.Attribute("running");
  287. if (runningAttribute is not null && Convert.ToBoolean(runningAttribute.Value))
  288. {
  289. var runningInfoXElement = buildXElement.Element("running-info");
  290. var currentStageText = runningInfoXElement.Attribute("currentStageText").Value;
  291. statusText = currentStageText;
  292. }
  293. BuildInfo buildInfo = new()
  294. {
  295. Id = idValue,
  296. StartDate = DecodeJsonDateTime(startDateText),
  297. Status = ParseBuildStatus(statusValue),
  298. Description = statusText,
  299. CommitHashList = commitHashList,
  300. Url = webUrl
  301. };
  302. return buildInfo;
  303. }
  304. private static BuildInfo.BuildStatus ParseBuildStatus(string statusValue)
  305. {
  306. return statusValue switch
  307. {
  308. "SUCCESS" => BuildInfo.BuildStatus.Success,
  309. "FAILURE" => BuildInfo.BuildStatus.Failure,
  310. _ => BuildInfo.BuildStatus.Unknown
  311. };
  312. }
  313. private Task<Stream> GetStreamAsync(string restServicePath, CancellationToken cancellationToken)
  314. {
  315. cancellationToken.ThrowIfCancellationRequested();
  316. Validates.NotNull(_httpClient);
  317. return _httpClient.GetAsync(FormatRelativePath(restServicePath), HttpCompletionOption.ResponseHeadersRead, cancellationToken)
  318. .ContinueWith(
  319. #pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks
  320. task => GetStreamFromHttpResponseAsync(task, restServicePath, cancellationToken),
  321. #pragma warning restore VSTHRD003 // Avoid awaiting foreign Tasks
  322. cancellationToken,
  323. TaskContinuationOptions.AttachedToParent,
  324. TaskScheduler.Current)
  325. .Unwrap();
  326. }
  327. private Task<Stream> GetStreamFromHttpResponseAsync(Task<HttpResponseMessage> task, string restServicePath, CancellationToken cancellationToken)
  328. {
  329. if (!task.IsCompleted)
  330. {
  331. throw new InvalidOperationException($"Task in state '{task.Status}' was expected to be completed.");
  332. }
  333. bool retry = task.IsCanceled && !cancellationToken.IsCancellationRequested;
  334. bool unauthorized = task.Status == TaskStatus.RanToCompletion &&
  335. (task.CompletedResult().StatusCode == HttpStatusCode.Unauthorized || task.CompletedResult().StatusCode == HttpStatusCode.Forbidden);
  336. if (!retry)
  337. {
  338. if (task.CompletedResult().IsSuccessStatusCode)
  339. {
  340. var httpContent = task.CompletedResult().Content;
  341. if (httpContent.Headers.ContentType.MediaType == "text/html")
  342. {
  343. // TeamCity responds with an HTML login page when guest access is denied.
  344. unauthorized = true;
  345. }
  346. else
  347. {
  348. return httpContent.ReadAsStreamAsync();
  349. }
  350. }
  351. }
  352. if (retry)
  353. {
  354. return GetStreamAsync(restServicePath, cancellationToken);
  355. }
  356. if (unauthorized)
  357. {
  358. Validates.NotNull(_buildServerWatcher);
  359. var buildServerCredentials = _buildServerWatcher.GetBuildServerCredentials(this, true);
  360. var useBuildServerCredentials = buildServerCredentials is not null
  361. && !buildServerCredentials.UseGuestAccess
  362. && (string.IsNullOrWhiteSpace(buildServerCredentials.Username) && string.IsNullOrWhiteSpace(buildServerCredentials.Password));
  363. if (useBuildServerCredentials)
  364. {
  365. UpdateHttpClientOptionsCredentialsAuth(buildServerCredentials!);
  366. return GetStreamAsync(restServicePath, cancellationToken);
  367. }
  368. else
  369. {
  370. UpdateHttpClientOptionsNtlmAuth(buildServerCredentials);
  371. return GetStreamAsync(restServicePath, cancellationToken);
  372. }
  373. }
  374. throw new HttpRequestException(task.CompletedResult().ReasonPhrase);
  375. }
  376. public void UpdateHttpClientOptionsNtlmAuth(IBuildServerCredentials? buildServerCredentials)
  377. {
  378. try
  379. {
  380. Validates.NotNull(_httpClient);
  381. Validates.NotNull(_httpClientHandler);
  382. Validates.NotNull(HostName);
  383. _httpClient.Dispose();
  384. _httpClientHandler.Dispose();
  385. _httpClientHostSuffix = "httpAuth";
  386. CreateNewHttpClient(HostName);
  387. _httpClientHandler.CookieContainer = GetTeamCityNtlmAuthCookie(_httpClient.BaseAddress.AbsoluteUri, buildServerCredentials);
  388. }
  389. catch (Exception exception)
  390. {
  391. Console.WriteLine(exception);
  392. throw;
  393. }
  394. }
  395. public void UpdateHttpClientOptionsGuestAuth()
  396. {
  397. Validates.NotNull(_httpClient);
  398. _httpClientHostSuffix = "guestAuth";
  399. _httpClient.DefaultRequestHeaders.Authorization = null;
  400. }
  401. private void UpdateHttpClientOptionsCredentialsAuth(IBuildServerCredentials buildServerCredentials)
  402. {
  403. Validates.NotNull(_httpClient);
  404. Validates.NotNull(buildServerCredentials.Username);
  405. Validates.NotNull(buildServerCredentials.Password);
  406. _httpClientHostSuffix = "httpAuth";
  407. _httpClient.DefaultRequestHeaders.Authorization = CreateBasicHeader(buildServerCredentials.Username, buildServerCredentials.Password);
  408. }
  409. private static AuthenticationHeaderValue CreateBasicHeader(string username, string password)
  410. {
  411. byte[] byteArray = Encoding.UTF8.GetBytes(string.Format("{0}:{1}", username, password));
  412. return new AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray));
  413. }
  414. private Task<XDocument> GetXmlResponseAsync(string relativePath, CancellationToken cancellationToken)
  415. {
  416. var getStreamTask = GetStreamAsync(relativePath, cancellationToken);
  417. return getStreamTask.ContinueWith(
  418. task =>
  419. {
  420. using var responseStream = task.Result;
  421. return XDocument.Load(responseStream);
  422. },
  423. cancellationToken,
  424. TaskContinuationOptions.AttachedToParent,
  425. TaskScheduler.Current);
  426. }
  427. private Uri FormatRelativePath(string restServicePath)
  428. {
  429. return new Uri(string.Format("{0}/app/rest/{1}", _httpClientHostSuffix, restServicePath), UriKind.Relative);
  430. }
  431. private Task<XDocument> GetBuildFromIdXmlResponseAsync(string buildId, CancellationToken cancellationToken)
  432. {
  433. return GetXmlResponseAsync(string.Format("builds/id:{0}", buildId), cancellationToken);
  434. }
  435. private Task<XDocument> GetBuildTypeFromIdXmlResponseAsync(string buildId, CancellationToken cancellationToken)
  436. {
  437. return GetXmlResponseAsync(string.Format("buildTypes/id:{0}", buildId), cancellationToken);
  438. }
  439. private Task<XDocument> GetProjectFromNameXmlResponseAsync(string projectName, CancellationToken cancellationToken)
  440. {
  441. return GetXmlResponseAsync(string.Format("projects/{0}", projectName), cancellationToken);
  442. }
  443. private Task<XDocument> GetProjectsResponseAsync(CancellationToken cancellationToken)
  444. {
  445. return GetXmlResponseAsync("projects", cancellationToken);
  446. }
  447. private Task<XDocument> GetFilteredBuildsXmlResponseAsync(string buildTypeId, CancellationToken cancellationToken, DateTime? sinceDate = null, bool? running = null)
  448. {
  449. List<string> values = new() { "branch:(default:any)" };
  450. if (sinceDate.HasValue)
  451. {
  452. values.Add(string.Format("sinceDate:{0}", FormatJsonDate(sinceDate.Value)));
  453. }
  454. if (running.HasValue)
  455. {
  456. values.Add(string.Format("running:{0}", running.Value.ToString(CultureInfo.InvariantCulture)));
  457. }
  458. string buildLocator = string.Join(",", values);
  459. var url = string.Format("buildTypes/id:{0}/builds/?locator={1}", buildTypeId, buildLocator);
  460. var filteredBuildsXmlResponseTask = GetXmlResponseAsync(url, cancellationToken);
  461. return filteredBuildsXmlResponseTask;
  462. }
  463. private static DateTime DecodeJsonDateTime(string dateTimeString)
  464. {
  465. var dateTime = new DateTime(
  466. int.Parse(dateTimeString.Substring(0, 4)),
  467. int.Parse(dateTimeString.Substring(4, 2)),
  468. int.Parse(dateTimeString.Substring(6, 2)),
  469. int.Parse(dateTimeString.Substring(9, 2)),
  470. int.Parse(dateTimeString.Substring(11, 2)),
  471. int.Parse(dateTimeString.Substring(13, 2)),
  472. DateTimeKind.Utc)
  473. .AddHours(int.Parse(dateTimeString.Substring(15, 3)))
  474. .AddMinutes(int.Parse(dateTimeString.Substring(15, 1) + dateTimeString.Substring(18, 2)));
  475. return dateTime;
  476. }
  477. private static string FormatJsonDate(DateTime dateTime)
  478. {
  479. return dateTime.ToUniversalTime().ToString("yyyyMMdd'T'HHmmss-0000", CultureInfo.InvariantCulture).Replace(":", string.Empty);
  480. }
  481. public void Dispose()
  482. {
  483. GC.SuppressFinalize(this);
  484. _httpClient?.Dispose();
  485. }
  486. public Project? GetProjectsTree()
  487. {
  488. var projectsRootElement = ThreadHelper.JoinableTaskFactory.Run(() => GetProjectsResponseAsync(CancellationToken.None));
  489. var projects = projectsRootElement.Root.Elements().Where(e => (string)e.Attribute("archived") != "true").Select(e => new Project
  490. {
  491. Id = (string)e.Attribute("id"),
  492. Name = (string)e.Attribute("name"),
  493. ParentProject = (string)e.Attribute("parentProjectId"),
  494. SubProjects = new List<Project>()
  495. }).ToList();
  496. var projectDictionary = projects.ToDictionary(p => p.Id, p => p);
  497. Project? rootProject = null;
  498. foreach (var project in projects)
  499. {
  500. if (project.ParentProject is not null)
  501. {
  502. Project parentProject = projectDictionary[project.ParentProject];
  503. Validates.NotNull(parentProject.SubProjects);
  504. parentProject.SubProjects.Add(project);
  505. }
  506. else
  507. {
  508. rootProject = project;
  509. }
  510. }
  511. return rootProject;
  512. }
  513. public List<Build> GetProjectBuilds(string projectId)
  514. {
  515. var projectsRootElement = ThreadHelper.JoinableTaskFactory.Run(() => GetProjectFromNameXmlResponseAsync(projectId, CancellationToken.None));
  516. return projectsRootElement.Root.Element("buildTypes").Elements().Select(e => new Build
  517. {
  518. Id = (string)e.Attribute("id"),
  519. Name = (string)e.Attribute("name"),
  520. ParentProject = (string)e.Attribute("projectId")
  521. }).ToList();
  522. }
  523. public Build GetBuildType(string buildId)
  524. {
  525. var projectsRootElement = ThreadHelper.JoinableTaskFactory.Run(() => GetBuildTypeFromIdXmlResponseAsync(buildId, CancellationToken.None));
  526. var buildType = projectsRootElement.Root;
  527. return new Build
  528. {
  529. Id = buildId,
  530. Name = (string)buildType.Attribute("name"),
  531. ParentProject = (string)buildType.Attribute("projectId")
  532. };
  533. }
  534. }
  535. public class Project
  536. {
  537. public string? Id { get; set; }
  538. public string? Name { get; set; }
  539. public string? ParentProject { get; set; }
  540. public IList<Project>? SubProjects { get; set; }
  541. public IList<Build>? Builds { get; set; }
  542. }
  543. public class Build
  544. {
  545. public string? ParentProject { get; set; }
  546. public string? Id { get; set; }
  547. public string? Name { get; set; }
  548. public string DisplayName => Name + " (" + Id + ")";
  549. }
  550. }