File: Automation\PullRequestCreator.cs
Web Access
Project: src\src\VersionTools\Microsoft.DotNet.VersionTools\Microsoft.DotNet.VersionTools.csproj (Microsoft.DotNet.VersionTools)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Microsoft.DotNet.VersionTools.Automation.GitHubApi;
using Microsoft.DotNet.VersionTools.Util;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
 
namespace Microsoft.DotNet.VersionTools.Automation
{
    public class PullRequestCreator
    {
        private const string DiscardedCommitElementName = "auto-pr-discard-list";
        private const string MaestroStopUpdatesLabel = "Maestro-Stop-Updating";
 
        private GitHubAuth _auth;
 
        public string GitAuthorName { get; }
 
        public PullRequestCreator(
            GitHubAuth auth,
            string gitAuthorName = null)
        {
            if (auth == null)
            {
                throw new ArgumentNullException(
                    nameof(auth),
                    "Authentication is required: pull requests cannot be created anonymously.");
            }
            _auth = auth;
            GitAuthorName = gitAuthorName ?? auth.User;
        }
 
        public async Task CreateOrUpdateAsync(
            string commitMessage,
            string title,
            string description,
            GitHubBranch baseBranch,
            GitHubProject origin,
            PullRequestOptions options)
        {
            using (var client = new GitHubClient(_auth))
            {
                await CreateOrUpdateAsync(
                    commitMessage,
                    title,
                    description,
                    baseBranch,
                    origin,
                    options,
                    client);
            }
        }
 
        public async Task CreateOrUpdateAsync(
            string commitMessage,
            string title,
            string description,
            GitHubBranch baseBranch,
            GitHubProject origin,
            PullRequestOptions options,
            IGitHubClient client)
        {
            options = options ?? new PullRequestOptions();
            client.AdjustOptionsToCapability(options);
 
            var upstream = baseBranch.Project;
 
            GitHubBranch originBranch = null;
            GitHubPullRequest pullRequestToUpdate = null;
 
            IUpdateBranchNamingStrategy namingStrategy = options.BranchNamingStrategy
                ?? new SingleBranchNamingStrategy("UpdateDependencies");
 
            string upgradeBranchPrefix = namingStrategy.Prefix(baseBranch.Name);
 
            if (!options.ForceCreate)
            {
                string myAuthorId = await client.GetMyAuthorIdAsync();
 
                pullRequestToUpdate = await client.SearchPullRequestsAsync(
                    upstream,
                    upgradeBranchPrefix,
                    myAuthorId);
 
                if (pullRequestToUpdate == null)
                {
                    Trace.TraceInformation($"No existing pull request found.");
                }
                else
                {
                    Trace.TraceInformation(
                        $"Pull request already exists for {upgradeBranchPrefix} in {upstream.Segments}. " +
                        $"#{pullRequestToUpdate.Number}, '{pullRequestToUpdate.Title}'");
 
                    GitCommit headCommit = await client.GetCommitAsync(
                        origin,
                        pullRequestToUpdate.Head.Sha);
 
                    string blockedReason = GetUpdateBlockedReason(
                        pullRequestToUpdate,
                        headCommit,
                        upgradeBranchPrefix,
                        origin,
                        options);
 
                    if (blockedReason == null)
                    {
                        if (options.TrackDiscardedCommits)
                        {
                            await PostDiscardedCommitCommentAsync(
                                baseBranch.Project,
                                pullRequestToUpdate,
                                headCommit,
                                client);
                        }
 
                        originBranch = new GitHubBranch(
                            pullRequestToUpdate.Head.Ref,
                            origin);
                    }
                    else
                    {
                        string comment =
                            $"Couldn't update this pull request: {blockedReason}\n" +
                            $"Would have applied '{commitMessage}'";
 
                        Trace.TraceInformation($"Sending comment to PR: {comment}");
 
                        await client.PostCommentAsync(upstream, pullRequestToUpdate.Number, comment);
                        return;
                    }
                }
 
                // No existing branch to update: push to a new one.
                if (originBranch == null)
                {
                    string newBranchName =
                        namingStrategy.Prefix(baseBranch.Name) +
                        namingStrategy.CreateFreshBranchNameSuffix(baseBranch.Name);
 
                    originBranch = new GitHubBranch(newBranchName, origin);
                }
 
                PushNewCommit(originBranch, commitMessage, client);
 
                if (pullRequestToUpdate != null)
                {
                    await client.UpdateGitHubPullRequestAsync(
                        upstream,
                        pullRequestToUpdate.Number,
                        title,
                        description,
                        maintainersCanModify: options.MaintainersCanModify);
                }
                else
                {
                    await client.PostGitHubPullRequestAsync(
                        title,
                        description,
                        originBranch,
                        baseBranch,
                        options.MaintainersCanModify);
                }
            }
        }
 
