Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle CodeFlow conflicts gracefully #4387

Merged
merged 29 commits into from
Jan 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
001dc4c
writing the new conflict test
dkurepa Jan 22, 2025
946b68d
Merge remote-tracking branch 'source/main' into dkurepa/PostCommentDu…
dkurepa Jan 22, 2025
2f4fec7
Merge remote-tracking branch 'source/main' into dkurepa/PostCommentDu…
dkurepa Jan 23, 2025
5ba53fe
Conflict stuff
dkurepa Jan 27, 2025
ffcfe7f
Merge remote-tracking branch 'source/main' into dkurepa/PostCommentDu…
dkurepa Jan 27, 2025
89beccb
More things
dkurepa Jan 27, 2025
8673a3b
Fix VMR cloning in PCS
premun Jan 27, 2025
6ad1da0
Add scenario test for other scenario
dkurepa Jan 27, 2025
b4ba888
what is wrong here..
dkurepa Jan 27, 2025
8eb1f89
fix unit tests
dkurepa Jan 28, 2025
ab7f82a
some improvements
dkurepa Jan 28, 2025
3617791
Merge remote-tracking branch 'source/main' into dkurepa/PostCommentDu…
dkurepa Jan 28, 2025
80c14e1
more things
dkurepa Jan 28, 2025
bf32b13
tests are passing locally??
dkurepa Jan 28, 2025
07b24e9
Update src/ProductConstructionService/ProductConstructionService.Depe…
dkurepa Jan 28, 2025
cd68e65
Address part of the feedback
dkurepa Jan 28, 2025
9db5613
Merge remote-tracking branch 'origin/dkurepa/PostCommentDuringConflic…
dkurepa Jan 28, 2025
a40dda8
More stuff resolved
dkurepa Jan 28, 2025
68b976a
Add DependencyFlow tests for conflict handling
dkurepa Jan 29, 2025
30d1b32
Don't check whole conflict comment in scenario tests, just check if t…
dkurepa Jan 29, 2025
b6df5c2
Update src/ProductConstructionService/ProductConstructionService.Depe…
dkurepa Jan 29, 2025
1cf0352
Update test/ProductConstructionService.DependencyFlow.Tests/PullReque…
dkurepa Jan 29, 2025
63b310a
Update test/ProductConstructionService.ScenarioTests/CodeFlowScenario…
dkurepa Jan 29, 2025
6831822
Update test/ProductConstructionService.ScenarioTests/ScenarioTestBase.cs
dkurepa Jan 29, 2025
ac92f08
Update src/ProductConstructionService/ProductConstructionService.Depe…
dkurepa Jan 29, 2025
a31cd31
Address feedback
dkurepa Jan 29, 2025
6544f92
Fix build
dkurepa Jan 30, 2025
d3fe00b
Update src/Microsoft.DotNet.Darc/DarcLib/VirtualMonoRepo/Exceptions.cs
dkurepa Jan 30, 2025
1182062
Update src/Microsoft.DotNet.Darc/DarcLib/VirtualMonoRepo/Exceptions.cs
dkurepa Jan 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/Microsoft.DotNet.Darc/DarcLib/AzureDevOpsClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1560,4 +1560,24 @@ private static string TruncateDescriptionIfNeeded(string str)
}
return str;
}

public async Task CommentPullRequestAsync(string pullRequestUri, string comment)
{
(string accountName, string _, string repoName, int id) = ParsePullRequestUri(pullRequestUri);

using VssConnection connection = CreateVssConnection(accountName);
using GitHttpClient client = await connection.GetClientAsync<GitHttpClient>();

var prComment = new Comment()
{
CommentType = CommentType.Text,
Content = $"{comment}{CommentMarker}"
};

var newCommentThread = new GitPullRequestCommentThread()
{
Comments = [prComment]
};
await client.CreateThreadAsync(newCommentThread, repoName, id);
}
}
6 changes: 6 additions & 0 deletions src/Microsoft.DotNet.Darc/DarcLib/GitHubClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1309,4 +1309,10 @@ public async Task DeletePullRequestBranchAsync(string pullRequestUri)
(string owner, string repo, int id) prInfo = ParsePullRequestUri(pullRequestUri);
await DeleteBranchAsync(prInfo.owner, prInfo.repo, pr.HeadBranch);
}

public async Task CommentPullRequestAsync(string pullRequestUri, string comment)
{
(string owner, string repo, int id) = ParsePullRequestUri(pullRequestUri);
await GetClient(owner, repo).Issue.Comment.Create(owner, repo, id, comment);
}
}
8 changes: 8 additions & 0 deletions src/Microsoft.DotNet.Darc/DarcLib/IRemote.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,5 +188,13 @@ Task<List<GitFile>> CommitUpdatesAsync(
/// <param name="gitDirParent">Location for the .git directory, or null for default</param>
Task CloneAsync(string repoUri, string commit, string targetDirectory, bool checkoutSubmodules, string gitDirectory);

/// <summary>
/// Comment on an existing pull request
/// </summary>
/// <param name="pullRequestUri">Uri of the pull request</param>
/// <param name="comment">Comment message</param>
/// <returns></returns>
Task CommentPullRequestAsync(string pullRequestUri, string comment);

#endregion
}
9 changes: 8 additions & 1 deletion src/Microsoft.DotNet.Darc/DarcLib/IRemoteGitRepo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Task<IEnumerable<int>> SearchPullRequestsAsync(
/// or remove each merge policy check that isn't in evaluations
/// </summary>
/// <param name="pullRequestUrl">Url of pull request</param>
/// <param name="evalutations">List of merge policies</param>
/// <param name="evaluations">List of merge policies</param>
Task CreateOrUpdatePullRequestMergeStatusInfoAsync(string pullRequestUrl, IReadOnlyList<MergePolicyEvaluationResult> evaluations);

/// <summary>
Expand Down Expand Up @@ -168,6 +168,13 @@ Task<IEnumerable<int>> SearchPullRequestsAsync(
/// <param name="repoUri">Repository to find the branch in</param>
/// <param name="branch">Branch to find</param>
Task<bool> DoesBranchExistAsync(string repoUri, string branch);

/// <summary>
/// Comment on an existing pull request
/// </summary>
/// <param name="pullRequestUri">Uri of the pull request</param>
/// <param name="comment">Comment message</param>
Task CommentPullRequestAsync(string pullRequestUri, string comment);
}

#nullable disable
Expand Down
6 changes: 6 additions & 0 deletions src/Microsoft.DotNet.Darc/DarcLib/Remote.cs
Original file line number Diff line number Diff line change
Expand Up @@ -405,4 +405,10 @@ public async Task<List<GitFile>> GetCommonScriptFilesAsync(string repoUri, strin

return files;
}

public async Task CommentPullRequestAsync(string pullRequestUri, string comment)
{
CheckForValidGitClient();
await _remoteGitClient.CommentPullRequestAsync(pullRequestUri, comment);
}
}
49 changes: 47 additions & 2 deletions src/Microsoft.DotNet.Darc/DarcLib/VirtualMonoRepo/Exceptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.DotNet.DarcLib.Helpers;

#nullable enable
Expand All @@ -24,8 +27,50 @@ private static string GetExceptionMessage(VmrIngestionPatch patch, ProcessExecut
+ result;
}

public class ConflictInPrBranchException(VmrIngestionPatch patch, string targetBranch)
/// <summary>
/// Exception thrown when the service can't apply an update to the PR branch due to a conflict
dkurepa marked this conversation as resolved.
Show resolved Hide resolved
/// between the source repo and a change that was made in the PR after it was opened.
/// </summary>
public class ConflictInPrBranchException(ProcessExecutionResult gitMergeResult, string targetBranch, bool isForwardFlow)
dkurepa marked this conversation as resolved.
Show resolved Hide resolved
: Exception($"Failed to flow changes due to conflicts in the target branch ({targetBranch})")
{
public VmrIngestionPatch Patch { get; } = patch;
public List<string> FilesInConflict { get; } = ParseResult(gitMergeResult, isForwardFlow);

private static readonly Regex AlreadyExistsRegex = new("patch failed: (.+): already exist in index");
private static readonly Regex PatchFailedRegex = new("error: patch failed: (.*):");
private static readonly Regex PatchDoesNotApplyRegex = new("error: (.+): patch does not apply");
private static readonly Regex FileDoesNotExistRegex = new("error: (.+): does not exist in index");

private static readonly Regex[] ConflictRegex =
[
AlreadyExistsRegex,
PatchFailedRegex,
PatchDoesNotApplyRegex,
FileDoesNotExistRegex
];

private static List<string> ParseResult(ProcessExecutionResult gitMergeResult, bool isForwardFlow)
{
List<string> filesInConflict = new();
var errors = gitMergeResult.StandardError.Split(Environment.NewLine);
foreach (var error in errors)
{
foreach (var regex in ConflictRegex)
{
var match = regex.Match(error);
if (match.Success)
{
filesInConflict.Add(match.Groups[1].Value);
break;
}
}
}
if (isForwardFlow)
{
// Convert VMR paths to normal repo paths, for example src/repo/file.cs -> file.cs
return filesInConflict.Select(file => file.Split('/', 3)[2]).Distinct().ToList();
}
// If we're backflowing, the file paths are already normal
return filesInConflict;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ protected override async Task<bool> SameDirectionFlowAsync(
if (!rebaseConflicts)
{
_logger.LogInformation("Failed to update a PR branch because of a conflict. Stopping the flow..");
throw new ConflictInPrBranchException(e.Patch, headBranch);
throw new ConflictInPrBranchException(e.Result, targetBranch, isForwardFlow: false);
}

// Otherwise, we have a conflicting change in the last backflow PR (before merging)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ protected override async Task<bool> SameDirectionFlowAsync(
if (!rebaseConflicts)
{
_logger.LogInformation("Failed to update a PR branch because of a conflict. Stopping the flow..");
throw new ConflictInPrBranchException(e.Patch, headBranch);
throw new ConflictInPrBranchException(e.Result, targetBranch, isForwardFlow: true);
}

// This happens when a conflicting change was made in the last backflow PR (before merging)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public interface IRedisCache
Task<string?> GetAsync(string key);
Task<string?> GetAsync();
Task SetAsync(string value, TimeSpan? expiration = null);
Task TryDeleteAsync();
Task<bool> TryDeleteAsync();
Task<string?> TryGetAsync();
IAsyncEnumerable<string> GetKeysAsync(string pattern);
}
Expand Down Expand Up @@ -44,9 +44,9 @@ public async Task SetAsync(string value, TimeSpan? expiration = null)
return value.HasValue ? value.ToString() : null;
}

public async Task TryDeleteAsync()
public async Task<bool> TryDeleteAsync()
{
await Cache.KeyDeleteAsync(_stateKey);
return await Cache.KeyDeleteAsync(_stateKey);
}

public async Task<string?> GetAsync()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,13 @@ public class InProgressPullRequest : DependencyFlowWorkItem, IPullRequest

[DataMember]
public DateTime? NextCheck { get; set; }

[DataMember]
public InProgressPullRequestState MergeState { get; set; }
}

public enum InProgressPullRequestState
{
Mergeable,
Conflict
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text;
using Maestro.Data.Models;
using Maestro.MergePolicies;
using Maestro.MergePolicyEvaluation;
Expand All @@ -26,7 +27,7 @@ namespace ProductConstructionService.DependencyFlow;
internal abstract class PullRequestUpdater : IPullRequestUpdater
{
#if DEBUG
private static readonly TimeSpan DefaultReminderDelay = TimeSpan.FromMinutes(3);
private static readonly TimeSpan DefaultReminderDelay = TimeSpan.FromMinutes(1);
dkurepa marked this conversation as resolved.
Show resolved Hide resolved
#else
private static readonly TimeSpan DefaultReminderDelay = TimeSpan.FromMinutes(5);
#endif
Expand Down Expand Up @@ -138,7 +139,6 @@ public async Task<bool> UpdateAssetsAsync(
public async Task<bool> ProcessPendingUpdatesAsync(SubscriptionUpdateWorkItem update)
{
_logger.LogInformation("Processing pending updates for subscription {subscriptionId}", update.SubscriptionId);

// Check if we track an on-going PR already
InProgressPullRequest? pr = await _pullRequestState.TryGetStateAsync();
bool isCodeFlow = update.SubscriptionType == SubscriptionType.DependenciesAndSources;
Expand Down Expand Up @@ -170,7 +170,7 @@ public async Task<bool> ProcessPendingUpdatesAsync(SubscriptionUpdateWorkItem up
}
}

// Code flow updates are handled separetely
// Code flow updates are handled separately
if (isCodeFlow)
{
return await ProcessCodeFlowUpdateAsync(update, pr);
Expand Down Expand Up @@ -206,8 +206,8 @@ public async Task<bool> CheckPullRequestAsync(PullRequestCheck pullRequestCheck)
if (inProgressPr == null)
{
_logger.LogInformation("No in-progress pull request found for a PR check");
await ClearAllStateAsync(isCodeFlow: true);
await ClearAllStateAsync(isCodeFlow: false);
await ClearAllStateAsync(isCodeFlow: true, clearPendingUpdates: true);
await ClearAllStateAsync(isCodeFlow: false, clearPendingUpdates: true);
return false;
}

Expand All @@ -232,7 +232,7 @@ protected virtual Task TagSourceRepositoryGitHubContactsIfPossibleAsync(InProgre
_logger.LogInformation("Querying status for pull request {prUrl}", pr.Url);

(var targetRepository, _) = await GetTargetAsync();
IRemote remote = await _remoteFactory.CreateRemoteAsync(targetRepository);
var remote = await _remoteFactory.CreateRemoteAsync(targetRepository);

PrStatus status;
try
Expand Down Expand Up @@ -267,7 +267,8 @@ await AddDependencyFlowEventsAsync(
mergePolicyResult,
pr.Url);

await ClearAllStateAsync(isCodeFlow);
// If the PR we just merged was in conflict with an update we previously tried to apply, we shouldn't delete the reminder for the update
await ClearAllStateAsync(isCodeFlow, clearPendingUpdates: pr.MergeState == InProgressPullRequestState.Mergeable);
dkurepa marked this conversation as resolved.
Show resolved Hide resolved
return PullRequestStatus.Completed;

case MergePolicyCheckResult.FailedPolicies:
Expand All @@ -277,6 +278,17 @@ await AddDependencyFlowEventsAsync(
case MergePolicyCheckResult.NoPolicies:
case MergePolicyCheckResult.FailedToMerge:
_logger.LogInformation("Pull request {url} still active (updatable) - keeping tracking it", pr.Url);
// Check if we think the PR has a conflict
if (pr.MergeState == InProgressPullRequestState.Conflict)
{
// If we think so, check if the PR head branch still has the same commit as the one we remembered.
// If it doesn't, we should try to update the PR again, the conflicts might be resolved
var latestCommit = await remote.GetLatestCommitAsync(targetRepository, pr.HeadBranch);
if (latestCommit == pr.SourceSha)
{
return PullRequestStatus.InProgressCannotUpdate;
}
}
await SetPullRequestCheckReminder(pr, isCodeFlow);
return PullRequestStatus.InProgressCanUpdate;

Expand Down Expand Up @@ -311,7 +323,7 @@ await AddDependencyFlowEventsAsync(

_logger.LogInformation("PR {url} has been manually {action}. Stopping tracking it", pr.Url, status.ToString().ToLowerInvariant());

await ClearAllStateAsync(isCodeFlow);
await ClearAllStateAsync(isCodeFlow, clearPendingUpdates: pr.MergeState == InProgressPullRequestState.Mergeable);

// Also try to clean up the PR branch.
try
Expand Down Expand Up @@ -800,11 +812,16 @@ private async Task SetPullRequestCheckReminder(InProgressPullRequest prState, bo
await _pullRequestState.SetAsync(prState);
}

private async Task ClearAllStateAsync(bool isCodeFlow)
private async Task ClearAllStateAsync(bool isCodeFlow, bool clearPendingUpdates)
{
await _pullRequestState.TryDeleteAsync();
await _pullRequestCheckReminders.UnsetReminderAsync(isCodeFlow);
await _pullRequestUpdateReminders.UnsetReminderAsync(isCodeFlow);
// If the pull request we deleted from the cache had a conflict, we shouldn't unset the update reminder
// as there was an update that was previously blocked
if (!clearPendingUpdates)
{
await _pullRequestUpdateReminders.UnsetReminderAsync(isCodeFlow);
}
}

/// <summary>
Expand Down Expand Up @@ -905,6 +922,13 @@ await CreateCodeFlowPullRequestAsync(
{
await UpdateAssetsAndSources(update, pr);
}
catch (ConflictInPrBranchException conflictException)
{
return await HandlePrUpdateConflictAsync(
conflictException,
update,
pr);
}
catch (Exception e)
{
// TODO https://github.com/dotnet/arcade-services/issues/4198: Notify us about these kind of failures
Expand Down Expand Up @@ -962,6 +986,10 @@ private async Task<bool> UpdateAssetsAndSources(SubscriptionUpdateWorkItem updat
cancellationToken: default);
}
}
catch (Exception e) when (e is ConflictInPrBranchException)
{
throw;
}
catch (Exception e)
{
_logger.LogError(e, "Failed to flow changes for build {buildId} in subscription {subscriptionId}",
Expand Down Expand Up @@ -1016,6 +1044,7 @@ private async Task<bool> UpdateAssetsAndSources(SubscriptionUpdateWorkItem updat
});

pullRequest.LastUpdate = DateTime.UtcNow;
pullRequest.MergeState = InProgressPullRequestState.Mergeable;
await SetPullRequestCheckReminder(pullRequest, true);
await _pullRequestUpdateReminders.UnsetReminderAsync(true);

Expand Down Expand Up @@ -1171,5 +1200,54 @@ await AddDependencyFlowEventsAsync(
}
}

private async Task<bool> HandlePrUpdateConflictAsync(
ConflictInPrBranchException conflictException,
SubscriptionUpdateWorkItem update,
InProgressPullRequest pr)
{
// The PR we're trying to update has a conflict with the source repo. We will mark it as blocked, not allowing any updates from this
// subscription till it's merged, or the conflict resolved. We'll set a reminder to check on it.
StringBuilder sb = new();
sb.AppendLine($"There was a conflict in the PR branch when flowing source from {update.GetRepoAtCommitUri()}");
sb.AppendLine("Files conflicting with the head branch:");
foreach (var file in conflictException.FilesInConflict)
{
sb.AppendLine($" - [{file}]({update.GetFileAtCommitUri(file)})");
}
sb.AppendLine();
sb.AppendLine("Updates from this subscription will be blocked until the conflict is resolved, or the PR is merged");

(var targetRepository, _) = await GetTargetAsync();
var remote = await _remoteFactory.CreateRemoteAsync(targetRepository);

try
{
await remote.CommentPullRequestAsync(pr.Url, sb.ToString());
}
catch (Exception e)
{
_logger.LogWarning("Posting comment to {prUrl} failed with exception {message}", pr.Url, e.Message);
}
// If the headBranch gets updated, we will retry to update it with previously conflicting changes. If these changes still cause a conflict, we should update the
// InProgressPullRequest with the latest commit from the remote branch
var remoteCommit = pr.SourceSha;
try
{
remoteCommit = await remote.GetLatestCommitAsync(targetRepository, pr.HeadBranch);
}
catch (Exception e)
{
_logger.LogWarning("Couldn't get latest commit of {repo}/{commit}. Failed with exception {message}", targetRepository, pr.HeadBranch, e.Message);
}

pr.MergeState = InProgressPullRequestState.Conflict;
pr.SourceSha = remoteCommit;
await _pullRequestState.SetAsync(pr);
await _pullRequestUpdateReminders.SetReminderAsync(update, DefaultReminderDelay, isCodeFlow: true);
await _pullRequestCheckReminders.UnsetReminderAsync(isCodeFlow: true);

return true;
}

#endregion
}
Loading