diff --git a/eng/update-dependencies/SpecificCommand.cs b/eng/update-dependencies/SpecificCommand.cs index ea9a2043e4..5d1e2f85e6 100644 --- a/eng/update-dependencies/SpecificCommand.cs +++ b/eng/update-dependencies/SpecificCommand.cs @@ -274,12 +274,15 @@ await prCreator.CreateOrUpdateAsync( } else { - UpdateExistingGitHubPullRequest(gitHubAuth, prOptions, commitMessage, upstreamBranch); + await UpdateExistingGitHubPullRequestAsync( + client, upstreamProject, pullRequestToUpdate.Number, + gitHubAuth, prOptions, commitMessage, upstreamBranch); } } } - private static void UpdateExistingGitHubPullRequest( + private static async Task UpdateExistingGitHubPullRequestAsync( + GitHubClient client, GitHubProject upstreamProject, int pullRequestNumber, GitHubAuth gitHubAuth, PullRequestOptions prOptions, string commitMessage, GitHubBranch upstreamBranch) { // PullRequestCreator ends up force-pushing updates to an existing PR which is not great when the logic @@ -308,13 +311,46 @@ private static void UpdateExistingGitHubPullRequest( // Clone the PR's repo/branch to a temp location Repository.Clone($"https://github.com/{gitHubAuth.User}/{Options.GitHubProject}", tempRepoPath, cloneOptions); + using Repository repo = new(tempRepoPath); + + // Add upstream remote and fetch the target branch so we can find the merge base + string upstreamUrl = $"https://github.com/{Options.GitHubUpstreamOwner}/{Options.GitHubProject}"; + repo.Network.Remotes.Add("upstream", upstreamUrl); + Commands.Fetch(repo, "upstream", [$"refs/heads/{upstreamBranch.Name}:refs/remotes/upstream/{upstreamBranch.Name}"], null, null); + + // Check for non-bot commits on the PR branch. If someone other than the bot + // has pushed commits, we should not overwrite them. + List nonBotCommits = GetNonBotCommits(repo, upstreamBranch.Name); + if (nonBotCommits.Count > 0) + { + Trace.WriteLine($"Found {nonBotCommits.Count} non-bot commit(s) on the PR branch - skipping update."); + + string localCommand = BuildLocalUpdateCommand(); + string nonBotCommitList = string.Join("\n", nonBotCommits.Select(c => $"- {c}")); + string comment = $""" + Automatic update skipped because additional commits were detected on this branch: + + {nonBotCommitList} + + To apply this update manually, run: + + ``` + {localCommand} + ``` + + To allow automatic updates to resume, merge or close this PR. + """; + + await client.PostCommentAsync(upstreamProject, pullRequestNumber, comment); + return; + } + // Remove all existing directories and files from the temp repo ClearRepoContents(tempRepoPath); // Copy contents of local repo changes to temp repo DirectoryCopy(".", tempRepoPath); - using Repository repo = new(tempRepoPath); RepositoryStatus status = repo.RetrieveStatus(new StatusOptions()); // If there are any changes from what exists in the PR @@ -355,6 +391,78 @@ private static void UpdateExistingGitHubPullRequest( } } + /// + /// Finds commits on the current branch that were not authored by the bot user. + /// Walks the commit log from HEAD back to the merge base with the target branch. + /// + private static List GetNonBotCommits(Repository repo, string targetBranchName) + { + List nonBotCommits = []; + + Branch? targetBranch = repo.Branches[$"upstream/{targetBranchName}"]; + if (targetBranch is null) + { + Trace.WriteLine($"Could not find remote tracking branch 'upstream/{targetBranchName}' - skipping non-bot commit check."); + return nonBotCommits; + } + + Commit headCommit = repo.Head.Tip; + Commit targetCommit = targetBranch.Tip; + + Commit? mergeBase = repo.ObjectDatabase.FindMergeBase(headCommit, targetCommit); + if (mergeBase is null) + { + Trace.WriteLine("Could not find merge base - skipping non-bot commit check."); + return nonBotCommits; + } + + foreach (Commit commit in repo.Commits) + { + if (commit.Sha == mergeBase.Sha) + { + break; + } + + if (!string.Equals(commit.Author.Name, Options.User, StringComparison.OrdinalIgnoreCase)) + { + nonBotCommits.Add($"`{commit.Sha[..7]}` {commit.MessageShort} (by {commit.Author.Name})"); + } + } + + return nonBotCommits; + } + + /// + /// Builds a CLI command string that can be run locally to apply the same dependency update. + /// Uses the specific subcommand with already-resolved versions, so no BAR auth is needed. + /// + private static string BuildLocalUpdateCommand() + { + List parts = + [ + "dotnet run --project eng/update-dependencies/update-dependencies.csproj --", + "specific", + Options.DockerfileVersion, + ]; + + foreach (var (name, version) in Options.ProductVersions) + { + parts.Add($"--product-version {name}={version}"); + } + + foreach (string tool in Options.Tools) + { + parts.Add($"--tool {tool}"); + } + + if (!string.IsNullOrEmpty(Options.VersionSourceName)) + { + parts.Add($"--version-source-name '{Options.VersionSourceName}'"); + } + + return string.Join(" ", parts); + } + private static void DeleteRepoDirectory(string repoPath) { if (Directory.Exists(repoPath))