PageRenderTime 51ms CodeModel.GetById 24ms RepoModel.GetById 1ms app.codeStats 0ms

/Plugins/BuildServerIntegration/AppVeyorIntegration/AppVeyorAdapter.cs

https://github.com/gitextensions/gitextensions
C# | 512 lines | 417 code | 74 blank | 21 comment | 46 complexity | 138dd16609a4fbbd8d22b0bbc3614870 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.IO;
  5. using System.Linq;
  6. using System.Net.Http;
  7. using System.Net.Http.Headers;
  8. using System.Reactive.Concurrency;
  9. using System.Reactive.Linq;
  10. using System.Threading;
  11. using System.Threading.Tasks;
  12. using GitCommands.Utils;
  13. using GitUI;
  14. using GitUIPluginInterfaces;
  15. using GitUIPluginInterfaces.BuildServerIntegration;
  16. using Microsoft;
  17. using Newtonsoft.Json.Linq;
  18. namespace AppVeyorIntegration
  19. {
  20. [MetadataAttribute]
  21. [AttributeUsage(AttributeTargets.Class)]
  22. public class AppVeyorIntegrationMetadata : BuildServerAdapterMetadataAttribute
  23. {
  24. public AppVeyorIntegrationMetadata(string buildServerType)
  25. : base(buildServerType)
  26. {
  27. }
  28. public override string? CanBeLoaded
  29. {
  30. get
  31. {
  32. if (EnvUtils.IsNet4FullOrHigher())
  33. {
  34. return null;
  35. }
  36. return ".NET Framework 4 or higher required";
  37. }
  38. }
  39. }
  40. [Export(typeof(IBuildServerAdapter))]
  41. [AppVeyorIntegrationMetadata(PluginName)]
  42. [PartCreationPolicy(CreationPolicy.NonShared)]
  43. internal class AppVeyorAdapter : IBuildServerAdapter
  44. {
  45. public const string PluginName = "AppVeyor";
  46. private const uint ProjectsToRetrieveCount = 25;
  47. private const string WebSiteUrl = "https://ci.appveyor.com";
  48. private const string ApiBaseUrl = WebSiteUrl + "/api/projects/";
  49. private IBuildServerWatcher? _buildServerWatcher;
  50. private HttpClient? _httpClientAppVeyor;
  51. private List<AppVeyorBuildInfo>? _allBuilds = new();
  52. private HashSet<ObjectId>? _fetchBuilds;
  53. private Func<ObjectId, bool>? _isCommitInRevisionGrid;
  54. private bool _shouldLoadTestResults;
  55. public void Initialize(
  56. IBuildServerWatcher buildServerWatcher,
  57. ISettingsSource config,
  58. Action openSettings,
  59. Func<ObjectId, bool>? isCommitInRevisionGrid = null)
  60. {
  61. if (_buildServerWatcher is not null)
  62. {
  63. throw new InvalidOperationException("Already initialized");
  64. }
  65. _buildServerWatcher = buildServerWatcher;
  66. _isCommitInRevisionGrid = isCommitInRevisionGrid;
  67. string? accountName = config.GetString("AppVeyorAccountName", null);
  68. string? accountToken = config.GetString("AppVeyorAccountToken", null);
  69. _shouldLoadTestResults = config.GetBool("AppVeyorLoadTestsResults", false);
  70. _fetchBuilds = new HashSet<ObjectId>();
  71. _httpClientAppVeyor = GetHttpClient(WebSiteUrl, accountToken);
  72. // projectId has format accountName/repoName
  73. // accountName may be any accessible project (for instance upstream)
  74. // if AppVeyorAccountName is set, projectNamesSetting may exclude the accountName part
  75. string projectNamesSetting = config.GetString("AppVeyorProjectName", "");
  76. var projectNames = _buildServerWatcher.ReplaceVariables(projectNamesSetting)
  77. .Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)
  78. .Where(p => p.Contains("/") || !string.IsNullOrEmpty(accountName))
  79. .Select(p => p.Contains("/") ? p : accountName.Combine("/", p)!)
  80. .ToList();
  81. if (projectNames.Count == 0)
  82. {
  83. if (string.IsNullOrWhiteSpace(accountName) || string.IsNullOrWhiteSpace(accountToken))
  84. {
  85. // No projectIds in settings, cannot query
  86. return;
  87. }
  88. // No settings, query projects for this account
  89. ThreadHelper.JoinableTaskFactory.Run(
  90. async () =>
  91. {
  92. // v2 tokens requires a separate prefix
  93. // (Documentation specifies that this is applicable for all requests, not the case though)
  94. string apiBaseUrl = !string.IsNullOrWhiteSpace(accountName) && !string.IsNullOrWhiteSpace(accountToken) && accountToken.StartsWith("v2.")
  95. ? $"{WebSiteUrl}/api/account/{accountName}/projects/"
  96. : ApiBaseUrl;
  97. // get the project ids for this account - no possibility to check if they are for the current repo
  98. var result = await GetResponseAsync(_httpClientAppVeyor, apiBaseUrl, CancellationToken.None).ConfigureAwait(false);
  99. if (string.IsNullOrWhiteSpace(result))
  100. {
  101. return;
  102. }
  103. foreach (var project in JArray.Parse(result))
  104. {
  105. // "slug" and "name" are normally the same
  106. var repoName = project["slug"].ToString();
  107. var projectId = accountName.Combine("/", repoName)!;
  108. projectNames.Add(projectId);
  109. }
  110. });
  111. }
  112. _allBuilds = FilterBuilds(projectNames.SelectMany(project => QueryBuildsResults(project)));
  113. return;
  114. static HttpClient GetHttpClient(string baseUrl, string? accountToken)
  115. {
  116. HttpClient httpClient = new(new HttpClientHandler { UseDefaultCredentials = true })
  117. {
  118. Timeout = TimeSpan.FromMinutes(2),
  119. BaseAddress = new Uri(baseUrl, UriKind.Absolute),
  120. };
  121. httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
  122. if (!string.IsNullOrWhiteSpace(accountToken))
  123. {
  124. httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accountToken);
  125. }
  126. return httpClient;
  127. }
  128. List<AppVeyorBuildInfo> FilterBuilds(IEnumerable<AppVeyorBuildInfo> allBuilds)
  129. {
  130. List<AppVeyorBuildInfo> filteredBuilds = new();
  131. foreach (var build in allBuilds.OrderByDescending(b => b.StartDate))
  132. {
  133. Validates.NotNull(build.CommitId);
  134. if (!_fetchBuilds.Contains(build.CommitId))
  135. {
  136. filteredBuilds.Add(build);
  137. _fetchBuilds.Add(build.CommitId);
  138. }
  139. }
  140. return filteredBuilds;
  141. }
  142. }
  143. private IEnumerable<AppVeyorBuildInfo> QueryBuildsResults(string projectId)
  144. {
  145. try
  146. {
  147. Validates.NotNull(_httpClientAppVeyor);
  148. return ThreadHelper.JoinableTaskFactory.Run(
  149. async () =>
  150. {
  151. string queryUrl = $"{ApiBaseUrl}{projectId}/history?recordsNumber={ProjectsToRetrieveCount}";
  152. var result = await GetResponseAsync(_httpClientAppVeyor, queryUrl, CancellationToken.None).ConfigureAwait(false);
  153. return ExtractBuildInfo(projectId, result);
  154. });
  155. }
  156. catch
  157. {
  158. return Enumerable.Empty<AppVeyorBuildInfo>();
  159. }
  160. }
  161. internal IEnumerable<AppVeyorBuildInfo> ExtractBuildInfo(string projectId, string? result)
  162. {
  163. if (string.IsNullOrWhiteSpace(result))
  164. {
  165. return Enumerable.Empty<AppVeyorBuildInfo>();
  166. }
  167. Validates.NotNull(_isCommitInRevisionGrid);
  168. var content = JObject.Parse(result);
  169. var projectData = content["project"];
  170. var repositoryName = projectData["repositoryName"];
  171. var repositoryType = projectData["repositoryType"];
  172. var builds = content["builds"].Children();
  173. var baseWebUrl = $"{WebSiteUrl}/project/{projectId}/build/";
  174. var baseApiUrl = $"{ApiBaseUrl}{projectId}/";
  175. List<AppVeyorBuildInfo> buildDetails = new();
  176. foreach (var b in builds)
  177. {
  178. try
  179. {
  180. if (!ObjectId.TryParse((b["pullRequestHeadCommitId"] ?? b["commitId"]).ToObject<string>(),
  181. out var objectId) || !_isCommitInRevisionGrid(objectId))
  182. {
  183. continue;
  184. }
  185. var pullRequestId = b["pullRequestId"];
  186. var version = b["version"].ToObject<string>();
  187. var status = ParseBuildStatus(b["status"].ToObject<string>());
  188. long? duration = null;
  189. if (status is (BuildInfo.BuildStatus.Success or BuildInfo.BuildStatus.Failure))
  190. {
  191. duration = GetBuildDuration(b);
  192. }
  193. var pullRequestTitle = b["pullRequestName"];
  194. buildDetails.Add(new AppVeyorBuildInfo
  195. {
  196. Id = version,
  197. BuildId = b["buildId"].ToObject<string>(),
  198. Branch = b["branch"].ToObject<string>(),
  199. CommitId = objectId,
  200. CommitHashList = new[] { objectId },
  201. Status = status,
  202. StartDate = b["started"]?.ToObject<DateTime>() ?? DateTime.MinValue,
  203. BaseWebUrl = baseWebUrl,
  204. Url = baseWebUrl + version,
  205. PullRequestUrl = repositoryType is not null && repositoryName is not null && pullRequestId is not null
  206. ? BuildPullRequetUrl(repositoryType.Value<string>(), repositoryName.Value<string>(),
  207. pullRequestId.Value<string>())
  208. : null,
  209. BaseApiUrl = baseApiUrl,
  210. AppVeyorBuildReportUrl = baseApiUrl + "build/" + version,
  211. PullRequestText = pullRequestId is not null ? "PR#" + pullRequestId.Value<string>() : string.Empty,
  212. PullRequestTitle = pullRequestTitle is not null ? pullRequestTitle.Value<string>() : string.Empty,
  213. Duration = duration,
  214. TestsResultText = string.Empty
  215. });
  216. }
  217. catch (Exception)
  218. {
  219. // Failure on reading data on a build detail should not prevent to display the others build results
  220. }
  221. }
  222. return buildDetails;
  223. static string? BuildPullRequetUrl(string repositoryType, string repositoryName, string pullRequestId)
  224. {
  225. return repositoryType.ToLowerInvariant() switch
  226. {
  227. "bitbucket" => $"https://bitbucket.org/{repositoryName}/pull-requests/{pullRequestId}",
  228. "github" => $"https://github.com/{repositoryName}/pull/{pullRequestId}",
  229. "gitlab" => $"https://gitlab.com/{repositoryName}/merge_requests/{pullRequestId}",
  230. "vso" => null,
  231. "git" => null,
  232. _ => null
  233. };
  234. }
  235. }
  236. /// <summary>
  237. /// Gets a unique key which identifies this build server.
  238. /// </summary>
  239. public string UniqueKey
  240. {
  241. get
  242. {
  243. Validates.NotNull(_httpClientAppVeyor);
  244. return _httpClientAppVeyor.BaseAddress.Host;
  245. }
  246. }
  247. public IObservable<BuildInfo> GetFinishedBuildsSince(IScheduler scheduler, DateTime? sinceDate = null)
  248. {
  249. // AppVeyor api is different than TeamCity one and all build results are fetch in one call without
  250. // filter parameters possible (so this call is useless!)
  251. return Observable.Empty<BuildInfo>();
  252. }
  253. public IObservable<BuildInfo> GetRunningBuilds(IScheduler scheduler)
  254. {
  255. return GetBuilds(scheduler);
  256. }
  257. private IObservable<BuildInfo> GetBuilds(IScheduler scheduler)
  258. {
  259. return Observable.Create<BuildInfo>((observer, cancellationToken) =>
  260. Task.Run(
  261. () => scheduler.Schedule(() => ObserveBuilds(observer, cancellationToken))));
  262. }
  263. private void ObserveBuilds(IObserver<BuildInfo> observer, CancellationToken cancellationToken)
  264. {
  265. try
  266. {
  267. if (_allBuilds is null)
  268. {
  269. // builds are updated, no requery for new builds
  270. return;
  271. }
  272. // Display all builds found
  273. foreach (var build in _allBuilds)
  274. {
  275. // Update finished build with tests results
  276. if (_shouldLoadTestResults
  277. && (build.Status == BuildInfo.BuildStatus.Success
  278. || build.Status == BuildInfo.BuildStatus.Failure))
  279. {
  280. UpdateDescription(build, cancellationToken);
  281. }
  282. UpdateDisplay(observer, build);
  283. }
  284. // Manage in progress builds...
  285. var inProgressBuilds = _allBuilds.Where(b => b.Status == BuildInfo.BuildStatus.InProgress).ToList();
  286. // Reset current build list - refresh required to see new builds
  287. _allBuilds = null;
  288. while (inProgressBuilds.Any() && !cancellationToken.IsCancellationRequested)
  289. {
  290. const int inProgressRefresh = 10000;
  291. Thread.Sleep(inProgressRefresh);
  292. foreach (var build in inProgressBuilds)
  293. {
  294. UpdateDescription(build, cancellationToken);
  295. UpdateDisplay(observer, build);
  296. }
  297. inProgressBuilds = inProgressBuilds.Where(b => b.Status == BuildInfo.BuildStatus.InProgress).ToList();
  298. }
  299. observer.OnCompleted();
  300. }
  301. catch (OperationCanceledException)
  302. {
  303. // Do nothing, the observer is already stopped
  304. }
  305. catch (Exception ex)
  306. {
  307. observer.OnError(ex);
  308. }
  309. }
  310. private void UpdateDisplay(IObserver<BuildInfo> observer, AppVeyorBuildInfo build)
  311. {
  312. build.UpdateDescription();
  313. observer.OnNext(build);
  314. }
  315. private void UpdateDescription(AppVeyorBuildInfo buildDetails, CancellationToken cancellationToken)
  316. {
  317. var buildDetailsParsed = ThreadHelper.JoinableTaskFactory.Run(() => FetchBuildDetailsManagingVersionUpdateAsync(buildDetails, cancellationToken));
  318. if (buildDetailsParsed is null)
  319. {
  320. return;
  321. }
  322. var buildData = buildDetailsParsed["build"];
  323. var buildDescription = buildData["jobs"].Last();
  324. var status = buildDescription["status"].ToObject<string>();
  325. buildDetails.Status = ParseBuildStatus(status);
  326. buildDetails.ChangeProgressCounter();
  327. if (!buildDetails.IsRunning)
  328. {
  329. buildDetails.Duration = GetBuildDuration(buildData);
  330. }
  331. int testCount = buildDescription["testsCount"].ToObject<int>();
  332. if (testCount != 0)
  333. {
  334. int failedTestCount = buildDescription["failedTestsCount"].ToObject<int>();
  335. int skippedTestCount = testCount - buildDescription["passedTestsCount"].ToObject<int>();
  336. var testResults = testCount + " tests";
  337. if (failedTestCount != 0 || skippedTestCount != 0)
  338. {
  339. testResults += $" ( {failedTestCount} failed, {skippedTestCount} skipped )";
  340. }
  341. buildDetails.TestsResultText = testResults;
  342. }
  343. }
  344. private long GetBuildDuration(JToken buildData)
  345. {
  346. var startTime = (buildData["started"] ?? buildData["created"])?.ToObject<DateTime>();
  347. var updateTime = buildData["updated"]?.ToObject<DateTime>();
  348. if (!startTime.HasValue || !updateTime.HasValue)
  349. {
  350. return 0;
  351. }
  352. return (long)(updateTime.Value - startTime.Value).TotalMilliseconds;
  353. }
  354. private async Task<JObject> FetchBuildDetailsManagingVersionUpdateAsync(AppVeyorBuildInfo buildDetails, CancellationToken cancellationToken)
  355. {
  356. Validates.NotNull(_httpClientAppVeyor);
  357. try
  358. {
  359. Validates.NotNull(buildDetails.AppVeyorBuildReportUrl);
  360. return JObject.Parse(await GetResponseAsync(_httpClientAppVeyor, buildDetails.AppVeyorBuildReportUrl, cancellationToken).ConfigureAwait(false));
  361. }
  362. catch
  363. {
  364. var buildHistoryUrl = buildDetails.BaseApiUrl + "/history?recordsNumber=1&startBuildId=" + (int.Parse(buildDetails.BuildId) + 1);
  365. var builds = JObject.Parse(await GetResponseAsync(_httpClientAppVeyor, buildHistoryUrl, cancellationToken).ConfigureAwait(false));
  366. var version = builds["builds"][0]["version"].ToObject<string>();
  367. buildDetails.Id = version;
  368. buildDetails.AppVeyorBuildReportUrl = buildDetails.BaseApiUrl + "/build/" + version;
  369. buildDetails.Url = buildDetails.BaseWebUrl + version;
  370. return JObject.Parse(await GetResponseAsync(_httpClientAppVeyor, buildDetails.AppVeyorBuildReportUrl, cancellationToken).ConfigureAwait(false));
  371. }
  372. }
  373. private static BuildInfo.BuildStatus ParseBuildStatus(string statusValue)
  374. {
  375. return statusValue switch
  376. {
  377. "success" => BuildInfo.BuildStatus.Success,
  378. "failed" => BuildInfo.BuildStatus.Failure,
  379. "cancelled" => BuildInfo.BuildStatus.Stopped,
  380. "queued" or "running" => BuildInfo.BuildStatus.InProgress,
  381. _ => BuildInfo.BuildStatus.Unknown
  382. };
  383. }
  384. private Task<Stream?> GetStreamAsync(HttpClient httpClient, string restServicePath, CancellationToken cancellationToken)
  385. {
  386. cancellationToken.ThrowIfCancellationRequested();
  387. return httpClient.GetAsync(restServicePath, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
  388. .ContinueWith(
  389. #pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks
  390. task => GetStreamFromHttpResponseAsync(httpClient, task, restServicePath, cancellationToken),
  391. #pragma warning restore VSTHRD003 // Avoid awaiting foreign Tasks
  392. cancellationToken,
  393. restServicePath.Contains("github") ? TaskContinuationOptions.None : TaskContinuationOptions.AttachedToParent,
  394. TaskScheduler.Current)
  395. .Unwrap();
  396. }
  397. private Task<Stream?> GetStreamFromHttpResponseAsync(HttpClient httpClient, Task<HttpResponseMessage> task, string restServicePath, CancellationToken cancellationToken)
  398. {
  399. var retry = task.IsCanceled && !cancellationToken.IsCancellationRequested;
  400. if (retry)
  401. {
  402. return GetStreamAsync(httpClient, restServicePath, cancellationToken);
  403. }
  404. if (task.Status == TaskStatus.RanToCompletion && task.CompletedResult().IsSuccessStatusCode)
  405. {
  406. return task.CompletedResult().Content.ReadAsStreamAsync();
  407. }
  408. return Task.FromResult<Stream?>(null);
  409. }
  410. private Task<string> GetResponseAsync(HttpClient httpClient, string relativePath, CancellationToken cancellationToken)
  411. {
  412. var getStreamTask = GetStreamAsync(httpClient, relativePath, cancellationToken);
  413. var taskContinuationOptions = relativePath.Contains("github") ? TaskContinuationOptions.None : TaskContinuationOptions.AttachedToParent;
  414. return getStreamTask.ContinueWith(
  415. task =>
  416. {
  417. if (task.Status != TaskStatus.RanToCompletion)
  418. {
  419. return string.Empty;
  420. }
  421. using var responseStream = task.Result;
  422. if (responseStream is null)
  423. {
  424. return "";
  425. }
  426. return new StreamReader(responseStream).ReadToEnd();
  427. },
  428. cancellationToken,
  429. taskContinuationOptions,
  430. TaskScheduler.Current);
  431. }
  432. public void Dispose()
  433. {
  434. GC.SuppressFinalize(this);
  435. _httpClientAppVeyor?.Dispose();
  436. }
  437. }
  438. }