Skip to content

Instantly share code, notes, and snippets.

@MattJeanes
Created March 27, 2019 16:00
Show Gist options
  • Save MattJeanes/656567daf6c03fd405506a87df8f7671 to your computer and use it in GitHub Desktop.
Save MattJeanes/656567daf6c03fd405506a87df8f7671 to your computer and use it in GitHub Desktop.
Release Notes Helper for Azure DevOps
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.TeamFoundation.SourceControl.WebApi;
using Microsoft.TeamFoundation.WorkItemTracking.WebApi;
using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;
using Microsoft.VisualStudio.Services.WebApi;
using Microsoft.VisualStudio.Services.WebApi.Patch;
using Microsoft.VisualStudio.Services.WebApi.Patch.Json;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using VSTSIntegration.Model.Data;
namespace VSTSIntegration.Model.Helpers
{
public interface IReleaseNotesHelper
{
Task<ReleaseNotes> GetReleaseNotes(int pullRequestId, bool includeMerges = false, bool includeSprintBugs = false, bool updateStatusOnly = false);
}
public class ReleaseNotesHelper : IReleaseNotesHelper
{
const string mergeCacheKey = "Moneybarn.MergeCache";
private readonly Dictionary<string, bool> _mergeCache = new Dictionary<string, bool>();
private readonly AppSettings _appSettings;
private readonly ILogger _logger;
private readonly VssConnection _vssConnection;
private GitHttpClient __gitClient;
private async Task<GitHttpClient> GetGitClient()
{
return __gitClient ?? (__gitClient = await _vssConnection.GetClientAsync<GitHttpClient>());
}
private WorkItemTrackingHttpClient __workItemClient;
private async Task<WorkItemTrackingHttpClient> GetWorkItemClient()
{
return __workItemClient ?? (__workItemClient = await _vssConnection.GetClientAsync<WorkItemTrackingHttpClient>());
}
public ReleaseNotesHelper(VssConnection vssConnection, ILogger<ReleaseNotesHelper> logger, IOptions<AppSettings> appSettings)
{
_vssConnection = vssConnection;
_logger = logger;
_appSettings = appSettings.Value;
}
public async Task<ReleaseNotes> GetReleaseNotes(int pullRequestId, bool includeMerges = false, bool includeSprintBugs = false, bool updateStatusOnly = false)
{
var releaseNotes = new ReleaseNotes();
var gitClient = await GetGitClient();
var pullRequest = await gitClient.GetPullRequestByIdAsync(pullRequestId);
releaseNotes.PullRequest = pullRequest;
var iterations = await gitClient.GetPullRequestIterationsAsync(pullRequest.Repository.Id, pullRequestId);
var iteration = iterations.OrderByDescending(x => x.Id).FirstOrDefault();
var mergePolicyApplicable = IsMergePolicyApplicable(pullRequest);
if (pullRequest.Status == PullRequestStatus.Active)
{
if (mergePolicyApplicable)
{
await UpdateStatus(pullRequest, iteration, new MergePolicy
{
Description = "Pending",
State = GitStatusState.Pending
});
}
else
{
await UpdateStatus(pullRequest, iteration, new MergePolicy
{
Description = "N/A",
State = GitStatusState.NotApplicable
});
}
}
if (!updateStatusOnly || (pullRequest.Status == PullRequestStatus.Active && mergePolicyApplicable))
{
try
{
releaseNotes.Commits = await GetCommits(pullRequest, iteration, includeMerges);
releaseNotes.WorkItems = await GetWorkItems(releaseNotes.Commits, includeSprintBugs);
if (pullRequest.Status == PullRequestStatus.Active && mergePolicyApplicable)
{
releaseNotes.MergePolicy = GetMergePolicy(releaseNotes);
await UpdateStatus(pullRequest, iteration, releaseNotes.MergePolicy);
}
}
catch (Exception e)
{
if (pullRequest.Status == PullRequestStatus.Active)
{
try
{
await UpdateStatus(pullRequest, iteration, new MergePolicy
{
State = GitStatusState.Error,
Description = e.Message
});
}
catch (Exception e2)
{
_logger.LogError("Failed to log Error to pull request");
_logger.LogError(e2.ToString());
}
}
throw;
};
}
return releaseNotes;
}
private bool IsMergePolicyApplicable(GitPullRequest pullRequest)
{
return pullRequest.TargetRefName.StartsWith("refs/heads/master");
}
private MergePolicy GetMergePolicy(ReleaseNotes releaseNotes)
{
var states = new string[] { "Done", "Removed" };
if (releaseNotes?.WorkItems?.Any(x => !states.Contains(x.Fields["System.State"])) ?? false)
{
return new MergePolicy
{
Description = "Work items incomplete",
State = GitStatusState.Failed
};
}
else
{
return new MergePolicy
{
Description = "Work items completed",
State = GitStatusState.Succeeded
};
}
}
private async Task UpdateStatus(GitPullRequest pullRequest, GitPullRequestIteration iteration, MergePolicy mergePolicy)
{
var gitClient = await GetGitClient();
var status = new GitPullRequestStatus
{
Context = new GitStatusContext
{
Genre = "moneybarn",
Name = "workitems",
},
TargetUrl = $"{_appSettings.Url}/releasenotes/{pullRequest.PullRequestId}",
IterationId = iteration.Id,
State = mergePolicy.State,
Description = mergePolicy.Description
};
_logger.LogInformation($"Marking PR {pullRequest.PullRequestId} as {status.State.ToString()}");
await gitClient.CreatePullRequestStatusAsync(status, pullRequest.Repository.Id, pullRequest.PullRequestId);
}
private async Task<IOrderedEnumerable<GitCommitRef>> GetCommits(GitPullRequest pullRequest, GitPullRequestIteration iteration, bool includeMerges)
{
var gitClient = await GetGitClient();
var baseVersion = new GitVersionDescriptor
{
Version = iteration.TargetRefCommit?.CommitId ?? throw new Exception("Merge incomplete"),
VersionType = GitVersionType.Commit
};
var targetVersion = new GitVersionDescriptor
{
Version = iteration.SourceRefCommit?.CommitId ?? throw new Exception("Merge incomplete"),
VersionType = GitVersionType.Commit
};
var searchCriteria = new GitQueryCommitsCriteria
{
ItemVersion = baseVersion,
CompareVersion = targetVersion,
IncludeWorkItems = true
};
var top = 100;
var skip = 0;
var commits = new List<GitCommitRef>();
var properties = await gitClient.GetPullRequestPropertiesAsync(pullRequest.Repository.Id, pullRequest.PullRequestId);
var mergeCache = properties.ContainsKey(mergeCacheKey)
? JsonConvert.DeserializeObject<Dictionary<string, bool>>(properties[mergeCacheKey].ToString())
: new Dictionary<string, bool>();
mergeCache.ToList().ForEach(x => _mergeCache[x.Key] = x.Value);
var mergeCacheModified = false;
while (true)
{
var batch = await gitClient.GetCommitsBatchAsync(searchCriteria, pullRequest.Repository.Id, skip, top);
if (batch.Count == 0) { break; }
if (!includeMerges)
{
foreach (var commitRef in batch)
{
if (!mergeCache.ContainsKey(commitRef.CommitId))
{
if (_mergeCache.ContainsKey(commitRef.CommitId))
{
mergeCache[commitRef.CommitId] = _mergeCache[commitRef.CommitId];
}
else
{
// Unfortunately GetCommitsBatch does not return parent info so we must query one-by-one :(
var commit = await gitClient.GetCommitAsync(commitRef.CommitId, pullRequest.Repository.Id);
mergeCache[commitRef.CommitId] = commit.Parents.Count() > 1;
_mergeCache[commitRef.CommitId] = mergeCache[commitRef.CommitId];
}
mergeCacheModified = true;
}
}
batch.RemoveAll(x => _mergeCache[x.CommitId]);
}
commits.AddRange(batch);
skip += top;
}
if (mergeCacheModified && pullRequest.Status == PullRequestStatus.Active)
{
var patch = new JsonPatchDocument
{
new JsonPatchOperation
{
Operation = Operation.Replace,
Path = $"/{mergeCacheKey}",
Value = JsonConvert.SerializeObject(mergeCache)
}
};
await gitClient.UpdatePullRequestPropertiesAsync(patch, pullRequest.Repository.Id, pullRequest.PullRequestId);
}
return commits.OrderByDescending(x => x.Author.Date);
}
private async Task<IOrderedEnumerable<WorkItem>> GetWorkItems(IEnumerable<GitCommitRef> commits, bool includeSprintBugs)
{
var workItemClient = await GetWorkItemClient();
var workItemRefs = commits.SelectMany(x => x.WorkItems).ToList();
var workItemIds = workItemRefs.Select(x => int.Parse(x.Id)).Distinct().ToList();
if (workItemIds.Count > 0)
{
var workItems = await workItemClient.GetWorkItemsAsync(workItemIds, expand: WorkItemExpand.All);
if (!includeSprintBugs)
{
workItems.RemoveAll(x => x.Fields["System.WorkItemType"].ToString() == "Bug"
&& x.Fields.ContainsKey("MBScrum.BugSource")
&& !new string[] { "", "Unknown", "Production" }.Contains(x.Fields["MBScrum.BugSource"].ToString()));
}
return workItems.OrderByDescending(x => x.Id);
}
return null;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment