diff --git a/src/GitHub.App/Api/ApiClient.cs b/src/GitHub.App/Api/ApiClient.cs index 632db4c807..09e46411b1 100644 --- a/src/GitHub.App/Api/ApiClient.cs +++ b/src/GitHub.App/Api/ApiClient.cs @@ -88,6 +88,11 @@ public IObservable GetUser() return gitHubClient.User.Current(); } + public IObservable GetUser(string login) + { + return gitHubClient.User.Get(login); + } + public IObservable GetOrganizations() { // Organization.GetAllForCurrent doesn't return all of the information we need (we diff --git a/src/GitHub.App/GitHub.App.csproj b/src/GitHub.App/GitHub.App.csproj index 46cd1945a0..39bce4ff38 100644 --- a/src/GitHub.App/GitHub.App.csproj +++ b/src/GitHub.App/GitHub.App.csproj @@ -212,6 +212,9 @@ + + + @@ -240,7 +243,10 @@ + + + diff --git a/src/GitHub.App/Models/PullRequestReviewModel.cs b/src/GitHub.App/Models/PullRequestReviewModel.cs index ff7ad40d13..aab24ab076 100644 --- a/src/GitHub.App/Models/PullRequestReviewModel.cs +++ b/src/GitHub.App/Models/PullRequestReviewModel.cs @@ -10,5 +10,6 @@ public class PullRequestReviewModel : IPullRequestReviewModel public string Body { get; set; } public PullRequestReviewState State { get; set; } public string CommitId { get; set; } + public DateTimeOffset? SubmittedAt { get; set; } } } diff --git a/src/GitHub.App/SampleData/PullRequestReviewFileCommentViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestReviewFileCommentViewModelDesigner.cs new file mode 100644 index 0000000000..634cea4abc --- /dev/null +++ b/src/GitHub.App/SampleData/PullRequestReviewFileCommentViewModelDesigner.cs @@ -0,0 +1,13 @@ +using System.Reactive; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; + +namespace GitHub.SampleData +{ + public class PullRequestReviewFileCommentViewModelDesigner : IPullRequestReviewFileCommentViewModel + { + public string Body { get; set; } + public string RelativePath { get; set; } + public ReactiveCommand Open { get; } + } +} diff --git a/src/GitHub.App/SampleData/PullRequestReviewViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestReviewViewModelDesigner.cs new file mode 100644 index 0000000000..6e9c3f5686 --- /dev/null +++ b/src/GitHub.App/SampleData/PullRequestReviewViewModelDesigner.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; + +namespace GitHub.SampleData +{ + [ExcludeFromCodeCoverage] + public class PullRequestReviewViewModelDesigner : PanePageViewModelBase, IPullRequestReviewViewModel + { + public PullRequestReviewViewModelDesigner() + { + PullRequestModel = new PullRequestModel( + 419, + "Fix a ton of potential crashers, odd code and redundant calls in ModelService", + new AccountDesigner { Login = "Haacked", IsUser = true }, + DateTimeOffset.Now - TimeSpan.FromDays(2)); + + Model = new PullRequestReviewModel + { + + SubmittedAt = DateTimeOffset.Now - TimeSpan.FromDays(1), + User = new AccountDesigner { Login = "Haacked", IsUser = true }, + }; + + Body = @"Just a few comments. I don't feel too strongly about them though. + +Otherwise, very nice work here! ✨"; + + StateDisplay = "approved"; + + FileComments = new[] + { + new PullRequestReviewFileCommentViewModelDesigner + { + Body = @"These should probably be properties. Most likely they should be readonly properties. I know that makes creating instances of these not look as nice as using property initializers when constructing an instance, but if these properties should never be mutated after construction, then it guides future consumers to the right behavior. + +However, if you're two-way binding these properties to a UI, then ignore the readonly part and make them properties. But in that case they should probably be reactive properties (or implement INPC).", + RelativePath = "src/GitHub.Exports.Reactive/ViewModels/IPullRequestListViewModel.cs", + }, + new PullRequestReviewFileCommentViewModelDesigner + { + Body = "While I have no problems with naming a variable ass I think we should probably avoid swear words in case Microsoft runs their Policheck tool against this code.", + RelativePath = "src/GitHub.App/ViewModels/PullRequestListViewModel.cs", + }, + }; + + OutdatedFileComments = new[] + { + new PullRequestReviewFileCommentViewModelDesigner + { + Body = @"So this is just casting a mutable list to an IReadOnlyList which can be cast back to List. I know we probably won't do that, but I'm thinking of the next person to come along. The safe thing to do is to wrap List with a ReadOnlyList. We have an extension method ToReadOnlyList for observables. Wouldn't be hard to write one for IEnumerable.", + RelativePath = "src/GitHub.Exports.Reactive/ViewModels/IPullRequestListViewModel.cs", + }, + }; + } + + public string Body { get; } + public IReadOnlyList FileComments { get; set; } + public bool IsExpanded { get; set; } + public bool HasDetails { get; set; } + public ILocalRepositoryModel LocalRepository { get; set; } + public IPullRequestReviewModel Model { get; set; } + public ReactiveCommand NavigateToPullRequest { get; } + public IReadOnlyList OutdatedFileComments { get; set; } + public IPullRequestModel PullRequestModel { get; set; } + public string RemoteRepositoryOwner { get; set; } + public string StateDisplay { get; set; } + } +} diff --git a/src/GitHub.App/SampleData/PullRequestUserReviewsViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestUserReviewsViewModelDesigner.cs new file mode 100644 index 0000000000..6f240a0577 --- /dev/null +++ b/src/GitHub.App/SampleData/PullRequestUserReviewsViewModelDesigner.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; + +namespace GitHub.SampleData +{ + [ExcludeFromCodeCoverage] + public class PullRequestUserReviewsViewModelDesigner : PanePageViewModelBase, IPullRequestUserReviewsViewModel + { + public PullRequestUserReviewsViewModelDesigner() + { + User = new AccountDesigner { Login = "Haacked", IsUser = true }; + PullRequestNumber = 123; + PullRequestTitle = "Error handling/bubbling from viewmodels to views to viewhosts"; + Reviews = new[] + { + new PullRequestReviewViewModelDesigner() + { + IsExpanded = true, + HasDetails = true, + FileComments = new PullRequestReviewFileCommentViewModel[0], + StateDisplay = "approved", + Model = new PullRequestReviewModel + { + State = PullRequestReviewState.Approved, + SubmittedAt = DateTimeOffset.Now - TimeSpan.FromDays(1), + User = User, + }, + }, + new PullRequestReviewViewModelDesigner() + { + IsExpanded = true, + HasDetails = true, + StateDisplay = "requested changes", + Model = new PullRequestReviewModel + { + State = PullRequestReviewState.ChangesRequested, + SubmittedAt = DateTimeOffset.Now - TimeSpan.FromDays(2), + User = User, + }, + }, + new PullRequestReviewViewModelDesigner() + { + IsExpanded = false, + HasDetails = false, + StateDisplay = "commented", + Model = new PullRequestReviewModel + { + State = PullRequestReviewState.Commented, + SubmittedAt = DateTimeOffset.Now - TimeSpan.FromDays(2), + User = User, + }, + } + }; + } + + public ILocalRepositoryModel LocalRepository { get; set; } + public string RemoteRepositoryOwner { get; set; } + public int PullRequestNumber { get; set; } + public IAccount User { get; set; } + public IReadOnlyList Reviews { get; set; } + public string PullRequestTitle { get; set; } + public ReactiveCommand NavigateToPullRequest { get; } + + public Task InitializeAsync(ILocalRepositoryModel localRepository, IConnection connection, string owner, string repo, int pullRequestNumber, string login) + { + return Task.CompletedTask; + } + } +} diff --git a/src/GitHub.App/Services/ModelService.cs b/src/GitHub.App/Services/ModelService.cs index 15f1321021..1c86ea394b 100644 --- a/src/GitHub.App/Services/ModelService.cs +++ b/src/GitHub.App/Services/ModelService.cs @@ -57,6 +57,13 @@ public IObservable GetCurrentUser() return GetUserFromCache().Select(Create); } + public IObservable GetUser(string login) + { + return hostCache.GetAndRefreshObject("user|" + login, + () => ApiClient.GetUser(login).Select(AccountCacheItem.Create), TimeSpan.FromMinutes(5), TimeSpan.FromDays(7)) + .Select(Create); + } + public IObservable GetGitIgnoreTemplates() { return Observable.Defer(() => @@ -398,6 +405,7 @@ async Task> GetPullRequestReviews(string owner, s Body = y.Body, CommitId = y.Commit.Oid, State = FromGraphQL(y.State), + SubmittedAt = y.SubmittedAt, User = Create(y.Author.Login, y.Author.AvatarUrl(null)) }).ToList() }); @@ -623,6 +631,7 @@ IPullRequestModel Create(PullRequestCacheItem prCacheItem) Body = x.Body, State = x.State, CommitId = x.CommitId, + SubmittedAt = x.SubmittedAt, }).ToList(), ReviewComments = prCacheItem.ReviewComments.Select(x => (IPullRequestReviewCommentModel)new PullRequestReviewCommentModel @@ -892,6 +901,7 @@ public PullRequestReviewCacheItem(IPullRequestReviewModel review) }; Body = review.Body; State = review.State; + SubmittedAt = review.SubmittedAt; } public long Id { get; set; } @@ -900,6 +910,7 @@ public PullRequestReviewCacheItem(IPullRequestReviewModel review) public string Body { get; set; } public GitHub.Models.PullRequestReviewState State { get; set; } public string CommitId { get; set; } + public DateTimeOffset? SubmittedAt { get; set; } } public class PullRequestReviewCommentCacheItem diff --git a/src/GitHub.App/ViewModels/GitHubPane/GitHubPaneViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/GitHubPaneViewModel.cs index d4784540a4..1d949821fe 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/GitHubPaneViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/GitHubPaneViewModel.cs @@ -33,6 +33,7 @@ public sealed class GitHubPaneViewModel : ViewModelBase, IGitHubPaneViewModel, I { static readonly ILogger log = LogManager.ForContext(); static readonly Regex pullUri = CreateRoute("/:owner/:repo/pull/:number"); + static readonly Regex pullUserReviewsUri = CreateRoute("/:owner/:repo/pull/:number/reviews/:login"); readonly IViewViewModelFactory viewModelFactory; readonly ISimpleApiClientFactory apiClientFactory; @@ -243,6 +244,14 @@ public async Task NavigateTo(Uri uri) var number = int.Parse(match.Groups["number"].Value); await ShowPullRequest(owner, repo, number); } + else if ((match = pullUserReviewsUri.Match(uri.AbsolutePath))?.Success == true) + { + var owner = match.Groups["owner"].Value; + var repo = match.Groups["repo"].Value; + var number = int.Parse(match.Groups["number"].Value); + var login = match.Groups["login"].Value; + await ShowPullRequestReviews(owner, repo, number, login); + } else { throw new NotSupportedException("Unrecognised GitHub pane URL: " + uri.AbsolutePath); @@ -282,6 +291,20 @@ public Task ShowPullRequest(string owner, string repo, int number) x => x.RemoteRepositoryOwner == owner && x.LocalRepository.Name == repo && x.Number == number); } + /// + public Task ShowPullRequestReviews(string owner, string repo, int number, string login) + { + Guard.ArgumentNotNull(owner, nameof(owner)); + Guard.ArgumentNotNull(repo, nameof(repo)); + + return NavigateTo( + x => x.InitializeAsync(LocalRepository, Connection, owner, repo, number, login), + x => x.RemoteRepositoryOwner == owner && + x.LocalRepository.Name == repo && + x.PullRequestNumber == number && + x.User.Login == login); + } + async Task CreateInitializeTask(IServiceProvider paneServiceProvider) { await UpdateContent(teamExplorerContext.ActiveRepository); @@ -408,7 +431,7 @@ static async Task IsValidRepository(ISimpleApiClient client) static Regex CreateRoute(string route) { // Build RegEx from route (:foo to named group (?[\w_.-]+)). - var routeFormat = new Regex("(:([a-z]+))\\b").Replace(route, @"(?<$2>[\w_.-]+)"); + var routeFormat = "^" + new Regex("(:([a-z]+))\\b").Replace(route, @"(?<$2>[\w_.-]+)") + "$"; return new Regex(routeFormat, RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase); } } diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs index 145493c5ee..6144bd920f 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs @@ -18,6 +18,7 @@ using LibGit2Sharp; using ReactiveUI; using Serilog; +using static System.FormattableString; namespace GitHub.ViewModels.GitHubPane { @@ -636,7 +637,16 @@ async Task DoSyncSubmodules(object unused) void DoShowReview(object item) { - // TODO + var review = (PullRequestReviewSummaryViewModel)item; + + if (review.State == PullRequestReviewState.Pending) + { + throw new NotImplementedException(); + } + else + { + NavigateTo(Invariant($"{RemoteRepositoryOwner}/{LocalRepository.Name}/pull/{Number}/reviews/{review.User.Login}")); + } } class CheckoutCommandState : IPullRequestCheckoutState diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewFileCommentViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewFileCommentViewModel.cs new file mode 100644 index 0000000000..bc6b706455 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewFileCommentViewModel.cs @@ -0,0 +1,45 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Reactive; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// A view model for a file comment in a . + /// + public class PullRequestReviewFileCommentViewModel : IPullRequestReviewFileCommentViewModel + { + [SuppressMessage("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields", Justification = "This will be used in a later PR")] + readonly IPullRequestEditorService editorService; + [SuppressMessage("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields", Justification = "This will be used in a later PR")] + readonly IPullRequestSession session; + readonly IPullRequestReviewCommentModel model; + + public PullRequestReviewFileCommentViewModel( + IPullRequestEditorService editorService, + IPullRequestSession session, + IPullRequestReviewCommentModel model) + { + Guard.ArgumentNotNull(editorService, nameof(editorService)); + Guard.ArgumentNotNull(session, nameof(session)); + Guard.ArgumentNotNull(model, nameof(model)); + + this.editorService = editorService; + this.session = session; + this.model = model; + } + + /// + public string Body => model.Body; + + /// + public string RelativePath => model.Path; + + /// + public ReactiveCommand Open { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewViewModel.cs new file mode 100644 index 0000000000..f84e2a9d8e --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewViewModel.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; +using Serilog; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// View model for displaying details of a pull request review. + /// + public class PullRequestReviewViewModel : ViewModelBase, IPullRequestReviewViewModel + { + bool isExpanded; + + /// + /// Initializes a new instance of the class. + /// + /// The pull request editor service. + /// The pull request session. + /// The pull request model. + /// The pull request review model. + public PullRequestReviewViewModel( + IPullRequestEditorService editorService, + IPullRequestSession session, + IPullRequestModel pullRequest, + IPullRequestReviewModel model) + { + Guard.ArgumentNotNull(editorService, nameof(editorService)); + Guard.ArgumentNotNull(session, nameof(session)); + Guard.ArgumentNotNull(model, nameof(model)); + + Model = model; + Body = string.IsNullOrWhiteSpace(Model.Body) ? null : Model.Body; + StateDisplay = ToString(Model.State); + + var comments = new List(); + var outdated = new List(); + + foreach (var comment in pullRequest.ReviewComments) + { + if (comment.PullRequestReviewId == model.Id) + { + var vm = new PullRequestReviewFileCommentViewModel( + editorService, + session, + comment); + + if (comment.Position.HasValue) + comments.Add(vm); + else + outdated.Add(vm); + } + } + + FileComments = comments; + OutdatedFileComments = outdated; + + HasDetails = Body != null || + FileComments.Count > 0 || + OutdatedFileComments.Count > 0; + } + + /// + public IPullRequestReviewModel Model { get; } + + /// + public string Body { get; } + + /// + public string StateDisplay { get; } + + /// + public bool IsExpanded + { + get { return isExpanded; } + set { this.RaiseAndSetIfChanged(ref isExpanded, value); } + } + + /// + public bool HasDetails { get; } + + /// + public IReadOnlyList FileComments { get; } + + /// + public IReadOnlyList OutdatedFileComments { get; } + + static string ToString(PullRequestReviewState state) + { + switch (state) + { + case PullRequestReviewState.Approved: + return "approved"; + case PullRequestReviewState.ChangesRequested: + return "requested changes"; + case PullRequestReviewState.Commented: + case PullRequestReviewState.Dismissed: + return "commented"; + default: + throw new NotSupportedException(); + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestUserReviewsViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestUserReviewsViewModel.cs new file mode 100644 index 0000000000..9bcb3b7905 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestUserReviewsViewModel.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; +using Serilog; +using static System.FormattableString; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Displays all reviews made by a user on a pull request. + /// + [Export(typeof(IPullRequestUserReviewsViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class PullRequestUserReviewsViewModel : PanePageViewModelBase, IPullRequestUserReviewsViewModel + { + static readonly ILogger log = LogManager.ForContext(); + + readonly IPullRequestEditorService editorService; + readonly IPullRequestSessionManager sessionManager; + readonly IModelServiceFactory modelServiceFactory; + IModelService modelService; + IPullRequestSession session; + IAccount user; + string title; + IReadOnlyList reviews; + + [ImportingConstructor] + public PullRequestUserReviewsViewModel( + IPullRequestEditorService editorService, + IPullRequestSessionManager sessionManager, + IModelServiceFactory modelServiceFactory) + { + Guard.ArgumentNotNull(editorService, nameof(editorService)); + Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); + Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory)); + + this.editorService = editorService; + this.sessionManager = sessionManager; + this.modelServiceFactory = modelServiceFactory; + + NavigateToPullRequest = ReactiveCommand.Create().OnExecuteCompleted(_ => + NavigateTo(Invariant($"{LocalRepository.Owner}/{LocalRepository.Name}/pull/{PullRequestNumber}"))); + } + + /// + public ILocalRepositoryModel LocalRepository { get; private set; } + + /// + public string RemoteRepositoryOwner { get; private set; } + + /// + public int PullRequestNumber { get; private set; } + + public IAccount User + { + get { return user; } + private set { this.RaiseAndSetIfChanged(ref user, value); } + } + + /// + public IReadOnlyList Reviews + { + get { return reviews; } + private set { this.RaiseAndSetIfChanged(ref reviews, value); } + } + + /// + public string PullRequestTitle + { + get { return title; } + private set { this.RaiseAndSetIfChanged(ref title, value); } + } + + /// + public ReactiveCommand NavigateToPullRequest { get; } + + /// + public async Task InitializeAsync( + ILocalRepositoryModel localRepository, + IConnection connection, + string owner, + string repo, + int pullRequestNumber, + string login) + { + if (repo != localRepository.Name) + { + throw new NotSupportedException(); + } + + IsLoading = true; + + try + { + LocalRepository = localRepository; + RemoteRepositoryOwner = owner; + PullRequestNumber = pullRequestNumber; + modelService = await modelServiceFactory.CreateAsync(connection); + User = await modelService.GetUser(login); + await Refresh(); + } + finally + { + IsLoading = false; + } + } + + /// + public override async Task Refresh() + { + try + { + Error = null; + IsBusy = true; + var pullRequest = await modelService.GetPullRequest(RemoteRepositoryOwner, LocalRepository.Name, PullRequestNumber); + await Load(User, pullRequest); + } + catch (Exception ex) + { + log.Error( + ex, + "Error loading pull request reviews {Owner}/{Repo}/{Number} from {Address}", + RemoteRepositoryOwner, + LocalRepository.Name, + PullRequestNumber, + modelService.ApiClient.HostAddress.Title); + Error = ex; + IsBusy = false; + } + } + + /// + async Task Load(IAccount author, IPullRequestModel pullRequest) + { + IsBusy = true; + + try + { + session = await sessionManager.GetSession(pullRequest); + User = author; + PullRequestTitle = pullRequest.Title; + + var reviews = new List(); + var isFirst = true; + + foreach (var review in pullRequest.Reviews.OrderByDescending(x => x.SubmittedAt)) + { + if (review.User.Login == author.Login && + review.State != PullRequestReviewState.Pending) + { + var vm = new PullRequestReviewViewModel(editorService, session, pullRequest, review); + vm.IsExpanded = isFirst; + reviews.Add(vm); + isFirst = false; + } + } + + Reviews = reviews; + } + finally + { + IsBusy = false; + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/Api/IApiClient.cs b/src/GitHub.Exports.Reactive/Api/IApiClient.cs index 66e0a19321..32fc0cfd70 100644 --- a/src/GitHub.Exports.Reactive/Api/IApiClient.cs +++ b/src/GitHub.Exports.Reactive/Api/IApiClient.cs @@ -16,6 +16,7 @@ public interface IApiClient IObservable CreateRepository(NewRepository repository, string login, bool isUser); IObservable CreateGist(NewGist newGist); IObservable GetUser(); + IObservable GetUser(string login); IObservable GetOrganizations(); /// /// Retrieves all repositories that belong to this user. diff --git a/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj b/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj index 07a1464237..d50ae7ae5e 100644 --- a/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj +++ b/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj @@ -197,7 +197,10 @@ + + + diff --git a/src/GitHub.Exports.Reactive/Services/IModelService.cs b/src/GitHub.Exports.Reactive/Services/IModelService.cs index f5e11a9168..8dc757a57e 100644 --- a/src/GitHub.Exports.Reactive/Services/IModelService.cs +++ b/src/GitHub.Exports.Reactive/Services/IModelService.cs @@ -17,6 +17,7 @@ public interface IModelService : IDisposable IApiClient ApiClient { get; } IObservable GetCurrentUser(); + IObservable GetUser(string login); IObservable InsertUser(AccountCacheItem user); IObservable> GetAccounts(); IObservable GetRepository(string owner, string repo); diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewFileCommentViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewFileCommentViewModel.cs new file mode 100644 index 0000000000..58a7cfb0aa --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewFileCommentViewModel.cs @@ -0,0 +1,27 @@ +using System; +using System.Reactive; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Represents a view model for a file comment in an . + /// + public interface IPullRequestReviewFileCommentViewModel + { + /// + /// Gets the body of the comment. + /// + string Body { get; } + + /// + /// Gets the path to the file, relative to the root of the repository. + /// + string RelativePath { get; } + + /// + /// Gets a command which opens the comment in a diff view. + /// + ReactiveCommand Open { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewViewModel.cs new file mode 100644 index 0000000000..5a680e9eee --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewViewModel.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using GitHub.Models; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Represents a view model that displays a pull request review. + /// + public interface IPullRequestReviewViewModel : IViewModel + { + /// + /// Gets the underlying pull request review model. + /// + IPullRequestReviewModel Model { get; } + + /// + /// Gets the body of the review. + /// + string Body { get; } + + /// + /// Gets the state of the pull request review as a string. + /// + string StateDisplay { get; } + + /// + /// Gets a value indicating whether the pull request review should initially be expanded. + /// + bool IsExpanded { get; } + + /// + /// Gets a value indicating whether the pull request review has a body or file comments. + /// + bool HasDetails { get; } + + /// + /// Gets a list of the file comments in the review. + /// + IReadOnlyList FileComments { get; } + + /// + /// Gets a list of outdated file comments in the review. + /// + IReadOnlyList OutdatedFileComments { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestUserReviewsViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestUserReviewsViewModel.cs new file mode 100644 index 0000000000..df18bf8547 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestUserReviewsViewModel.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Displays all reviews made by a user on a pull request. + /// + public interface IPullRequestUserReviewsViewModel : IPanePageViewModel + { + /// + /// Gets the local repository. + /// + ILocalRepositoryModel LocalRepository { get; } + + /// + /// Gets the owner of the remote repository that contains the pull request. + /// + /// + /// The remote repository may be different from the local repository if the local + /// repository is a fork and the user is viewing pull requests from the parent repository. + /// + string RemoteRepositoryOwner { get; } + + /// + /// Gets the number of the pull request. + /// + int PullRequestNumber { get; } + + /// + /// Gets the reviews made by the . + /// + IReadOnlyList Reviews { get; } + + /// + /// Gets the title of the pull request. + /// + string PullRequestTitle { get; } + + /// + /// Gets the user whose reviews are being shown. + /// + IAccount User { get; } + + /// + /// Gets a command that navigates to the parent pull request in the GitHub pane. + /// + ReactiveCommand NavigateToPullRequest { get; } + + /// + /// Initializes the view model, loading data from the API. + /// + /// The local repository. + /// The connection to the repository host. + /// The pull request's repository owner. + /// The pull request's repository name. + /// The pull request number. + /// The user's login. + Task InitializeAsync( + ILocalRepositoryModel localRepository, + IConnection connection, + string owner, + string repo, + int pullRequestNumber, + string login); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Models/IPullRequestReviewModel.cs b/src/GitHub.Exports/Models/IPullRequestReviewModel.cs index d59d401e1e..4e633ccb68 100644 --- a/src/GitHub.Exports/Models/IPullRequestReviewModel.cs +++ b/src/GitHub.Exports/Models/IPullRequestReviewModel.cs @@ -67,5 +67,10 @@ public interface IPullRequestReviewModel /// Gets the SHA of the commit that the review was submitted on. /// string CommitId { get; } + + /// + /// Gets the date/time that the review was submitted. + /// + DateTimeOffset? SubmittedAt { get; } } } diff --git a/src/GitHub.Exports/ViewModels/GitHubPane/IGitHubPaneViewModel.cs b/src/GitHub.Exports/ViewModels/GitHubPane/IGitHubPaneViewModel.cs index 5b6771c4d3..dc6c4a669f 100644 --- a/src/GitHub.Exports/ViewModels/GitHubPane/IGitHubPaneViewModel.cs +++ b/src/GitHub.Exports/ViewModels/GitHubPane/IGitHubPaneViewModel.cs @@ -90,5 +90,14 @@ public interface IGitHubPaneViewModel : IViewModel /// The repository name. /// The pull rqeuest number. Task ShowPullRequest(string owner, string repo, int number); + + /// + /// Shows the pull requests reviews authored by a user. + /// + /// The repository owner. + /// The repository name. + /// The pull rqeuest number. + /// The user login. + Task ShowPullRequestReviews(string owner, string repo, int number, string login); } } \ No newline at end of file diff --git a/src/GitHub.UI/Assets/Controls.xaml b/src/GitHub.UI/Assets/Controls.xaml index 47977c775a..78925a17ec 100644 --- a/src/GitHub.UI/Assets/Controls.xaml +++ b/src/GitHub.UI/Assets/Controls.xaml @@ -419,7 +419,130 @@ + + + + + + + + + + diff --git a/src/GitHub.UI/Controls/TrimmedPathTextBlock.cs b/src/GitHub.UI/Controls/TrimmedPathTextBlock.cs new file mode 100644 index 0000000000..d272f9fd77 --- /dev/null +++ b/src/GitHub.UI/Controls/TrimmedPathTextBlock.cs @@ -0,0 +1,161 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; + +namespace GitHub.UI +{ + /// + /// TextBlock that displays a path and intelligently trims with ellipsis when the path doesn't + /// fit in the allocated size. + /// + /// + /// When displaying a path that is too long for its allocated space, we need to trim the path + /// with ellipses intelligently instead of simply trimming the end (as this is the filename + /// which is the most important part!). This control trims a path in the following manner with + /// decreasing allocated space: + /// + /// - VisualStudio\src\GitHub.UI\Controls\TrimmedPathTextBlock.cs + /// - VisualStudio\...\GitHub.UI\Controls\TrimmedPathTextBlock.cs + /// - VisualStudio\...\...\Controls\TrimmedPathTextBlock.cs + /// - VisualStudio\...\...\...\TrimmedPathTextBlock.cs + /// - ...\...\...\...\TrimmedPathTextBlock.cs + /// + public class TrimmedPathTextBlock : FrameworkElement + { + public static readonly DependencyProperty FontFamilyProperty = + TextBlock.FontFamilyProperty.AddOwner(typeof(TrimmedPathTextBlock)); + public static readonly DependencyProperty FontSizeProperty = + TextBlock.FontSizeProperty.AddOwner(typeof(TrimmedPathTextBlock)); + public static readonly DependencyProperty FontStretchProperty = + TextBlock.FontStretchProperty.AddOwner(typeof(TrimmedPathTextBlock)); + public static readonly DependencyProperty FontStyleProperty = + TextBlock.FontStyleProperty.AddOwner(typeof(TrimmedPathTextBlock)); + public static readonly DependencyProperty FontWeightProperty = + TextBlock.FontWeightProperty.AddOwner(typeof(TrimmedPathTextBlock)); + public static readonly DependencyProperty ForegroundProperty = + TextBlock.ForegroundProperty.AddOwner(typeof(TrimmedPathTextBlock)); + public static readonly DependencyProperty TextProperty = + TextBlock.TextProperty.AddOwner( + typeof(TrimmedPathTextBlock), + new FrameworkPropertyMetadata( + null, + FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsRender, + TextChanged)); + + FormattedText formattedText; + FormattedText renderText; + + public FontFamily FontFamily + { + get { return (FontFamily)GetValue(FontFamilyProperty); } + set { SetValue(FontFamilyProperty, value); } + } + + public double FontSize + { + get { return (double)GetValue(FontSizeProperty); } + set { SetValue(FontSizeProperty, value); } + } + + public FontStretch FontStretch + { + get { return (FontStretch)GetValue(FontStretchProperty); } + set { SetValue(FontStretchProperty, value); } + } + + public FontStyle FontStyle + { + get { return (FontStyle)GetValue(FontStyleProperty); } + set { SetValue(FontStyleProperty, value); } + } + + public FontWeight FontWeight + { + get { return (FontWeight)GetValue(FontWeightProperty); } + set { SetValue(FontWeightProperty, value); } + } + + public Brush Foreground + { + get { return (Brush)GetValue(ForegroundProperty); } + set { SetValue(ForegroundProperty, value); } + } + + public string Text + { + get { return (string)GetValue(TextProperty); } + set { SetValue(TextProperty, value); } + } + + protected FormattedText FormattedText + { + get + { + if (formattedText == null && Text != null) + { + formattedText = CreateFormattedText(Text); + } + + return formattedText; + } + } + + protected override Size MeasureOverride(Size availableSize) + { + var parts = Text + .Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }) + .ToList(); + var nextPart = Math.Min(1, parts.Count - 1); + + while (true) + { + renderText = CreateFormattedText(string.Join(Path.DirectorySeparatorChar.ToString(), parts)); + + if (renderText.Width <= availableSize.Width || nextPart == -1) + break; + + parts[nextPart] = "\u2026"; + + if (nextPart == 0) + nextPart = -1; + else if (nextPart == parts.Count - 2) + nextPart = 0; + else + nextPart++; + }; + + return new Size(renderText.Width, renderText.Height); + } + + protected override void OnRender(DrawingContext drawingContext) + { + drawingContext.DrawText(renderText, new Point()); + } + + FormattedText CreateFormattedText(string text) + { + return new FormattedText( + text, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + new Typeface(FontFamily, FontStyle, FontWeight, FontStretch), + FontSize, + Foreground); + } + + static void TextChanged(object sender, DependencyPropertyChangedEventArgs e) + { + var textBlock = sender as TrimmedPathTextBlock; + + if (textBlock != null) + { + textBlock.formattedText = null; + textBlock.renderText = null; + } + } + } +} diff --git a/src/GitHub.UI/Converters/TrimNewlinesConverter.cs b/src/GitHub.UI/Converters/TrimNewlinesConverter.cs new file mode 100644 index 0000000000..d7dee09fd7 --- /dev/null +++ b/src/GitHub.UI/Converters/TrimNewlinesConverter.cs @@ -0,0 +1,20 @@ +using System; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace GitHub.UI +{ + /// + /// An that trims newlines and tabs from a string and replaces them + /// with spaces. + /// + public class TrimNewlinesConverter : ValueConverterMarkupExtension + { + public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var text = value as string; + if (String.IsNullOrEmpty(text)) return null; + return Regex.Replace(text, @"\t|\n|\r", " "); + } + } +} diff --git a/src/GitHub.UI/GitHub.UI.csproj b/src/GitHub.UI/GitHub.UI.csproj index 6fcc20e64a..20a45d3522 100644 --- a/src/GitHub.UI/GitHub.UI.csproj +++ b/src/GitHub.UI/GitHub.UI.csproj @@ -88,6 +88,7 @@ Spinner.xaml + @@ -98,6 +99,7 @@ + diff --git a/src/GitHub.VisualStudio.UI/Styles/ThemeBlue.xaml b/src/GitHub.VisualStudio.UI/Styles/ThemeBlue.xaml index 215464137f..02e1b632f4 100644 --- a/src/GitHub.VisualStudio.UI/Styles/ThemeBlue.xaml +++ b/src/GitHub.VisualStudio.UI/Styles/ThemeBlue.xaml @@ -64,5 +64,6 @@ - + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Styles/ThemeDark.xaml b/src/GitHub.VisualStudio.UI/Styles/ThemeDark.xaml index 47ec2ac1f9..b9994994e7 100644 --- a/src/GitHub.VisualStudio.UI/Styles/ThemeDark.xaml +++ b/src/GitHub.VisualStudio.UI/Styles/ThemeDark.xaml @@ -65,4 +65,5 @@ + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Styles/ThemeLight.xaml b/src/GitHub.VisualStudio.UI/Styles/ThemeLight.xaml index 65d7a28d90..85fcdf79b5 100644 --- a/src/GitHub.VisualStudio.UI/Styles/ThemeLight.xaml +++ b/src/GitHub.VisualStudio.UI/Styles/ThemeLight.xaml @@ -65,4 +65,5 @@ + \ No newline at end of file diff --git a/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj b/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj index ac1602410c..0c9e40efb0 100644 --- a/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj +++ b/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj @@ -382,6 +382,9 @@ GitHubPaneView.xaml + + PullRequestFileCommentsView.xaml + PullRequestReviewSummaryView.xaml @@ -403,6 +406,9 @@ NotAGitRepositoryView.xaml + + PullRequestUserReviewsView.xaml + RepositoryPublishView.xaml @@ -540,6 +546,10 @@ Designer MSBuild:Compile + + MSBuild:Compile + Designer + MSBuild:Compile Designer @@ -572,6 +582,10 @@ MSBuild:Compile Designer + + MSBuild:Compile + Designer + MSBuild:Compile Designer diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFileCommentsView.xaml b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFileCommentsView.xaml new file mode 100644 index 0000000000..b8032cc3cc --- /dev/null +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFileCommentsView.xaml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFileCommentsView.xaml.cs b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFileCommentsView.xaml.cs new file mode 100644 index 0000000000..ac60ef8035 --- /dev/null +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFileCommentsView.xaml.cs @@ -0,0 +1,28 @@ +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; + +namespace GitHub.VisualStudio.Views.GitHubPane +{ + /// + /// Interaction logic for PullRequestFileCommentsView.xaml + /// + public partial class PullRequestFileCommentsView : UserControl + { + public PullRequestFileCommentsView() + { + InitializeComponent(); + } + } +} diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestUserReviewsView.xaml b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestUserReviewsView.xaml new file mode 100644 index 0000000000..21aaddeecb --- /dev/null +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestUserReviewsView.xaml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + Reviews by + + for + + # + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Description + + + + + + Comments + + + + + + Outdated comments + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestUserReviewsView.xaml.cs b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestUserReviewsView.xaml.cs new file mode 100644 index 0000000000..f3e1a31cc5 --- /dev/null +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestUserReviewsView.xaml.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.Composition; +using System.Windows.Controls; +using GitHub.Exports; +using GitHub.ViewModels.GitHubPane; + +namespace GitHub.VisualStudio.Views.GitHubPane +{ + [ExportViewFor(typeof(IPullRequestUserReviewsViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public partial class PullRequestUserReviewsView : UserControl + { + public PullRequestUserReviewsView() + { + InitializeComponent(); + } + } +} diff --git a/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestReviewViewModelTests.cs b/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestReviewViewModelTests.cs new file mode 100644 index 0000000000..f740ce9be2 --- /dev/null +++ b/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestReviewViewModelTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Linq; +using GitHub.Models; +using GitHub.Services; +using GitHub.ViewModels.GitHubPane; +using NSubstitute; +using NUnit.Framework; + +namespace UnitTests.GitHub.App.ViewModels.GitHubPane +{ + public class PullRequestReviewViewModelTests + { + [Test] + public void Empty_Body_Is_Exposed_As_Null() + { + var pr = CreatePullRequest(); + ((PullRequestReviewModel)pr.Reviews[0]).Body = string.Empty; + + var target = CreateTarget(pullRequest: pr); + + Assert.That(target.Body, Is.Null); + } + + [Test] + public void Creates_FileComments_And_OutdatedComments() + { + var pr = CreatePullRequest(); + ((PullRequestReviewModel)pr.Reviews[0]).Body = string.Empty; + + var target = CreateTarget(pullRequest: pr); + + Assert.That(target.FileComments, Has.Count.EqualTo(2)); + Assert.That(target.OutdatedFileComments, Has.Count.EqualTo(1)); + } + + [Test] + public void HasDetails_True_When_Has_Body() + { + var pr = CreatePullRequest(); + pr.ReviewComments = new IPullRequestReviewCommentModel[0]; + + var target = CreateTarget(pullRequest: pr); + + Assert.That(target.HasDetails, Is.True); + } + + [Test] + public void HasDetails_True_When_Has_Comments() + { + var pr = CreatePullRequest(); + ((PullRequestReviewModel)pr.Reviews[0]).Body = string.Empty; + + var target = CreateTarget(pullRequest: pr); + + Assert.That(target.HasDetails, Is.True); + } + + [Test] + public void HasDetails_False_When_Has_No_Body_Or_Comments() + { + var pr = CreatePullRequest(); + ((PullRequestReviewModel)pr.Reviews[0]).Body = string.Empty; + pr.ReviewComments = new IPullRequestReviewCommentModel[0]; + + var target = CreateTarget(pullRequest: pr); + + Assert.That(target.HasDetails, Is.False); + } + + PullRequestReviewViewModel CreateTarget( + IPullRequestEditorService editorService = null, + IPullRequestSession session = null, + IPullRequestModel pullRequest = null, + IPullRequestReviewModel model = null) + { + editorService = editorService ?? Substitute.For(); + session = session ?? Substitute.For(); + pullRequest = pullRequest ?? CreatePullRequest(); + model = model ?? pullRequest.Reviews[0]; + + return new PullRequestReviewViewModel( + editorService, + session, + pullRequest, + model); + } + + private PullRequestModel CreatePullRequest( + int number = 5, + string title = "Pull Request Title", + string body = "Pull Request Body", + IAccount author = null, + DateTimeOffset? createdAt = null) + { + author = author ?? Substitute.For(); + createdAt = createdAt ?? DateTimeOffset.Now; + + return new PullRequestModel(number, title, author, createdAt.Value) + { + Body = body, + Reviews = new[] + { + new PullRequestReviewModel + { + Id = 1, + Body = "Looks good to me!", + State = PullRequestReviewState.Approved, + }, + new PullRequestReviewModel + { + Id = 2, + Body = "Changes please.", + State = PullRequestReviewState.ChangesRequested, + }, + }, + ReviewComments = new[] + { + new PullRequestReviewCommentModel + { + Body = "I like this.", + PullRequestReviewId = 1, + Position = 10, + }, + new PullRequestReviewCommentModel + { + Body = "This is good.", + PullRequestReviewId = 1, + Position = 11, + }, + new PullRequestReviewCommentModel + { + Body = "Fine, but outdated.", + PullRequestReviewId = 1, + Position = null, + }, + new PullRequestReviewCommentModel + { + Body = "Not great.", + PullRequestReviewId = 2, + Position = 20, + }, + new PullRequestReviewCommentModel + { + Body = "This sucks.", + PullRequestReviewId = 2, + Position = 21, + }, + new PullRequestReviewCommentModel + { + Body = "Bad and old.", + PullRequestReviewId = 2, + Position = null, + }, + } + }; + } + } +} diff --git a/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestUserReviewsViewModelTests.cs b/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestUserReviewsViewModelTests.cs new file mode 100644 index 0000000000..a757fa3dd7 --- /dev/null +++ b/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestUserReviewsViewModelTests.cs @@ -0,0 +1,232 @@ +using System; +using System.Linq; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Factories; +using GitHub.Models; +using GitHub.Services; +using GitHub.ViewModels.GitHubPane; +using NSubstitute; +using NUnit.Framework; + +namespace UnitTests.GitHub.App.ViewModels.GitHubPane +{ + public class PullRequestUserReviewsViewModelTests + { + const string AuthorLogin = "grokys"; + + [Test] + public async Task InitializeAsync_Loads_User() + { + var modelSerivce = Substitute.For(); + var user = Substitute.For(); + modelSerivce.GetUser(AuthorLogin).Returns(Observable.Return(user)); + + var target = CreateTarget( + modelServiceFactory: CreateFactory(modelSerivce)); + + await Initialize(target); + + Assert.That(target.User, Is.SameAs(user)); + } + + [Test] + public async Task InitializeAsync_Creates_Reviews() + { + var author = Substitute.For(); + author.Login.Returns(AuthorLogin); + + var anotherAuthor = Substitute.For(); + anotherAuthor.Login.Returns("SomeoneElse"); + + var pullRequest = new PullRequestModel(5, "PR title", author, DateTimeOffset.Now) + { + Reviews = new[] + { + new PullRequestReviewModel + { + User = author, + State = PullRequestReviewState.Approved, + }, + new PullRequestReviewModel + { + User = author, + State = PullRequestReviewState.ChangesRequested, + }, + new PullRequestReviewModel + { + User = anotherAuthor, + State = PullRequestReviewState.Approved, + }, + new PullRequestReviewModel + { + User = author, + State = PullRequestReviewState.Dismissed, + }, + new PullRequestReviewModel + { + User = author, + State = PullRequestReviewState.Pending, + }, + }, + ReviewComments = new IPullRequestReviewCommentModel[0], + }; + + var modelSerivce = Substitute.For(); + modelSerivce.GetUser(AuthorLogin).Returns(Observable.Return(author)); + modelSerivce.GetPullRequest("owner", "repo", 5).Returns(Observable.Return(pullRequest)); + + var user = Substitute.For(); + var target = CreateTarget( + modelServiceFactory: CreateFactory(modelSerivce)); + + await Initialize(target); + + // Should load reviews by the correct author which are not Pending. + Assert.That(target.Reviews, Has.Count.EqualTo(3)); + } + + [Test] + public async Task Orders_Reviews_Descending() + { + var author = Substitute.For(); + author.Login.Returns(AuthorLogin); + + var pullRequest = new PullRequestModel(5, "PR title", author, DateTimeOffset.Now) + { + Reviews = new[] + { + new PullRequestReviewModel + { + User = author, + State = PullRequestReviewState.Approved, + SubmittedAt = DateTimeOffset.Now - TimeSpan.FromDays(2), + }, + new PullRequestReviewModel + { + User = author, + State = PullRequestReviewState.ChangesRequested, + SubmittedAt = DateTimeOffset.Now - TimeSpan.FromDays(3), + }, + new PullRequestReviewModel + { + User = author, + State = PullRequestReviewState.Dismissed, + SubmittedAt = DateTimeOffset.Now - TimeSpan.FromDays(1), + }, + }, + ReviewComments = new IPullRequestReviewCommentModel[0], + }; + + var modelSerivce = Substitute.For(); + modelSerivce.GetUser(AuthorLogin).Returns(Observable.Return(author)); + modelSerivce.GetPullRequest("owner", "repo", 5).Returns(Observable.Return(pullRequest)); + + var user = Substitute.For(); + var target = CreateTarget( + modelServiceFactory: CreateFactory(modelSerivce)); + + await Initialize(target); + + Assert.That( + target.Reviews.Select(x => x.Model.SubmittedAt), + Is.EqualTo(target.Reviews.Select(x => x.Model.SubmittedAt).OrderByDescending(x => x))); + } + + [Test] + public async Task First_Review_Is_Expanded() + { + var author = Substitute.For(); + author.Login.Returns(AuthorLogin); + + var anotherAuthor = Substitute.For(); + author.Login.Returns("SomeoneElse"); + + var pullRequest = new PullRequestModel(5, "PR title", author, DateTimeOffset.Now) + { + Reviews = new[] + { + new PullRequestReviewModel + { + User = author, + State = PullRequestReviewState.Approved, + }, + new PullRequestReviewModel + { + User = author, + State = PullRequestReviewState.ChangesRequested, + }, + new PullRequestReviewModel + { + User = author, + State = PullRequestReviewState.Dismissed, + }, + }, + ReviewComments = new IPullRequestReviewCommentModel[0], + }; + + var modelSerivce = Substitute.For(); + modelSerivce.GetUser(AuthorLogin).Returns(Observable.Return(author)); + modelSerivce.GetPullRequest("owner", "repo", 5).Returns(Observable.Return(pullRequest)); + + var user = Substitute.For(); + var target = CreateTarget( + modelServiceFactory: CreateFactory(modelSerivce)); + + await Initialize(target); + + Assert.That(target.Reviews[0].IsExpanded, Is.True); + Assert.That(target.Reviews[1].IsExpanded, Is.False); + Assert.That(target.Reviews[2].IsExpanded, Is.False); + } + + async Task Initialize( + PullRequestUserReviewsViewModel target, + ILocalRepositoryModel localRepository = null, + IConnection connection = null, + int pullRequestNumber = 5, + string login = AuthorLogin) + { + localRepository = localRepository ?? CreateRepository(); + connection = connection ?? Substitute.For(); + + await target.InitializeAsync( + localRepository, + connection, + localRepository.Owner, + localRepository.Name, + pullRequestNumber, + login); + } + + PullRequestUserReviewsViewModel CreateTarget( + IPullRequestEditorService editorService = null, + IPullRequestSessionManager sessionManager = null, + IModelServiceFactory modelServiceFactory = null) + { + editorService = editorService ?? Substitute.For(); + sessionManager = sessionManager ?? Substitute.For(); + modelServiceFactory = modelServiceFactory ?? Substitute.For(); + + return new PullRequestUserReviewsViewModel( + editorService, + sessionManager, + modelServiceFactory); + } + + IModelServiceFactory CreateFactory(IModelService modelService) + { + var result = Substitute.For(); + result.CreateAsync(null).ReturnsForAnyArgs(modelService); + return result; + } + + ILocalRepositoryModel CreateRepository(string owner = "owner", string name = "repo") + { + var result = Substitute.For(); + result.Owner.Returns(owner); + result.Name.Returns(name); + return result; + } + } +} diff --git a/test/UnitTests/UnitTests.csproj b/test/UnitTests/UnitTests.csproj index b9bd0c647c..819bd3ce00 100644 --- a/test/UnitTests/UnitTests.csproj +++ b/test/UnitTests/UnitTests.csproj @@ -257,6 +257,8 @@ + +