diff --git a/src/GitHub.App/SampleData/PullRequestListItemViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestListItemViewModelDesigner.cs index 500089fb84..38c6b5b740 100644 --- a/src/GitHub.App/SampleData/PullRequestListItemViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/PullRequestListItemViewModelDesigner.cs @@ -17,6 +17,9 @@ public class PullRequestListItemViewModelDesigner : ViewModelBase, IPullRequestL public int Number { get; set; } public string Title { get; set; } public DateTimeOffset UpdatedAt { get; set; } - public PullRequestChecksState Checks { get; set; } + public PullRequestChecksSummaryState ChecksSummary { get; set; } + public int ChecksPendingCount { get; set; } + public int ChecksSuccessCount { get; set; } + public int ChecksErrorCount { get; set; } } } diff --git a/src/GitHub.App/Services/PullRequestService.cs b/src/GitHub.App/Services/PullRequestService.cs index fd2f9bc006..107fc3017e 100644 --- a/src/GitHub.App/Services/PullRequestService.cs +++ b/src/GitHub.App/Services/PullRequestService.cs @@ -223,64 +223,65 @@ public async Task> ReadPullRequests( item.Reviews = null; var checkRuns = item.LastCommit?.CheckSuites?.SelectMany(model => model.CheckRuns).ToArray(); + var statuses = item.LastCommit?.Statuses; - var hasCheckRuns = checkRuns?.Any() ?? false; - var hasStatuses = item.LastCommit?.Statuses?.Any() ?? false; + var totalCount = 0; + var pendingCount = 0; + var successCount = 0; + var errorCount = 0; - if (!hasCheckRuns && !hasStatuses) + if (checkRuns != null) { - item.Checks = PullRequestChecksState.None; + totalCount += checkRuns.Length; + + pendingCount += checkRuns.Count(model => model.Status != CheckStatusState.Completed); + + successCount += checkRuns.Count(model => model.Status == CheckStatusState.Completed && + model.Conclusion.HasValue && + (model.Conclusion == CheckConclusionState.Success || + model.Conclusion == CheckConclusionState.Neutral)); + errorCount += checkRuns.Count(model => model.Status == CheckStatusState.Completed && + model.Conclusion.HasValue && + !(model.Conclusion == CheckConclusionState.Success || + model.Conclusion == CheckConclusionState.Neutral)); } - else - { - var checksHasFailure = false; - var checksHasCompleteSuccess = true; - if (hasCheckRuns) - { - checksHasFailure = checkRuns - .Any(model => model.Conclusion.HasValue - && (model.Conclusion.Value == CheckConclusionState.Failure - || model.Conclusion.Value == CheckConclusionState.ActionRequired)); + if (statuses != null) + { + totalCount += statuses.Count; - if (!checksHasFailure) - { - checksHasCompleteSuccess = checkRuns - .All(model => model.Conclusion.HasValue - && (model.Conclusion.Value == CheckConclusionState.Success - || model.Conclusion.Value == CheckConclusionState.Neutral)); - } - } + pendingCount += statuses.Count(model => + model.State == StatusState.Pending || model.State == StatusState.Expected); - var statusHasFailure = false; - var statusHasCompleteSuccess = true; + successCount += statuses.Count(model => model.State == StatusState.Success); - if (!checksHasFailure && hasStatuses) - { - statusHasFailure = item.LastCommit - .Statuses - .Any(status => status.State == StatusState.Failure - || status.State == StatusState.Error); + errorCount += statuses.Count(model => + model.State == StatusState.Error || model.State == StatusState.Failure); + } - if (!statusHasFailure) - { - statusHasCompleteSuccess = - item.LastCommit.Statuses.All(status => status.State == StatusState.Success); - } - } + item.ChecksPendingCount = pendingCount; + item.ChecksSuccessCount = successCount; + item.ChecksErrorCount = errorCount; - if (checksHasFailure || statusHasFailure) - { - item.Checks = PullRequestChecksState.Failure; - } - else if (statusHasCompleteSuccess && checksHasCompleteSuccess) - { - item.Checks = PullRequestChecksState.Success; - } - else - { - item.Checks = PullRequestChecksState.Pending; - } + if (totalCount == 0) + { + item.ChecksSummary = PullRequestChecksSummaryState.None; + } + else if (totalCount == pendingCount) + { + item.ChecksSummary = PullRequestChecksSummaryState.Pending; + } + else if (totalCount == successCount) + { + item.ChecksSummary = PullRequestChecksSummaryState.Success; + } + else if (totalCount == errorCount) + { + item.ChecksSummary = PullRequestChecksSummaryState.Failure; + } + else + { + item.ChecksSummary = PullRequestChecksSummaryState.Mixed; } item.LastCommit = null; diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestListItemViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestListItemViewModel.cs index b60cf0e70b..30436ed5a0 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestListItemViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestListItemViewModel.cs @@ -19,7 +19,10 @@ public PullRequestListItemViewModel(PullRequestListItemModel model) { Id = model.Id; Author = new ActorViewModel(model.Author); - Checks = model.Checks; + ChecksSummary = model.ChecksSummary; + ChecksErrorCount = model.ChecksErrorCount; + ChecksPendingCount = model.ChecksPendingCount; + ChecksSuccessCount = model.ChecksSuccessCount; CommentCount = model.CommentCount; Number = model.Number; Title = model.Title; @@ -33,7 +36,16 @@ public PullRequestListItemViewModel(PullRequestListItemModel model) public IActorViewModel Author { get; } /// - public PullRequestChecksState Checks { get; } + public PullRequestChecksSummaryState ChecksSummary { get; } + + /// + public int ChecksSuccessCount { get; } + + /// + public int ChecksPendingCount { get; } + + /// + public int ChecksErrorCount { get; } /// public int CommentCount { get; } diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListItemViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListItemViewModel.cs index f8fa89c351..9f6758243b 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListItemViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListItemViewModel.cs @@ -32,6 +32,21 @@ public interface IPullRequestListItemViewModel : IIssueListItemViewModelBase /// /// Gets the pull request checks and statuses summary /// - PullRequestChecksState Checks { get; } + PullRequestChecksSummaryState ChecksSummary { get; } + + /// + /// Gets the number of pending checks and statuses + /// + int ChecksPendingCount { get; } + + /// + /// Gets the number of successful checks and statuses + /// + int ChecksSuccessCount { get; } + + /// + /// Gets the number of erroneous checks and statuses + /// + int ChecksErrorCount { get; } } } diff --git a/src/GitHub.Exports/Models/IPullRequestModel.cs b/src/GitHub.Exports/Models/IPullRequestModel.cs index 4d78de08ab..3a8df4952f 100644 --- a/src/GitHub.Exports/Models/IPullRequestModel.cs +++ b/src/GitHub.Exports/Models/IPullRequestModel.cs @@ -13,9 +13,10 @@ public enum PullRequestStateEnum Merged, } - public enum PullRequestChecksState + public enum PullRequestChecksSummaryState { None, + Mixed, Pending, Success, Failure diff --git a/src/GitHub.Exports/Models/PullRequestListItemModel.cs b/src/GitHub.Exports/Models/PullRequestListItemModel.cs index f6a9a7fe55..0301af0da9 100644 --- a/src/GitHub.Exports/Models/PullRequestListItemModel.cs +++ b/src/GitHub.Exports/Models/PullRequestListItemModel.cs @@ -40,7 +40,22 @@ public class PullRequestListItemModel /// /// Gets the pull request checks and statuses summary /// - public PullRequestChecksState Checks { get; set; } + public PullRequestChecksSummaryState ChecksSummary { get; set; } + + /// + /// Gets the number of pending checks and statuses + /// + public int ChecksPendingCount { get; set; } + + /// + /// Gets the number of successful checks and statuses + /// + public int ChecksSuccessCount { get; set; } + + /// + /// Gets the number of erroneous checks and statuses + /// + public int ChecksErrorCount { get; set; } /// /// Gets or sets the date/time at which the pull request was last updated. diff --git a/src/GitHub.VisualStudio.UI/UI/Controls/PullRequestStatusCircle.xaml b/src/GitHub.VisualStudio.UI/UI/Controls/PullRequestStatusCircle.xaml new file mode 100644 index 0000000000..66984bda34 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/UI/Controls/PullRequestStatusCircle.xaml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.UI/UI/Controls/PullRequestStatusCircle.xaml.cs b/src/GitHub.VisualStudio.UI/UI/Controls/PullRequestStatusCircle.xaml.cs new file mode 100644 index 0000000000..a46e2f1224 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/UI/Controls/PullRequestStatusCircle.xaml.cs @@ -0,0 +1,348 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; +using ReactiveUI; + +namespace GitHub.VisualStudio.UI.Controls +{ + /// + /// Interaction logic for PullRequestStatusCircle.xaml + /// + public partial class PullRequestStatusCircle : UserControl + { + public static readonly DependencyProperty ErrorCountProperty = DependencyProperty.Register( + "ErrorCount", typeof(int), typeof(PullRequestStatusCircle), + new PropertyMetadata(0, OnErrorCountChanged)); + + private static void OnErrorCountChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs eventArgs) + { + var pullRequestStatusCircle = ((PullRequestStatusCircle)dependencyObject); + pullRequestStatusCircle.ErrorCount = (int)eventArgs.NewValue; + pullRequestStatusCircle.GeneratePolygons(); + } + + public static readonly DependencyProperty SuccessCountProperty = DependencyProperty.Register( + "SuccessCount", typeof(int), typeof(PullRequestStatusCircle), + new PropertyMetadata(0, OnSuccessCountChanged)); + + private static void OnSuccessCountChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs eventArgs) + { + var pullRequestStatusCircle = ((PullRequestStatusCircle)dependencyObject); + pullRequestStatusCircle.SuccessCount = (int)eventArgs.NewValue; + pullRequestStatusCircle.GeneratePolygons(); + } + + public static readonly DependencyProperty PendingCountProperty = DependencyProperty.Register( + "PendingCount", typeof(int), typeof(PullRequestStatusCircle), + new PropertyMetadata(0, OnPendingCountChanged)); + + private static void OnPendingCountChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs eventArgs) + { + var pullRequestStatusCircle = ((PullRequestStatusCircle) dependencyObject); + pullRequestStatusCircle.PendingCount = (int) eventArgs.NewValue; + pullRequestStatusCircle.GeneratePolygons(); + } + + public static readonly DependencyProperty RadiusProperty = DependencyProperty.Register( + "Radius", typeof(double), typeof(PullRequestStatusCircle), + new PropertyMetadata((double)250, OnRadiusChanged)); + + private static void OnRadiusChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs eventArgs) + { + var pullRequestStatusCircle = ((PullRequestStatusCircle) dependencyObject); + pullRequestStatusCircle.Radius = (double) eventArgs.NewValue; + pullRequestStatusCircle.GenerateMask(); + pullRequestStatusCircle.GeneratePolygons(); + } + + public static readonly DependencyProperty InnerRadiusProperty = DependencyProperty.Register( + "InnerRadius", typeof(double), typeof(PullRequestStatusCircle), + new PropertyMetadata((double)200, OnInnerRadiusChanged)); + + private static void OnInnerRadiusChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs eventArgs) + { + var pullRequestStatusCircle = ((PullRequestStatusCircle) dependencyObject); + pullRequestStatusCircle.InnerRadius = (double) eventArgs.NewValue; + pullRequestStatusCircle.GenerateMask(); + pullRequestStatusCircle.GeneratePolygons(); + } + + public static readonly DependencyProperty PendingColorProperty = DependencyProperty.Register( + "PendingColor", typeof(Brush), typeof(PullRequestStatusCircle), + new PropertyMetadata(Brushes.Yellow, OnPendingColorChanged)); + + private static void OnPendingColorChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs eventArgs) + { + var pullRequestStatusCircle = ((PullRequestStatusCircle) dependencyObject); + var brush = (Brush) eventArgs.NewValue; + pullRequestStatusCircle.PendingColor = brush; + pullRequestStatusCircle.PendingPolygon.Fill = brush; + } + + public static readonly DependencyProperty ErrorColorProperty = DependencyProperty.Register( + "ErrorColor", typeof(Brush), typeof(PullRequestStatusCircle), + new PropertyMetadata(Brushes.Red, OnErrorColorChanged)); + + private static void OnErrorColorChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs eventArgs) + { + var pullRequestStatusCircle = ((PullRequestStatusCircle) dependencyObject); + var brush = (Brush) eventArgs.NewValue; + pullRequestStatusCircle.ErrorColor = brush; + pullRequestStatusCircle.ErrorPolygon.Fill = brush; + } + + public static readonly DependencyProperty SuccessColorProperty = DependencyProperty.Register( + "SuccessColor", typeof(Brush), typeof(PullRequestStatusCircle), + new PropertyMetadata(Brushes.Green, OnSuccessColorChanged)); + + private static void OnSuccessColorChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs eventArgs) + { + var pullRequestStatusCircle = ((PullRequestStatusCircle) dependencyObject); + var brush = (Brush) eventArgs.NewValue; + pullRequestStatusCircle.SuccessColor = brush; + pullRequestStatusCircle.SuccessPolygon.Fill = brush; + } + + public IEnumerable GeneratePoints(float percentage) + { + double ToRadians(float val) + { + return (Math.PI / 180) * val; + } + + if (float.IsNaN(percentage)) + { + return Array.Empty(); + } + + if (percentage < 0 || percentage > 1) + { + throw new ArgumentException(); + } + + var diameter = Diameter; + + var leftEdge = XAdjust; + var rightEdge = diameter + XAdjust; + var topEdge = YAdjust; + var bottomEdge = diameter + YAdjust; + + var topMiddle = new Point(Origin.X, topEdge); + var topRight = new Point(rightEdge, topEdge); + var bottomRight = new Point(rightEdge, bottomEdge); + var bottomLeft = new Point(leftEdge, bottomEdge); + var topLeft = new Point(leftEdge, topEdge); + + if (percentage == 1) + { + return new[] { topLeft, topRight, bottomRight, bottomLeft }; + } + + var degrees = percentage * 360; + var adjustedDegrees = (degrees + 90) % 360; + + if (adjustedDegrees >= 90 && adjustedDegrees < 135) + { + var angleDegrees = adjustedDegrees - 90; + var angleRadians = ToRadians(angleDegrees); + var tan = Math.Tan(angleRadians); + var oppositeEdge = tan * Radius; + return new[] { Origin, topMiddle, new Point(topMiddle.X + oppositeEdge, topMiddle.Y) }; + } + + if (adjustedDegrees >= 135 && adjustedDegrees < 180) + { + var angleDegrees = adjustedDegrees - 135; + var angleRadians = ToRadians(angleDegrees); + var tan = Math.Tan(angleRadians); + var oppositeEdge = tan * Radius; + return new[] { Origin, topMiddle, topRight, new Point(topRight.X, topRight.Y + oppositeEdge) }; + } + + if (adjustedDegrees >= 180 && adjustedDegrees < 225) + { + var angleDegrees = adjustedDegrees - 180; + var angleRadians = ToRadians(angleDegrees); + var tan = Math.Tan(angleRadians); + var oppositeEdge = tan * Radius; + return new[] { Origin, topMiddle, topRight, new Point(topRight.X, topRight.Y + Radius + oppositeEdge) }; + } + + if (adjustedDegrees >= 225 && adjustedDegrees < 270) + { + var angleDegrees = adjustedDegrees - 225; + var angleRadians = ToRadians(angleDegrees); + var tan = Math.Tan(angleRadians); + var oppositeEdge = tan * Radius; + return new[] { Origin, topMiddle, topRight, bottomRight, new Point(bottomRight.X - oppositeEdge, bottomRight.Y) }; + } + + if (adjustedDegrees >= 270 && adjustedDegrees < 315) + { + var angleDegrees = adjustedDegrees - 270; + var angleRadians = ToRadians(angleDegrees); + var tan = Math.Tan(angleRadians); + var oppositeEdge = tan * Radius; + return new[] { Origin, topMiddle, topRight, bottomRight, new Point(bottomRight.X - Radius - oppositeEdge, bottomRight.Y) }; + } + + if (adjustedDegrees >= 315 && adjustedDegrees < 360) + { + var angleDegrees = adjustedDegrees - 315; + var angleRadians = ToRadians(angleDegrees); + var tan = Math.Tan(angleRadians); + var oppositeEdge = tan * Radius; + return new[] { Origin, topMiddle, topRight, bottomRight, bottomLeft, new Point(bottomLeft.X, bottomLeft.Y - oppositeEdge) }; + } + + if (adjustedDegrees >= 0 && adjustedDegrees < 45) + { + var angleDegrees = adjustedDegrees; + var angleRadians = ToRadians(angleDegrees); + var tan = Math.Tan(angleRadians); + var oppositeEdge = tan * Radius; + return new[] { Origin, topMiddle, topRight, bottomRight, bottomLeft, new Point(bottomLeft.X, bottomLeft.Y - Radius - oppositeEdge) }; + } + + if (adjustedDegrees >= 45 && adjustedDegrees < 90) + { + var angleDegrees = adjustedDegrees - 45; + var angleRadians = ToRadians(angleDegrees); + var tan = Math.Tan(angleRadians); + var oppositeEdge = tan * Radius; + return new[] { Origin, topMiddle, topRight, bottomRight, bottomLeft, topLeft, new Point(topLeft.X + oppositeEdge, topLeft.Y) }; + } + + throw new InvalidOperationException(); + } + + public PullRequestStatusCircle() + { + InitializeComponent(); + GeneratePolygons(); + GenerateMask(); + } + + private void GeneratePolygons() + { + ErrorPolygon.Points = new PointCollection(GeneratePoints((float)ErrorCount / TotalCount)); + SuccessPolygon.Points = new PointCollection(GeneratePoints((float)(SuccessCount + ErrorCount) / TotalCount)); + PendingPolygon.Points = new PointCollection(GeneratePoints((float)(SuccessCount + ErrorCount + PendingCount) / TotalCount)); + } + + private void GenerateMask() + { + var pendingPolygonClip = new CombinedGeometry( + GeometryCombineMode.Exclude, + new EllipseGeometry(Origin, Radius, Radius), + new EllipseGeometry(Origin, InnerRadius, InnerRadius)); + + PendingPolygon.Clip = pendingPolygonClip; + SuccessPolygon.Clip = pendingPolygonClip; + ErrorPolygon.Clip = pendingPolygonClip; + } + + private Point Origin => new Point(Radius + XAdjust, Radius + YAdjust); + + private double Diameter => Radius * 2; + + private double XAdjust => (ActualWidth - Diameter) / 2; + + private double YAdjust => (ActualHeight - Diameter) / 2; + + private int TotalCount => ErrorCount + SuccessCount + PendingCount; + + public int ErrorCount + { + get => (int)GetValue(ErrorCountProperty); + set + { + SetValue(ErrorCountProperty, value); + } + } + + public int SuccessCount + { + get => (int)GetValue(SuccessCountProperty); + set + { + SetValue(SuccessCountProperty, value); + } + } + + public int PendingCount + { + get => (int)GetValue(PendingCountProperty); + set + { + SetValue(PendingCountProperty, value); + } + } + + public double Radius + { + get => (double)GetValue(RadiusProperty); + set + { + SetValue(RadiusProperty, value); + } + } + + public double InnerRadius + { + get => (double)GetValue(InnerRadiusProperty); + set + { + SetValue(InnerRadiusProperty, value); + } + } + + public Brush PendingColor + { + get => (Brush)GetValue(PendingColorProperty); + set + { + SetValue(PendingColorProperty, value); + } + } + + public Brush ErrorColor + { + get => (Brush)GetValue(ErrorColorProperty); + set + { + SetValue(ErrorColorProperty, value); + } + } + + public Brush SuccessColor + { + get => (Brush)GetValue(SuccessColorProperty); + set + { + SetValue(SuccessColorProperty, value); + } + } + + protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo) + { + base.OnRenderSizeChanged(sizeInfo); + if (sizeInfo.WidthChanged || sizeInfo.HeightChanged) + { + GenerateMask(); + GeneratePolygons(); + } + } + } +} diff --git a/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestListItemView.xaml b/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestListItemView.xaml index 4d7e261552..ffc8210ced 100644 --- a/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestListItemView.xaml +++ b/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestListItemView.xaml @@ -5,6 +5,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:ghfvs="https://github.com/github/VisualStudio" xmlns:views="clr-namespace:GitHub.VisualStudio.Views" + xmlns:controls="clr-namespace:GitHub.VisualStudio.UI.Controls" mc:Ignorable="d" d:DesignWidth="300" Padding="0 4"> @@ -13,7 +14,7 @@ CommentCount="4" IsCurrent="True" UpdatedAt="2018-01-29" - Checks="Success"> + ChecksSummary="Success"> @@ -95,12 +96,23 @@ - + - - - + + + +