        private async Task PostDiscardedCommitCommentAsync(
            GitHubProject baseProject,
            GitHubPullRequest pullRequestToUpdate,
            GitCommit oldCommit,
            IGitHubClient client)
        {
            GitHubCombinedStatus combinedStatus = await client.GetStatusAsync(
                baseProject,
                oldCommit.Sha);
 
            CiStatusLine[] statuses = combinedStatus
                .Statuses
                .OrderBy(s => s.State)
                .ThenBy(s => s.Context)
                .Select(CiStatusLine.Create)
                .ToArray();
 
            string statusLines = statuses
                .Aggregate(string.Empty, (acc, line) => acc + line.MarkdownLine + "\r\n");
 
            string ciSummary = string.Join(
                " ",
                statuses
                    .GroupBy(s => s.Emoticon)
                    .Select(g => $"{g.Count()}{g.Key}")
                    .ToArray());
 
            string commentBody =
                $"Discarded [`{oldCommit.Sha.Substring(0, 7)}`]({oldCommit.HtmlUrl}): " +
                $"`{oldCommit.Message}`";
 
            if (statuses.Any())
            {
                commentBody += "\r\n\r\n" +
                    "<details>" +
                    "<summary>" +
                    $"CI Status: {ciSummary} (click to expand)\r\n" +
                    "</summary>" +
                    $"\r\n\r\n{statusLines}\r\n" +
                    "</details>";
            }
 
            await client.PostCommentAsync(
                baseProject,
                pullRequestToUpdate.Number,
                commentBody);
        }
 
        public static string NotificationString(IEnumerable<string> usernames)
        {
            return $"/cc @{string.Join(" @", usernames)}";
        }
 
        private string GetUpdateBlockedReason(
            GitHubPullRequest pr,
            GitCommit headCommit,
            string upgradeBranchPrefix,
            GitHubProject origin,
            PullRequestOptions options)
        {
            if (pr.Head.User.Login != origin.Owner && !options.AllowBranchOnAnyRepoOwner)
            {
                return $"Owner of head repo '{pr.Head.User.Login}' is not '{origin.Owner}'";
            }
            if (!pr.Head.Ref.StartsWith(upgradeBranchPrefix))
            {
                return $"Ref name '{pr.Head.Ref}' does not start with '{upgradeBranchPrefix}'";
            }
            if (headCommit.Author.Name != GitAuthorName)
            {
                return $"Head commit author '{headCommit.Author.Name}' is not '{GitAuthorName}'";
            }
            if (headCommit.Committer.Name != GitAuthorName)
            {
                return $"Head commit committer '{headCommit.Committer.Name}' is not '{GitAuthorName}'";
            }
            if (pr.Labels?.Any(IsMaestroStopUpdatesLabel) ?? false)
            {
                return $"Label `{MaestroStopUpdatesLabel}` is attached";
            }
            return null;
        }
 
        private void PushNewCommit(GitHubBranch branch, string commitMessage, IGitHubClient client)
        {
            GitCommand.Commit(commitMessage, GitAuthorName, _auth.Email, all: true);
 
            string remoteUrl = client.CreateGitRemoteUrl(branch.Project);
            string refSpec = $"HEAD:refs/heads/{branch.Name}";
 
            GitCommand.Push(
                $"https://{_auth.User}:{_auth.AuthToken}@{remoteUrl}",
                $"https://{remoteUrl}",
                refSpec,
                force: true);
        }
 
        private static bool IsMaestroStopUpdatesLabel(GitHubLabel label) => string.Equals(
            label?.Name,
            MaestroStopUpdatesLabel,
            StringComparison.OrdinalIgnoreCase);
 
        private class CiStatusLine
        {
            public static CiStatusLine Create(GitHubStatus status)
            {
                string emoticon = ":grey_question:";
                if (string.Equals(status.State, "success", StringComparison.OrdinalIgnoreCase))
                {
                    emoticon = ":heavy_check_mark:";
                }
                else if (string.Equals(status.State, "pending", StringComparison.OrdinalIgnoreCase))
                {
                    emoticon = ":hourglass:";
                }
                else if (string.Equals(status.State, "error", StringComparison.OrdinalIgnoreCase) ||
                         string.Equals(status.State, "failure", StringComparison.OrdinalIgnoreCase))
                {
                    emoticon = ":x:";
                }
 
                string line = $" * {emoticon} **{status.Context}** {status.Description}";
                if (!string.IsNullOrEmpty(status.TargetUrl))
                {
                    line += $" [Details]({status.TargetUrl})";
                }
 
                return new CiStatusLine
                {
                    Emoticon = emoticon,
                    MarkdownLine = line
                };
            }
 
            public string Emoticon { get; private set; }
            public string MarkdownLine { get; private set; }
        }
    }
}