Skip to content

Instantly share code, notes, and snippets.

@jennings
Created October 22, 2024 20:06
Show Gist options
  • Save jennings/4cb0b228e6eacacad4e07ebc42d18de9 to your computer and use it in GitHub Desktop.
Save jennings/4cb0b228e6eacacad4e07ebc42d18de9 to your computer and use it in GitHub Desktop.
// <PackageReference Include="Microsoft.TeamFoundationServer.Client" Version="19.239.0-preview" />
// <PackageReference Include="Microsoft.VisualStudio.Services.InteractiveClient" Version="19.239.0-preview" />
public class RetargetPullRequests
{
private readonly ILogger _log;
private readonly AzureDevOpsConfiguration _config;
private readonly GitHttpClient _gitClient;
public RetargetPullRequests(ILogger<RetargetPullRequests> log, IOptions<AzureDevOpsConfiguration> config, VssConnection connection)
{
_log = log;
_config = config.Value;
_gitClient = connection.GetClient<GitHttpClient>();
}
[Function("RetargetPullRequests")]
public async Task Run([QueueTrigger("retarget-pull-requests")] QueueMessage message, FunctionContext context)
{
var cloudEvent = CloudEvent.Parse(message.Body);
if (cloudEvent == null)
{
_log.LogError("Received message body that was JSON but not a valid CloudEvent");
throw new InvalidOperationException("Cannot process message body");
}
_log.LogInformation("Received {EventType} event with ID {Id}", cloudEvent.Type, cloudEvent.Id);
if (cloudEvent.Type != "com.azure.dev.git.pullrequest.merged")
{
_log.LogInformation("Not interested in event");
return;
}
var webhook = cloudEvent.Data!.ToObjectFromJson<PullRequestMergedWebhook>();
await ExecuteAsync(webhook, default);
}
public async Task ExecuteAsync(PullRequestMergedWebhook webhook, CancellationToken cancellationToken)
{
if (!await ShouldTriggerRetargetingAsync(webhook, cancellationToken))
{
return;
}
await RetargetPullRequestsAsync(webhook, cancellationToken);
await DeleteSourceRefAsync(webhook, cancellationToken);
}
private async Task RetargetPullRequestsAsync(PullRequestMergedWebhook webhook, CancellationToken cancellationToken)
{
_log.LogInformation("Fetching pull requests targeting {TargetRefName}", webhook.Resource.SourceRefName);
var pullRequests = await _gitClient.GetPullRequestsByProjectAsync(webhook.Resource.Repository.Project.Id, new()
{
RepositoryId = webhook.Resource.Repository.Id,
Status = PullRequestStatus.Active,
TargetRefName = webhook.Resource.SourceRefName,
});
foreach (var pullRequest in pullRequests)
{
var update = new GitPullRequest()
{
TargetRefName = webhook.Resource.TargetRefName,
};
_log.LogInformation("Retargeting pull request {PullRequestId} from {CurrentTargetRefName} to {TargetRefName}", pullRequest.PullRequestId, pullRequest.TargetRefName, update.TargetRefName);
await _gitClient.UpdatePullRequestAsync(update, webhook.Resource.Repository.Id, pullRequest.PullRequestId, cancellationToken);
}
}
private async Task DeleteSourceRefAsync(PullRequestMergedWebhook webhook, CancellationToken cancellationToken)
{
const string DELETE_REF = "0000000000000000000000000000000000000000";
var update = new GitRefUpdate
{
Name = webhook.Resource.SourceRefName,
// Only delete the ref if it's still in the same position it was when the PR was merged
OldObjectId = webhook.Resource.LastMergeSourceCommit.CommitId,
NewObjectId = DELETE_REF,
};
try
{
var changes = await _gitClient.UpdateRefsAsync(new List<GitRefUpdate> { update }, webhook.Resource.Repository.Id, cancellationToken: cancellationToken);
foreach (var result in changes)
{
_log.Log(
result.Success ? LogLevel.Information : LogLevel.Warning,
"Update to ref {SourceRefName} Status={Status}: {Message}",
result.Name,
result.UpdateStatus,
result.CustomMessage
);
}
}
catch (Exception ex)
{
_log.LogError(ex, "Unable to delete source branch");
}
}
private async Task<bool> ShouldTriggerRetargetingAsync(PullRequestMergedWebhook webhook, CancellationToken cancellationToken)
{
// pull request is not merged yet
if (webhook.Resource.Status != "completed")
{
_log.LogInformation("Pull request {PullRequestId} is not completed, has status {Status}", webhook.Resource.PullRequestId, webhook.Resource.Status);
return false;
}
// source ref is a branch
if (!TryGetBranchName(webhook.Resource.SourceRefName, out var sourceBranchName))
{
_log.LogInformation("Merged ref {SourceRefName} is not a branch", webhook.Resource.SourceRefName);
return false;
}
// configured to delete source branch
var pullRequest = await _gitClient.GetPullRequestAsync(webhook.Resource.Repository.Id, webhook.Resource.PullRequestId, cancellationToken: cancellationToken);
if (!pullRequest.CompletionOptions.DeleteSourceBranch)
{
_log.LogInformation("Pull request {PullRequestId} option to delete source branch was not set", webhook.Resource.SourceRefName);
return false;
}
// source branch exists
try
{
var branch = await _gitClient.GetBranchAsync(webhook.Resource.Repository.Id, sourceBranchName, cancellationToken: cancellationToken);
_log.LogInformation("Branch {SourceBranchName} still exists", sourceBranchName);
return true;
}
catch (VssServiceResponseException ex)
{
_log.LogWarning(ex, "Exception while getting branch");
_log.LogInformation("Branch {SourceBranchName} no longer exists", sourceBranchName);
return false;
}
}
bool TryGetBranchName(string refName, [NotNullWhen(returnValue: true)] out string? branchName)
{
const string BRANCH_PREFIX = "refs/heads/";
if (refName.StartsWith(BRANCH_PREFIX))
{
branchName = refName.Substring(BRANCH_PREFIX.Length);
return true;
}
branchName = null;
return false;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment