diff --git a/README.md b/README.md index 4ffa2e1416..258111cdc2 100644 --- a/README.md +++ b/README.md @@ -98,3 +98,4 @@ Visit the [Contributor Guidelines](CONTRIBUTING.md) for details on how to contri Copyright 2015 - 2017 GitHub, Inc. Licensed under the [MIT License](LICENSE.md) + diff --git a/appveyor.yml b/appveyor.yml index 63fa013b88..cd32d5c109 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,4 +1,4 @@ -version: '2.4.4.{build}' +version: '2.4.99.{build}' skip_tags: true install: - ps: | diff --git a/lib/.gitignore b/lib/.gitignore new file mode 100644 index 0000000000..296234c026 --- /dev/null +++ b/lib/.gitignore @@ -0,0 +1 @@ +!*.nupkg diff --git a/lib/Octokit.GraphQL.0.0.2-alpha.nupkg b/lib/Octokit.GraphQL.0.0.2-alpha.nupkg new file mode 100644 index 0000000000..88686b7729 Binary files /dev/null and b/lib/Octokit.GraphQL.0.0.2-alpha.nupkg differ diff --git a/src/GitHub.Api/GitHub.Api.csproj b/src/GitHub.Api/GitHub.Api.csproj index a825cfc079..fc35dd136b 100644 --- a/src/GitHub.Api/GitHub.Api.csproj +++ b/src/GitHub.Api/GitHub.Api.csproj @@ -46,6 +46,18 @@ + + ..\..\packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll + True + + + ..\..\packages\Octokit.GraphQL.0.0.2-alpha\lib\netstandard1.1\Octokit.GraphQL.dll + True + + + ..\..\packages\Octokit.GraphQL.0.0.2-alpha\lib\netstandard1.1\Octokit.GraphQL.Core.dll + True + ..\..\packages\Serilog.2.5.0\lib\net46\Serilog.dll True @@ -66,6 +78,9 @@ ApiClientConfiguration_User.cs + + + diff --git a/src/GitHub.Api/GraphQLClientFactory.cs b/src/GitHub.Api/GraphQLClientFactory.cs new file mode 100644 index 0000000000..cd91295935 --- /dev/null +++ b/src/GitHub.Api/GraphQLClientFactory.cs @@ -0,0 +1,41 @@ +using System; +using System.ComponentModel.Composition; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Primitives; +using Octokit.GraphQL; + +namespace GitHub.Api +{ + /// + /// Creates GraphQL s for querying the + /// GitHub GraphQL API. + /// + [Export(typeof(IGraphQLClientFactory))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class GraphQLClientFactory : IGraphQLClientFactory + { + readonly IKeychain keychain; + readonly IProgram program; + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The program details. + [ImportingConstructor] + public GraphQLClientFactory(IKeychain keychain, IProgram program) + { + this.keychain = keychain; + this.program = program; + } + + /// + public Task CreateConnection(HostAddress address) + { + var credentials = new GraphQLKeychainCredentialStore(keychain, address); + var header = new ProductHeaderValue(program.ProductHeader.Name, program.ProductHeader.Version); + return Task.FromResult(new Connection(header, address.GraphQLUri, credentials)); + } + } +} diff --git a/src/GitHub.Api/GraphQLKeychainCredentialStore.cs b/src/GitHub.Api/GraphQLKeychainCredentialStore.cs new file mode 100644 index 0000000000..0098d15983 --- /dev/null +++ b/src/GitHub.Api/GraphQLKeychainCredentialStore.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Primitives; +using Octokit.GraphQL; + +namespace GitHub.Api +{ + /// + /// An Octokit.GraphQL credential store that reads from an . + /// + public class GraphQLKeychainCredentialStore : ICredentialStore + { + readonly IKeychain keychain; + readonly HostAddress address; + + public GraphQLKeychainCredentialStore(IKeychain keychain, HostAddress address) + { + Guard.ArgumentNotNull(keychain, nameof(keychain)); + Guard.ArgumentNotNull(address, nameof(keychain)); + + this.keychain = keychain; + this.address = address; + } + + public async Task GetCredentials() + { + var userPass = await keychain.Load(address).ConfigureAwait(false); + return userPass?.Item2; + } + } +} diff --git a/src/GitHub.Api/IGraphQLClientFactory.cs b/src/GitHub.Api/IGraphQLClientFactory.cs new file mode 100644 index 0000000000..464fab0de8 --- /dev/null +++ b/src/GitHub.Api/IGraphQLClientFactory.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using GitHub.Primitives; + +namespace GitHub.Api +{ + /// + /// Creates GraphQL s for querying the + /// GitHub GraphQL API. + /// + public interface IGraphQLClientFactory + { + /// + /// Creates a new . + /// + /// The address of the server. + /// A task returning the created connection. + Task CreateConnection(HostAddress address); + } +} \ No newline at end of file diff --git a/src/GitHub.Api/packages.config b/src/GitHub.Api/packages.config index bfb877d0d6..6356b0e89c 100644 --- a/src/GitHub.Api/packages.config +++ b/src/GitHub.Api/packages.config @@ -1,4 +1,6 @@  + + \ No newline at end of file diff --git a/src/GitHub.App/Api/ApiClient.cs b/src/GitHub.App/Api/ApiClient.cs index 8aa92a19a6..978bfe3354 100644 --- a/src/GitHub.App/Api/ApiClient.cs +++ b/src/GitHub.App/Api/ApiClient.cs @@ -48,6 +48,27 @@ public IObservable CreateRepository(NewRepository repository, string return (isUser ? client.Create(repository) : client.Create(login, repository)); } + public IObservable PostPullRequestReview( + string owner, + string name, + int number, + string commitId, + string body, + PullRequestReviewEvent e) + { + Guard.ArgumentNotEmptyString(owner, nameof(owner)); + Guard.ArgumentNotEmptyString(name, nameof(name)); + + var review = new PullRequestReviewCreate + { + Body = body, + CommitId = commitId, + Event = e, + }; + + return gitHubClient.PullRequest.Review.Create(owner, name, number, review); + } + public IObservable CreatePullRequestReviewComment( string owner, string name, @@ -88,6 +109,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 @@ -123,30 +149,10 @@ public IObservable GetLicenses() public HostAddress HostAddress { get; } - static string GetSha256Hash(string input) - { - Guard.ArgumentNotEmptyString(input, nameof(input)); - - try - { - using (var sha256 = SHA256.Create()) - { - var bytes = Encoding.UTF8.GetBytes(input); - var hash = sha256.ComputeHash(bytes); - - return string.Join("", hash.Select(b => b.ToString("x2", CultureInfo.InvariantCulture))); - } - } - catch (Exception e) - { - log.Error(e, "IMPOSSIBLE! Generating Sha256 hash caused an exception"); - return null; - } - } - static string GetFingerprint() { - return GetSha256Hash(ProductName + ":" + GetMachineIdentifier()); + var fingerprint = ProductName + ":" + GetMachineIdentifier(); + return fingerprint.GetSha256Hash(); } static string GetMachineNameSafe() @@ -269,11 +275,7 @@ public IObservable GetBranches(string owner, string repo) Guard.ArgumentNotEmptyString(owner, nameof(owner)); Guard.ArgumentNotEmptyString(repo, nameof(repo)); -#pragma warning disable 618 - // GetAllBranches is obsolete, but don't want to introduce the change to fix the - // warning in the PR, so disabling for now. - return gitHubClient.Repository.GetAllBranches(owner, repo); -#pragma warning restore + return gitHubClient.Repository.Branch.GetAll(owner, repo); } public IObservable GetRepository(string owner, string repo) diff --git a/src/GitHub.App/Factories/ModelServiceFactory.cs b/src/GitHub.App/Factories/ModelServiceFactory.cs index 8a6188b6bd..5ac1d26cdc 100644 --- a/src/GitHub.App/Factories/ModelServiceFactory.cs +++ b/src/GitHub.App/Factories/ModelServiceFactory.cs @@ -3,6 +3,7 @@ using System.ComponentModel.Composition; using System.Threading; using System.Threading.Tasks; +using GitHub.Api; using GitHub.Caches; using GitHub.Models; using GitHub.Services; @@ -15,6 +16,7 @@ namespace GitHub.Factories public sealed class ModelServiceFactory : IModelServiceFactory, IDisposable { readonly IApiClientFactory apiClientFactory; + readonly IGraphQLClientFactory graphQLClientFactory; readonly IHostCacheFactory hostCacheFactory; readonly IAvatarProvider avatarProvider; readonly Dictionary cache = new Dictionary(); @@ -23,10 +25,12 @@ public sealed class ModelServiceFactory : IModelServiceFactory, IDisposable [ImportingConstructor] public ModelServiceFactory( IApiClientFactory apiClientFactory, + IGraphQLClientFactory graphQLClientFactory, IHostCacheFactory hostCacheFactory, IAvatarProvider avatarProvider) { this.apiClientFactory = apiClientFactory; + this.graphQLClientFactory = graphQLClientFactory; this.hostCacheFactory = hostCacheFactory; this.avatarProvider = avatarProvider; } @@ -43,6 +47,7 @@ public async Task CreateAsync(IConnection connection) { result = new ModelService( await apiClientFactory.Create(connection.HostAddress), + await graphQLClientFactory.CreateConnection(connection.HostAddress), await hostCacheFactory.Create(connection.HostAddress), avatarProvider); result.InsertUser(AccountCacheItem.Create(connection.User)); diff --git a/src/GitHub.App/GitHub.App.csproj b/src/GitHub.App/GitHub.App.csproj index 815b12f40b..8f3a92c09d 100644 --- a/src/GitHub.App/GitHub.App.csproj +++ b/src/GitHub.App/GitHub.App.csproj @@ -61,8 +61,16 @@ ..\..\packages\Microsoft.VisualStudio.ComponentModelHost.14.0.25424\lib\net45\Microsoft.VisualStudio.ComponentModelHost.dll True + + ..\..\packages\Microsoft.VisualStudio.CoreUtility.14.3.25407\lib\net45\Microsoft.VisualStudio.CoreUtility.dll + True + + + ..\..\packages\Microsoft.VisualStudio.Editor.14.3.25407\lib\net45\Microsoft.VisualStudio.Editor.dll + True + - ..\..\packages\Microsoft.VisualStudio.OLE.Interop.7.10.6070\lib\Microsoft.VisualStudio.OLE.Interop.dll + ..\..\packages\Microsoft.VisualStudio.OLE.Interop.7.10.6071\lib\Microsoft.VisualStudio.OLE.Interop.dll True @@ -74,25 +82,75 @@ True - ..\..\packages\Microsoft.VisualStudio.Shell.Interop.7.10.6071\lib\Microsoft.VisualStudio.Shell.Interop.dll + ..\..\packages\Microsoft.VisualStudio.Shell.Interop.7.10.6072\lib\net11\Microsoft.VisualStudio.Shell.Interop.dll + True + + + True + ..\..\packages\Microsoft.VisualStudio.Shell.Interop.10.0.10.0.30320\lib\net20\Microsoft.VisualStudio.Shell.Interop.10.0.dll + True + + + True + ..\..\packages\Microsoft.VisualStudio.Shell.Interop.11.0.11.0.61031\lib\net20\Microsoft.VisualStudio.Shell.Interop.11.0.dll + True + + + True + ..\..\packages\Microsoft.VisualStudio.Shell.Interop.12.0.12.0.30111\lib\net20\Microsoft.VisualStudio.Shell.Interop.12.0.dll + True + + + ..\..\packages\Microsoft.VisualStudio.Shell.Interop.8.0.8.0.50728\lib\net11\Microsoft.VisualStudio.Shell.Interop.8.0.dll + True + + + ..\..\packages\Microsoft.VisualStudio.Text.Data.14.3.25407\lib\net45\Microsoft.VisualStudio.Text.Data.dll + True + + + ..\..\packages\Microsoft.VisualStudio.Text.Logic.14.3.25407\lib\net45\Microsoft.VisualStudio.Text.Logic.dll + True + + + ..\..\packages\Microsoft.VisualStudio.Text.UI.14.3.25407\lib\net45\Microsoft.VisualStudio.Text.UI.dll + True + + + ..\..\packages\Microsoft.VisualStudio.Text.UI.Wpf.14.3.25407\lib\net45\Microsoft.VisualStudio.Text.UI.Wpf.dll True - ..\..\packages\Microsoft.VisualStudio.TextManager.Interop.7.10.6070\lib\Microsoft.VisualStudio.TextManager.Interop.dll + ..\..\packages\Microsoft.VisualStudio.TextManager.Interop.7.10.6071\lib\net11\Microsoft.VisualStudio.TextManager.Interop.dll True - ..\..\packages\Microsoft.VisualStudio.TextManager.Interop.8.0.8.0.50727\lib\Microsoft.VisualStudio.TextManager.Interop.8.0.dll + ..\..\packages\Microsoft.VisualStudio.TextManager.Interop.8.0.8.0.50728\lib\net11\Microsoft.VisualStudio.TextManager.Interop.8.0.dll True False ..\..\packages\Microsoft.VisualStudio.Threading.14.1.131\lib\net45\Microsoft.VisualStudio.Threading.dll - - False - ..\..\packages\Newtonsoft.Json.6.0.8\lib\net45\Newtonsoft.Json.dll - False + + ..\..\packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll + True + + + ..\..\packages\Microsoft.VisualStudio.Utilities.14.3.25407\lib\net45\Microsoft.VisualStudio.Utilities.dll + True + + + ..\..\packages\Microsoft.VisualStudio.Utilities.14.3.25407\lib\net45\Microsoft.VisualStudio.Utilities.dll + True + + + ..\..\packages\Octokit.GraphQL.0.0.2-alpha\lib\netstandard1.1\Octokit.GraphQL.dll + True + + + ..\..\packages\Octokit.GraphQL.0.0.2-alpha\lib\netstandard1.1\Octokit.GraphQL.Core.dll + True @@ -152,11 +210,18 @@ + + + + + + + @@ -172,12 +237,18 @@ + + + + + + diff --git a/src/GitHub.App/Models/Account.cs b/src/GitHub.App/Models/Account.cs index e7a61c3146..050871f6fa 100644 --- a/src/GitHub.App/Models/Account.cs +++ b/src/GitHub.App/Models/Account.cs @@ -23,6 +23,7 @@ public Account( bool isEnterprise, int ownedPrivateRepositoryCount, long privateRepositoryInPlanCount, + string avatarUrl, IObservable bitmapSource) { Guard.ArgumentNotEmptyString(login, nameof(login)); @@ -34,6 +35,7 @@ public Account( PrivateReposInPlan = privateRepositoryInPlanCount; IsOnFreePlan = privateRepositoryInPlanCount == 0; HasMaximumPrivateRepositories = OwnedPrivateRepos >= PrivateReposInPlan; + AvatarUrl = avatarUrl; this.bitmapSource = bitmapSource; bitmapSourceSubscription = bitmapSource @@ -54,6 +56,7 @@ public Account(Octokit.Account account) OwnedPrivateRepos = account.OwnedPrivateRepos; IsOnFreePlan = PrivateReposInPlan == 0; HasMaximumPrivateRepositories = OwnedPrivateRepos >= PrivateReposInPlan; + AvatarUrl = account.AvatarUrl; } public Account(Octokit.Account account, IObservable bitmapSource) @@ -77,13 +80,15 @@ public Account(Octokit.Account account, IObservable bitmapSource) public long PrivateReposInPlan { get; private set; } + public string AvatarUrl { get; private set; } + public BitmapSource Avatar { get { return avatar; } set { avatar = value; this.RaisePropertyChanged(); } } -#region Equality things + #region Equality things public void CopyFrom(IAccount other) { if (!Equals(other)) @@ -115,7 +120,7 @@ public override bool Equals(object obj) public override int GetHashCode() { - return (Login?.GetHashCode() ?? 0) ^ IsUser .GetHashCode() ^ IsEnterprise.GetHashCode(); + return (Login?.GetHashCode() ?? 0) ^ IsUser.GetHashCode() ^ IsEnterprise.GetHashCode(); } bool IEquatable.Equals(IAccount other) diff --git a/src/GitHub.App/Models/IssueCommentModel.cs b/src/GitHub.App/Models/IssueCommentModel.cs index f4e4ddfe5e..5031aa2bd4 100644 --- a/src/GitHub.App/Models/IssueCommentModel.cs +++ b/src/GitHub.App/Models/IssueCommentModel.cs @@ -4,8 +4,9 @@ namespace GitHub.Models { public class IssueCommentModel : ICommentModel { - public string Body { get; set; } public int Id { get; set; } + public string NodeId { get; set; } + public string Body { get; set; } public DateTimeOffset CreatedAt { get; set; } public IAccount User { get; set; } } diff --git a/src/GitHub.App/Models/PullRequestModel.cs b/src/GitHub.App/Models/PullRequestModel.cs index 6ae87f3012..8e9d6439d7 100644 --- a/src/GitHub.App/Models/PullRequestModel.cs +++ b/src/GitHub.App/Models/PullRequestModel.cs @@ -162,11 +162,23 @@ public string Body public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } public IAccount Author { get; set; } - public IReadOnlyCollection ChangedFiles { get; set; } = new IPullRequestFileModel[0]; - public IReadOnlyCollection Comments { get; set; } = new ICommentModel[0]; + public IReadOnlyList ChangedFiles { get; set; } = new IPullRequestFileModel[0]; + public IReadOnlyList Comments { get; set; } = new ICommentModel[0]; - IReadOnlyCollection reviewComments = new IPullRequestReviewCommentModel[0]; - public IReadOnlyCollection ReviewComments + IReadOnlyList reviews = new IPullRequestReviewModel[0]; + public IReadOnlyList Reviews + { + get { return reviews; } + set + { + Guard.ArgumentNotNull(value, nameof(value)); + reviews = value; + this.RaisePropertyChange(); + } + } + + IReadOnlyList reviewComments = new IPullRequestReviewCommentModel[0]; + public IReadOnlyList ReviewComments { get { return reviewComments; } set diff --git a/src/GitHub.App/Models/PullRequestReviewCommentModel.cs b/src/GitHub.App/Models/PullRequestReviewCommentModel.cs index 1a073f84c0..7ae45548e6 100644 --- a/src/GitHub.App/Models/PullRequestReviewCommentModel.cs +++ b/src/GitHub.App/Models/PullRequestReviewCommentModel.cs @@ -5,6 +5,8 @@ namespace GitHub.Models public class PullRequestReviewCommentModel : IPullRequestReviewCommentModel { public int Id { get; set; } + public string NodeId { get; set; } + public int PullRequestReviewId { get; set; } public string Path { get; set; } public int? Position { get; set; } public int? OriginalPosition { get; set; } @@ -14,5 +16,6 @@ public class PullRequestReviewCommentModel : IPullRequestReviewCommentModel public IAccount User { get; set; } public string Body { get; set; } public DateTimeOffset CreatedAt { get; set; } + public bool IsPending { get; set; } } } diff --git a/src/GitHub.App/Models/PullRequestReviewModel.cs b/src/GitHub.App/Models/PullRequestReviewModel.cs new file mode 100644 index 0000000000..aab24ab076 --- /dev/null +++ b/src/GitHub.App/Models/PullRequestReviewModel.cs @@ -0,0 +1,15 @@ +using System; + +namespace GitHub.Models +{ + public class PullRequestReviewModel : IPullRequestReviewModel + { + public long Id { get; set; } + public string NodeId { get; set; } + public IAccount User { get; set; } + 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/Resources.Designer.cs b/src/GitHub.App/Resources.Designer.cs index d658cc788d..f9f2856d6a 100644 --- a/src/GitHub.App/Resources.Designer.cs +++ b/src/GitHub.App/Resources.Designer.cs @@ -69,6 +69,15 @@ internal static string AddedFileStatus { } } + /// + /// Looks up a localized string similar to Approved. + /// + internal static string Approved { + get { + return ResourceManager.GetString("Approved", resourceCulture); + } + } + /// /// Looks up a localized string similar to Select a containing folder for your new repository.. /// @@ -78,6 +87,15 @@ internal static string BrowseForDirectory { } } + /// + /// Looks up a localized string similar to Changes Requested. + /// + internal static string ChangesRequested { + get { + return ResourceManager.GetString("ChangesRequested", resourceCulture); + } + } + /// /// Looks up a localized string similar to Clone a {0} Repository. /// @@ -87,6 +105,15 @@ internal static string CloneTitle { } } + /// + /// Looks up a localized string similar to Commented. + /// + internal static string Commented { + get { + return ResourceManager.GetString("Commented", resourceCulture); + } + } + /// /// Looks up a localized string similar to Could not connect to github.com. /// @@ -180,6 +207,15 @@ internal static string Fork { } } + /// + /// Looks up a localized string similar to InProgress. + /// + internal static string InProgress { + get { + return ResourceManager.GetString("InProgress", resourceCulture); + } + } + /// /// Looks up a localized string similar to [invalid]. /// @@ -234,6 +270,33 @@ internal static string MustPullBeforePush { } } + /// + /// Looks up a localized string similar to Checkout PR branch before navigating to Editor. + /// + internal static string NavigateToEditorNotCheckedOutInfoMessage { + get { + return ResourceManager.GetString("NavigateToEditorNotCheckedOutInfoMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Press Enter to navigate to Editor (PR branch must be checked out). + /// + internal static string NavigateToEditorNotCheckedOutStatusMessage { + get { + return ResourceManager.GetString("NavigateToEditorNotCheckedOutStatusMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Press Enter to navigate to Editor. + /// + internal static string NavigateToEditorStatusMessage { + get { + return ResourceManager.GetString("NavigateToEditorStatusMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to No commits to pull. /// diff --git a/src/GitHub.App/Resources.resx b/src/GitHub.App/Resources.resx index 7fa2be0265..aa055d9027 100644 --- a/src/GitHub.App/Resources.resx +++ b/src/GitHub.App/Resources.resx @@ -294,4 +294,25 @@ Please install Git for Windows from: https://git-scm.com/download/win + + Approved + + + Changes Requested + + + Commented + + + InProgress + + + Press Enter to navigate to Editor + + + Checkout PR branch before navigating to Editor + + + Press Enter to navigate to Editor (PR branch must be checked out) + \ No newline at end of file diff --git a/src/GitHub.App/SampleData/AccountDesigner.cs b/src/GitHub.App/SampleData/AccountDesigner.cs index 8d555e2cdb..68770f8336 100644 --- a/src/GitHub.App/SampleData/AccountDesigner.cs +++ b/src/GitHub.App/SampleData/AccountDesigner.cs @@ -32,6 +32,7 @@ public BitmapSource Avatar public string Login { get; set; } public int OwnedPrivateRepos { get; set; } public long PrivateReposInPlan { get; set; } + public string AvatarUrl { get; set; } public override string ToString() { diff --git a/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs index 64126dc3bd..a4bd06b8a0 100644 --- a/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs @@ -31,8 +31,6 @@ public class PullRequestUpdateStateDesigner : IPullRequestUpdateState [ExcludeFromCodeCoverage] public class PullRequestDetailViewModelDesigner : PanePageViewModelBase, IPullRequestDetailViewModel { - private List changedFilesTree; - public PullRequestDetailViewModelDesigner() { var repoPath = @"C:\Repo"; @@ -69,8 +67,35 @@ public PullRequestDetailViewModelDesigner() modelsDir.Files.Add(oldBranchModel); gitHubDir.Directories.Add(modelsDir); - changedFilesTree = new List(); - changedFilesTree.Add(gitHubDir); + Reviews = new[] + { + new PullRequestReviewSummaryViewModel + { + Id = 2, + User = new AccountDesigner { Login = "grokys", IsUser = true }, + State = PullRequestReviewState.Pending, + FileCommentCount = 0, + }, + new PullRequestReviewSummaryViewModel + { + Id = 1, + User = new AccountDesigner { Login = "jcansdale", IsUser = true }, + State = PullRequestReviewState.Approved, + FileCommentCount = 5, + }, + new PullRequestReviewSummaryViewModel + { + Id = 2, + User = new AccountDesigner { Login = "shana", IsUser = true }, + State = PullRequestReviewState.ChangesRequested, + FileCommentCount = 5, + }, + new PullRequestReviewSummaryViewModel + { + }, + }; + + Files = new PullRequestFilesViewModelDesigner(); } public IPullRequestModel Model { get; } @@ -84,7 +109,8 @@ public PullRequestDetailViewModelDesigner() public bool IsCheckedOut { get; } public bool IsFromFork { get; } public string Body { get; } - public IReadOnlyList ChangedFilesTree => changedFilesTree; + public IReadOnlyList Reviews { get; } + public IPullRequestFilesViewModel Files { get; set; } public IPullRequestCheckoutState CheckoutState { get; set; } public IPullRequestUpdateState UpdateState { get; set; } public string OperationError { get; set; } @@ -94,20 +120,11 @@ public PullRequestDetailViewModelDesigner() public ReactiveCommand Checkout { get; } public ReactiveCommand Pull { get; } public ReactiveCommand Push { get; } - public ReactiveCommand SyncSubmodules { get; } public ReactiveCommand OpenOnGitHub { get; } - public ReactiveCommand DiffFile { get; } - public ReactiveCommand DiffFileWithWorkingDirectory { get; } - public ReactiveCommand OpenFileInWorkingDirectory { get; } - public ReactiveCommand ViewFile { get; } + public ReactiveCommand ShowReview { get; } public Task InitializeAsync(ILocalRepositoryModel localRepository, IConnection connection, string owner, string repo, int number) => Task.CompletedTask; - public Task ExtractFile(IPullRequestFileNode file, bool head) - { - return null; - } - public string GetLocalFilePath(IPullRequestFileNode file) { return null; diff --git a/src/GitHub.App/SampleData/PullRequestFilesViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestFilesViewModelDesigner.cs new file mode 100644 index 0000000000..c37398067a --- /dev/null +++ b/src/GitHub.App/SampleData/PullRequestFilesViewModelDesigner.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; + +namespace GitHub.SampleData +{ + public class PullRequestFilesViewModelDesigner : PanePageViewModelBase, IPullRequestFilesViewModel + { + public PullRequestFilesViewModelDesigner() + { + Items = new[] + { + new PullRequestDirectoryNode("src") + { + Files = + { + new PullRequestFileNode("x", "src/File1.cs", "x", PullRequestFileStatus.Added, null), + new PullRequestFileNode("x", "src/File2.cs", "x", PullRequestFileStatus.Modified, null), + new PullRequestFileNode("x", "src/File3.cs", "x", PullRequestFileStatus.Removed, null), + new PullRequestFileNode("x", "src/File4.cs", "x", PullRequestFileStatus.Renamed, "src/Old.cs"), + } + } + }; + ChangedFilesCount = 4; + } + + public int ChangedFilesCount { get; set; } + public IReadOnlyList Items { get; } + public ReactiveCommand DiffFile { get; } + public ReactiveCommand ViewFile { get; } + public ReactiveCommand DiffFileWithWorkingDirectory { get; } + public ReactiveCommand OpenFileInWorkingDirectory { get; } + public ReactiveCommand OpenFirstComment { get; } + + public Task InitializeAsync( + IPullRequestSession session, + Func commentFilter = null) + { + return Task.CompletedTask; + } + } +} diff --git a/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs new file mode 100644 index 0000000000..a13faba254 --- /dev/null +++ b/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; + +namespace GitHub.SampleData +{ + public class PullRequestReviewAuthoringViewModelDesigner : PanePageViewModelBase, IPullRequestReviewAuthoringViewModel + { + public PullRequestReviewAuthoringViewModelDesigner() + { + 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)); + + Files = new PullRequestFilesViewModelDesigner(); + + 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", + }, + }; + } + + public string Body { get; set; } + public bool CanApproveRequestChanges { get; set; } + public IReadOnlyList FileComments { get; } + public IPullRequestFilesViewModel Files { get; } + public ILocalRepositoryModel LocalRepository { get; set; } + public IPullRequestReviewModel Model { get; set; } + public ReactiveCommand NavigateToPullRequest { get; } + public string OperationError { get; set; } + public IPullRequestModel PullRequestModel { get; set; } + public string RemoteRepositoryOwner { get; set; } + public ReactiveCommand Approve { get; } + public ReactiveCommand Comment { get; } + public ReactiveCommand RequestChanges { get; } + public ReactiveCommand Cancel { get; } + + public Task InitializeAsync( + ILocalRepositoryModel localRepository, + IConnection connection, + string owner, + string repo, + int pullRequestNumber) + { + throw new NotImplementedException(); + } + } +} 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/AvatarProvider.cs b/src/GitHub.App/Services/AvatarProvider.cs index 5ed2428434..1a99e815f6 100644 --- a/src/GitHub.App/Services/AvatarProvider.cs +++ b/src/GitHub.App/Services/AvatarProvider.cs @@ -70,6 +70,21 @@ public IObservable GetAvatar(IAvatarContainer apiAccount) .Catch(_ => Observable.Return(DefaultAvatar(apiAccount))); } + public IObservable GetAvatar(string url) + { + if (url == null) + { + return Observable.Return(DefaultUserBitmapImage); + } + + Uri avatarUrl; + Uri.TryCreate(url, UriKind.Absolute, out avatarUrl); + Log.Assert(avatarUrl != null, "Cannot have a null avatar url"); + + return imageCache.GetImage(avatarUrl) + .Catch(_ => Observable.Return(DefaultUserBitmapImage)); + } + public IObservable InvalidateAvatar(IAvatarContainer apiAccount) { return String.IsNullOrWhiteSpace(apiAccount?.Login) diff --git a/src/GitHub.App/Services/ModelService.cs b/src/GitHub.App/Services/ModelService.cs index e50eae69ec..1c86ea394b 100644 --- a/src/GitHub.App/Services/ModelService.cs +++ b/src/GitHub.App/Services/ModelService.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Reactive; using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; using System.Threading.Tasks; using Akavache; using GitHub.Api; @@ -17,7 +18,9 @@ using GitHub.Models; using GitHub.Primitives; using Octokit; +using Octokit.GraphQL; using Serilog; +using static Octokit.GraphQL.Variable; namespace GitHub.Services { @@ -33,13 +36,16 @@ public class ModelService : IModelService readonly IBlobCache hostCache; readonly IAvatarProvider avatarProvider; + readonly Octokit.GraphQL.IConnection graphql; public ModelService( IApiClient apiClient, + Octokit.GraphQL.IConnection graphql, IBlobCache hostCache, IAvatarProvider avatarProvider) { this.ApiClient = apiClient; + this.graphql = graphql; this.hostCache = hostCache; this.avatarProvider = avatarProvider; } @@ -51,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(() => @@ -203,19 +216,22 @@ public IObservable GetPullRequest(string owner, string name, ApiClient.GetPullRequest(owner, name, number), ApiClient.GetPullRequestFiles(owner, name, number).ToList(), ApiClient.GetIssueComments(owner, name, number).ToList(), - ApiClient.GetPullRequestReviewComments(owner, name, number).ToList(), - (pr, files, comments, reviewComments) => new + GetPullRequestReviews(owner, name, number).ToObservable(), + GetPullRequestReviewComments(owner, name, number).ToObservable(), + (pr, files, comments, reviews, reviewComments) => new { PullRequest = pr, Files = files, Comments = comments, + Reviews = reviews, ReviewComments = reviewComments }) .Select(x => PullRequestCacheItem.Create( x.PullRequest, (IReadOnlyList)x.Files, (IReadOnlyList)x.Comments, - (IReadOnlyList)x.ReviewComments)), + (IReadOnlyList)x.Reviews, + (IReadOnlyList)x.ReviewComments)), TimeSpan.Zero, TimeSpan.FromDays(7)) .Select(Create); @@ -366,6 +382,159 @@ IObservable> GetOrganizationRepositories(s }); } +#pragma warning disable CS0618 // DatabaseId is marked obsolete by GraphQL but we need it + async Task> GetPullRequestReviews(string owner, string name, int number) + { + string cursor = null; + var result = new List(); + + while (true) + { + var query = new Query() + .Repository(owner, name) + .PullRequest(number) + .Reviews(first: 30, after: cursor) + .Select(x => new + { + x.PageInfo.HasNextPage, + x.PageInfo.EndCursor, + Items = x.Nodes.Select(y => new PullRequestReviewModel + { + Id = y.DatabaseId.Value, + NodeId = y.Id, + Body = y.Body, + CommitId = y.Commit.Oid, + State = FromGraphQL(y.State), + SubmittedAt = y.SubmittedAt, + User = Create(y.Author.Login, y.Author.AvatarUrl(null)) + }).ToList() + }); + + var page = await graphql.Run(query); + result.AddRange(page.Items); + + if (page.HasNextPage) + cursor = page.EndCursor; + else + return result; + } + } + + async Task> GetPullRequestReviewComments(string owner, string name, int number) + { + var result = new List(); + + // Reads a single page of reviews and for each review the first page of review comments. + var query = new Query() + .Repository(owner, name) + .PullRequest(number) + .Reviews(first: 100, after: Var("cursor")) + .Select(x => new + { + x.PageInfo.HasNextPage, + x.PageInfo.EndCursor, + Reviews = x.Nodes.Select(y => new + { + y.Id, + CommentPage = y.Comments(100, null, null, null).Select(z => new + { + z.PageInfo.HasNextPage, + z.PageInfo.EndCursor, + Items = z.Nodes.Select(a => new PullRequestReviewCommentModel + { + Id = a.DatabaseId.Value, + NodeId = a.Id, + Body = a.Body, + CommitId = a.Commit.Oid, + CreatedAt = a.CreatedAt.Value, + DiffHunk = a.DiffHunk, + OriginalCommitId = a.OriginalCommit.Oid, + OriginalPosition = a.OriginalPosition, + Path = a.Path, + Position = a.Position, + PullRequestReviewId = y.DatabaseId.Value, + User = Create(a.Author.Login, a.Author.AvatarUrl(null)), + IsPending = y.State == Octokit.GraphQL.Model.PullRequestReviewState.Pending, + }).ToList(), + }).Single() + }).ToList() + }).Compile(); + + var vars = new Dictionary + { + { "cursor", null } + }; + + // Read all pages of reviews. + while (true) + { + var reviewPage = await graphql.Run(query, vars); + + foreach (var review in reviewPage.Reviews) + { + result.AddRange(review.CommentPage.Items); + + // The the review has >1 page of review comments, read the remaining pages. + if (review.CommentPage.HasNextPage) + { + result.AddRange(await GetPullRequestReviewComments(review.Id, review.CommentPage.EndCursor)); + } + } + + if (reviewPage.HasNextPage) + vars["cursor"] = reviewPage.EndCursor; + else + return result; + } + } + + private async Task> GetPullRequestReviewComments(string reviewId, string commentCursor) + { + var result = new List(); + var query = new Query() + .Node(reviewId) + .Cast() + .Select(x => new + { + CommentPage = x.Comments(100, Var("cursor"), null, null).Select(z => new + { + z.PageInfo.HasNextPage, + z.PageInfo.EndCursor, + Items = z.Nodes.Select(a => new PullRequestReviewCommentModel + { + Id = a.DatabaseId.Value, + NodeId = a.Id, + Body = a.Body, + CommitId = a.Commit.Oid, + CreatedAt = a.CreatedAt.Value, + DiffHunk = a.DiffHunk, + OriginalCommitId = a.OriginalCommit.Oid, + OriginalPosition = a.OriginalPosition, + Path = a.Path, + Position = a.Position, + PullRequestReviewId = x.DatabaseId.Value, + User = Create(a.Author.Login, a.Author.AvatarUrl(null)), + }).ToList(), + }).Single() + }).Compile(); + var vars = new Dictionary + { + { "cursor", commentCursor } + }; + + while (true) + { + var page = await graphql.Run(query, vars); + result.AddRange(page.CommentPage.Items); + + if (page.CommentPage.HasNextPage) + vars["cursor"] = page.CommentPage.EndCursor; + else + return result; + } + } +#pragma warning restore CS0618 // Type or member is obsolete + public IObservable GetBranches(IRepositoryModel repo) { var keyobs = GetUserFromCache() @@ -394,9 +563,22 @@ IAccount Create(AccountCacheItem accountCacheItem) accountCacheItem.IsEnterprise, accountCacheItem.OwnedPrivateRepositoriesCount, accountCacheItem.PrivateRepositoriesInPlanCount, + accountCacheItem.AvatarUrl, avatarProvider.GetAvatar(accountCacheItem)); } + IAccount Create(string login, string avatarUrl) + { + return new Models.Account( + login, + true, + false, + 0, + 0, + avatarUrl, + avatarProvider.GetAvatar(avatarUrl)); + } + IRemoteRepositoryModel Create(RepositoryCacheItem item) { return new RemoteRepositoryModel( @@ -440,10 +622,23 @@ IPullRequestModel Create(PullRequestCacheItem prCacheItem) User = Create(x.User), CreatedAt = x.CreatedAt ?? DateTimeOffset.MinValue, }).ToList(), + Reviews = prCacheItem.Reviews.Select(x => + (IPullRequestReviewModel)new PullRequestReviewModel + { + Id = x.Id, + NodeId = x.NodeId, + User = Create(x.User), + Body = x.Body, + State = x.State, + CommitId = x.CommitId, + SubmittedAt = x.SubmittedAt, + }).ToList(), ReviewComments = prCacheItem.ReviewComments.Select(x => (IPullRequestReviewCommentModel)new PullRequestReviewCommentModel { Id = x.Id, + NodeId = x.NodeId, + PullRequestReviewId = x.PullRequestReviewId, Path = x.Path, Position = x.Position, OriginalPosition = x.OriginalPosition, @@ -453,6 +648,7 @@ IPullRequestModel Create(PullRequestCacheItem prCacheItem) User = Create(x.User), Body = x.Body, CreatedAt = x.CreatedAt, + IsPending = x.IsPending, }).ToList(), CommentCount = prCacheItem.CommentCount, CommitCount = prCacheItem.CommitCount, @@ -478,6 +674,11 @@ public void Dispose() GC.SuppressFinalize(this); } + static GitHub.Models.PullRequestReviewState FromGraphQL(Octokit.GraphQL.Model.PullRequestReviewState s) + { + return (GitHub.Models.PullRequestReviewState)s; + } + public class GitIgnoreCacheItem : CacheItem { public static GitIgnoreCacheItem Create(string ignore) @@ -539,22 +740,28 @@ public class PullRequestCacheItem : CacheItem { public static PullRequestCacheItem Create(PullRequest pr) { - return new PullRequestCacheItem(pr, new PullRequestFile[0], new IssueComment[0], new PullRequestReviewComment[0]); + return new PullRequestCacheItem( + pr, + new PullRequestFile[0], + new IssueComment[0], + new IPullRequestReviewModel[0], + new IPullRequestReviewCommentModel[0]); } public static PullRequestCacheItem Create( PullRequest pr, IReadOnlyList files, IReadOnlyList comments, - IReadOnlyList reviewComments) + IReadOnlyList reviews, + IReadOnlyList reviewComments) { - return new PullRequestCacheItem(pr, files, comments, reviewComments); + return new PullRequestCacheItem(pr, files, comments, reviews, reviewComments); } public PullRequestCacheItem() {} public PullRequestCacheItem(PullRequest pr) - : this(pr, new PullRequestFile[0], new IssueComment[0], new PullRequestReviewComment[0]) + : this(pr, new PullRequestFile[0], new IssueComment[0], new IPullRequestReviewModel[0], new IPullRequestReviewCommentModel[0]) { } @@ -562,7 +769,8 @@ public PullRequestCacheItem( PullRequest pr, IReadOnlyList files, IReadOnlyList comments, - IReadOnlyList reviewComments) + IReadOnlyList reviews, + IReadOnlyList reviewComments) { Title = pr.Title; Number = pr.Number; @@ -580,7 +788,7 @@ public PullRequestCacheItem( Sha = pr.Head.Sha, RepositoryCloneUrl = pr.Head.Repository?.CloneUrl }; - CommentCount = pr.Comments + pr.ReviewComments; + CommentCount = pr.Comments; CommitCount = pr.Commits; Author = new AccountCacheItem(pr.User); Assignee = pr.Assignee != null ? new AccountCacheItem(pr.Assignee) : null; @@ -589,6 +797,7 @@ public PullRequestCacheItem( Body = pr.Body; ChangedFiles = files.Select(x => new PullRequestFileCacheItem(x)).ToList(); Comments = comments.Select(x => new IssueCommentCacheItem(x)).ToList(); + Reviews = reviews.Select(x => new PullRequestReviewCacheItem(x)).ToList(); ReviewComments = reviewComments.Select(x => new PullRequestReviewCommentCacheItem(x)).ToList(); State = GetState(pr); IsOpen = pr.State == ItemState.Open; @@ -610,6 +819,7 @@ public PullRequestCacheItem( public string Body { get; set; } public IList ChangedFiles { get; set; } = new PullRequestFileCacheItem[0]; public IList Comments { get; set; } = new IssueCommentCacheItem[0]; + public IList Reviews { get; set; } = new PullRequestReviewCacheItem[0]; public IList ReviewComments { get; set; } = new PullRequestReviewCommentCacheItem[0]; // Nullable for compatibility with old caches. @@ -674,27 +884,65 @@ public IssueCommentCacheItem(IssueComment comment) public DateTimeOffset? CreatedAt { get; set; } } + public class PullRequestReviewCacheItem + { + public PullRequestReviewCacheItem() + { + } + + public PullRequestReviewCacheItem(IPullRequestReviewModel review) + { + Id = review.Id; + NodeId = review.NodeId; + User = new AccountCacheItem + { + Login = review.User.Login, + AvatarUrl = review.User.AvatarUrl, + }; + Body = review.Body; + State = review.State; + SubmittedAt = review.SubmittedAt; + } + + public long Id { get; set; } + public string NodeId { get; set; } + public AccountCacheItem User { get; set; } + 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 { public PullRequestReviewCommentCacheItem() { } - public PullRequestReviewCommentCacheItem(PullRequestReviewComment comment) + public PullRequestReviewCommentCacheItem(IPullRequestReviewCommentModel comment) { Id = comment.Id; + NodeId = comment.NodeId; + PullRequestReviewId = comment.PullRequestReviewId; Path = comment.Path; Position = comment.Position; OriginalPosition = comment.OriginalPosition; CommitId = comment.CommitId; OriginalCommitId = comment.OriginalCommitId; DiffHunk = comment.DiffHunk; - User = new AccountCacheItem(comment.User); + User = new AccountCacheItem + { + Login = comment.User.Login, + AvatarUrl = comment.User.AvatarUrl, + }; Body = comment.Body; CreatedAt = comment.CreatedAt; + IsPending = comment.IsPending; } public int Id { get; } + public string NodeId { get; } + public int PullRequestReviewId { get; set; } public string Path { get; set; } public int? Position { get; set; } public int? OriginalPosition { get; set; } @@ -704,6 +952,7 @@ public PullRequestReviewCommentCacheItem(PullRequestReviewComment comment) public AccountCacheItem User { get; set; } public string Body { get; set; } public DateTimeOffset CreatedAt { get; set; } + public bool IsPending { get; set; } } public class GitReferenceCacheItem diff --git a/src/GitHub.App/Services/PullRequestEditorService.cs b/src/GitHub.App/Services/PullRequestEditorService.cs index b8c758fa39..360fed5db0 100644 --- a/src/GitHub.App/Services/PullRequestEditorService.cs +++ b/src/GitHub.App/Services/PullRequestEditorService.cs @@ -1,25 +1,239 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Threading.Tasks; +using EnvDTE; +using GitHub.Commands; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.ViewModels.GitHubPane; +using GitHub.VisualStudio; using Microsoft.VisualStudio; +using Microsoft.VisualStudio.Editor; using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Shell.Interop; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Differencing; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Projection; using Microsoft.VisualStudio.TextManager.Interop; -using GitHub.Models; +using Task = System.Threading.Tasks.Task; namespace GitHub.Services { + /// + /// Services for opening views of pull request files in Visual Studio. + /// [Export(typeof(IPullRequestEditorService))] + [PartCreationPolicy(CreationPolicy.Shared)] public class PullRequestEditorService : IPullRequestEditorService { - readonly IGitHubServiceProvider serviceProvider; - // If the target line doesn't have a unique match, search this number of lines above looking for a match. public const int MatchLinesAboveTarget = 4; + readonly IGitHubServiceProvider serviceProvider; + readonly IPullRequestService pullRequestService; + readonly IVsEditorAdaptersFactoryService vsEditorAdaptersFactory; + readonly IStatusBarNotificationService statusBar; + readonly IUsageTracker usageTracker; + [ImportingConstructor] - public PullRequestEditorService(IGitHubServiceProvider serviceProvider) + public PullRequestEditorService( + IGitHubServiceProvider serviceProvider, + IPullRequestService pullRequestService, + IVsEditorAdaptersFactoryService vsEditorAdaptersFactory, + IStatusBarNotificationService statusBar, + IUsageTracker usageTracker) { + Guard.ArgumentNotNull(serviceProvider, nameof(serviceProvider)); + Guard.ArgumentNotNull(pullRequestService, nameof(pullRequestService)); + Guard.ArgumentNotNull(vsEditorAdaptersFactory, nameof(vsEditorAdaptersFactory)); + Guard.ArgumentNotNull(statusBar, nameof(statusBar)); + Guard.ArgumentNotNull(usageTracker, nameof(usageTracker)); + this.serviceProvider = serviceProvider; + this.pullRequestService = pullRequestService; + this.vsEditorAdaptersFactory = vsEditorAdaptersFactory; + this.statusBar = statusBar; + this.usageTracker = usageTracker; + } + + /// + public async Task OpenFile( + IPullRequestSession session, + string relativePath, + bool workingDirectory) + { + Guard.ArgumentNotNull(session, nameof(session)); + Guard.ArgumentNotEmptyString(relativePath, nameof(relativePath)); + + try + { + var fullPath = Path.Combine(session.LocalRepository.LocalPath, relativePath); + string fileName; + string commitSha; + + if (workingDirectory) + { + fileName = fullPath; + commitSha = null; + } + else + { + var file = await session.GetFile(relativePath); + fileName = await pullRequestService.ExtractToTempFile( + session.LocalRepository, + session.PullRequest, + file.RelativePath, + file.CommitSha, + pullRequestService.GetEncoding(session.LocalRepository, file.RelativePath)); + commitSha = file.CommitSha; + } + + using (workingDirectory ? null : OpenInProvisionalTab()) + { + var window = VisualStudio.Services.Dte.ItemOperations.OpenFile(fileName); + window.Document.ReadOnly = !workingDirectory; + + var buffer = GetBufferAt(fileName); + + if (!workingDirectory) + { + AddBufferTag(buffer, session, fullPath, commitSha, null); + + var textView = FindActiveView(); + var file = await session.GetFile(relativePath); + EnableNavigateToEditor(textView, session, file); + } + } + + if (workingDirectory) + await usageTracker.IncrementCounter(x => x.NumberOfPRDetailsOpenFileInSolution); + else + await usageTracker.IncrementCounter(x => x.NumberOfPRDetailsViewFile); + } + catch (Exception e) + { + ShowErrorInStatusBar("Error opening file", e); + } + } + + /// + public async Task OpenDiff(IPullRequestSession session, string relativePath, string headSha) + { + Guard.ArgumentNotNull(session, nameof(session)); + Guard.ArgumentNotEmptyString(relativePath, nameof(relativePath)); + + try + { + var workingDirectory = headSha == null; + var file = await session.GetFile(relativePath, headSha ?? "HEAD"); + var mergeBase = await pullRequestService.GetMergeBase(session.LocalRepository, session.PullRequest); + var encoding = pullRequestService.GetEncoding(session.LocalRepository, file.RelativePath); + var rightFile = workingDirectory ? + Path.Combine(session.LocalRepository.LocalPath, relativePath) : + await pullRequestService.ExtractToTempFile( + session.LocalRepository, + session.PullRequest, + relativePath, + file.CommitSha, + encoding); + + if (FocusExistingDiffViewer(session, mergeBase, rightFile)) + { + return; + } + + var leftFile = await pullRequestService.ExtractToTempFile( + session.LocalRepository, + session.PullRequest, + relativePath, + mergeBase, + encoding); + var leftPath = await GetBaseFileName(session, file); + var rightPath = file.RelativePath; + var leftLabel = $"{leftPath};{session.GetBaseBranchDisplay()}"; + var rightLabel = workingDirectory ? rightPath : $"{rightPath};PR {session.PullRequest.Number}"; + var caption = $"Diff - {Path.GetFileName(file.RelativePath)}"; + var options = __VSDIFFSERVICEOPTIONS.VSDIFFOPT_DetectBinaryFiles | + __VSDIFFSERVICEOPTIONS.VSDIFFOPT_LeftFileIsTemporary; + + if (!workingDirectory) + { + options |= __VSDIFFSERVICEOPTIONS.VSDIFFOPT_RightFileIsTemporary; + } + + IVsWindowFrame frame; + using (OpenInProvisionalTab()) + { + var tooltip = $"{leftLabel}\nvs.\n{rightLabel}"; + + // Diff window will open in provisional (right hand) tab until document is touched. + frame = VisualStudio.Services.DifferenceService.OpenComparisonWindow2( + leftFile, + rightFile, + caption, + tooltip, + leftLabel, + rightLabel, + string.Empty, + string.Empty, + (uint)options); + } + + var diffViewer = GetDiffViewer(frame); + + AddBufferTag(diffViewer.LeftView.TextBuffer, session, leftPath, mergeBase, DiffSide.Left); + + if (!workingDirectory) + { + AddBufferTag(diffViewer.RightView.TextBuffer, session, rightPath, file.CommitSha, DiffSide.Right); + EnableNavigateToEditor(diffViewer.LeftView, session, file); + EnableNavigateToEditor(diffViewer.RightView, session, file); + EnableNavigateToEditor(diffViewer.InlineView, session, file); + } + + if (workingDirectory) + await usageTracker.IncrementCounter(x => x.NumberOfPRDetailsCompareWithSolution); + else + await usageTracker.IncrementCounter(x => x.NumberOfPRDetailsViewChanges); + } + catch (Exception e) + { + ShowErrorInStatusBar("Error opening file", e); + } + } + + /// + public async Task OpenDiff( + IPullRequestSession session, + string relativePath, + IInlineCommentThreadModel thread) + { + Guard.ArgumentNotNull(session, nameof(session)); + Guard.ArgumentNotEmptyString(relativePath, nameof(relativePath)); + Guard.ArgumentNotNull(thread, nameof(thread)); + + await OpenDiff(session, relativePath, thread.CommitSha); + + // HACK: We need to wait here for the diff view to set itself up and move its cursor + // to the first changed line. There must be a better way of doing this. + await Task.Delay(1500); + + var param = (object)new InlineCommentNavigationParams + { + FromLine = thread.LineNumber - 1, + }; + + VisualStudio.Services.Dte.Commands.Raise( + Guids.CommandSetString, + PkgCmdIDList.NextInlineCommentId, + ref param, + null); } public IVsTextView NavigateToEquivalentPosition(IVsTextView sourceView, string targetFile) @@ -180,6 +394,212 @@ IVsTextView OpenDocument(string fullPath) return view; } + bool FocusExistingDiffViewer( + IPullRequestSession session, + string mergeBase, + string rightPath) + { + IVsUIHierarchy uiHierarchy; + uint itemID; + IVsWindowFrame windowFrame; + + // Diff documents are indexed by the path on the right hand side of the comparison. + if (VsShellUtilities.IsDocumentOpen( + serviceProvider, + rightPath, + Guid.Empty, + out uiHierarchy, + out itemID, + out windowFrame)) + { + var diffViewer = GetDiffViewer(windowFrame); + + if (diffViewer != null) + { + PullRequestTextBufferInfo leftBufferInfo; + + if (diffViewer.LeftView.TextBuffer.Properties.TryGetProperty( + typeof(PullRequestTextBufferInfo), + out leftBufferInfo) && + leftBufferInfo.Session.PullRequest.Number == session.PullRequest.Number && + leftBufferInfo.CommitSha == mergeBase) + { + return ErrorHandler.Succeeded(windowFrame.Show()); + } + } + } + + return false; + } + + void ShowErrorInStatusBar(string message) + { + statusBar.ShowMessage(message); + } + + void ShowErrorInStatusBar(string message, Exception e) + { + statusBar.ShowMessage(message + ": " + e.Message); + } + + void AddBufferTag( + ITextBuffer buffer, + IPullRequestSession session, + string path, + string commitSha, + DiffSide? side) + { + buffer.Properties.GetOrCreateSingletonProperty( + typeof(PullRequestTextBufferInfo), + () => new PullRequestTextBufferInfo(session, path, commitSha, side)); + + var projection = buffer as IProjectionBuffer; + + if (projection != null) + { + foreach (var source in projection.SourceBuffers) + { + AddBufferTag(source, session, path, commitSha, side); + } + } + } + + void EnableNavigateToEditor(ITextView textView, IPullRequestSession session, IPullRequestSessionFile file) + { + var vsTextView = vsEditorAdaptersFactory.GetViewAdapter(textView); + EnableNavigateToEditor(vsTextView, session, file); + } + + void EnableNavigateToEditor(IVsTextView vsTextView, IPullRequestSession session, IPullRequestSessionFile file) + { + var commandGroup = VSConstants.CMDSETID.StandardCommandSet2K_guid; + var commandId = (int)VSConstants.VSStd2KCmdID.RETURN; + new TextViewCommandDispatcher(vsTextView, commandGroup, commandId).Exec += + async (s, e) => await DoNavigateToEditor(session, file); + + var contextMenuCommandGroup = new Guid(Guids.guidContextMenuSetString); + var goToCommandId = PkgCmdIDList.openFileInSolutionCommand; + new TextViewCommandDispatcher(vsTextView, contextMenuCommandGroup, goToCommandId).Exec += + async (s, e) => await DoNavigateToEditor(session, file); + + EnableNavigateStatusBarMessage(vsTextView, session); + } + + void EnableNavigateStatusBarMessage(IVsTextView vsTextView, IPullRequestSession session) + { + var textView = vsEditorAdaptersFactory.GetWpfTextView(vsTextView); + + var statusMessage = session.IsCheckedOut ? + App.Resources.NavigateToEditorStatusMessage : App.Resources.NavigateToEditorNotCheckedOutStatusMessage; + + textView.GotAggregateFocus += (s, e) => + statusBar.ShowMessage(statusMessage); + + textView.LostAggregateFocus += (s, e) => + statusBar.ShowMessage(string.Empty); + } + + async Task DoNavigateToEditor(IPullRequestSession session, IPullRequestSessionFile file) + { + try + { + if (!session.IsCheckedOut) + { + ShowInfoMessage(App.Resources.NavigateToEditorNotCheckedOutInfoMessage); + return; + } + + var fullPath = GetAbsolutePath(session, file); + + var activeView = FindActiveView(); + if (activeView == null) + { + ShowErrorInStatusBar("Couldn't find active view"); + return; + } + + NavigateToEquivalentPosition(activeView, fullPath); + + await usageTracker.IncrementCounter(x => x.NumberOfPRDetailsNavigateToEditor); + } + catch (Exception e) + { + ShowErrorInStatusBar("Error navigating to editor", e); + } + } + + ITextBuffer GetBufferAt(string filePath) + { + IVsUIHierarchy uiHierarchy; + uint itemID; + IVsWindowFrame windowFrame; + + if (VsShellUtilities.IsDocumentOpen( + serviceProvider, + filePath, + Guid.Empty, + out uiHierarchy, + out itemID, + out windowFrame)) + { + IVsTextView view = VsShellUtilities.GetTextView(windowFrame); + IVsTextLines lines; + if (view.GetBuffer(out lines) == 0) + { + var buffer = lines as IVsTextBuffer; + if (buffer != null) + return vsEditorAdaptersFactory.GetDataBuffer(buffer); + } + } + + return null; + } + + async Task GetBaseFileName(IPullRequestSession session, IPullRequestSessionFile file) + { + using (var changes = await pullRequestService.GetTreeChanges( + session.LocalRepository, + session.PullRequest)) + { + var fileChange = changes.FirstOrDefault(x => x.Path == file.RelativePath); + return fileChange?.Status == LibGit2Sharp.ChangeKind.Renamed ? + fileChange.OldPath : file.RelativePath; + } + } + + void ShowInfoMessage(string message) + { + ErrorHandler.ThrowOnFailure(VsShellUtilities.ShowMessageBox( + serviceProvider, message, null, + OLEMSGICON.OLEMSGICON_INFO, OLEMSGBUTTON.OLEMSGBUTTON_OK, OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST)); + } + + static string GetAbsolutePath(IPullRequestSession session, IPullRequestSessionFile file) + { + var localPath = session.LocalRepository.LocalPath; + var relativePath = file.RelativePath.Replace('/', Path.DirectorySeparatorChar); + return Path.Combine(localPath, relativePath); + } + + static IDifferenceViewer GetDiffViewer(IVsWindowFrame frame) + { + object docView; + + if (ErrorHandler.Succeeded(frame.GetProperty((int)__VSFPROPID.VSFPROPID_DocView, out docView))) + { + return (docView as IVsDifferenceCodeWindow)?.DifferenceViewer; + } + + return null; + } + + static IDisposable OpenInProvisionalTab() + { + return new NewDocumentStateScope( + __VSNEWDOCUMENTSTATE.NDS_Provisional, + VSConstants.NewDocumentStateReason.SolutionExplorer); + } + static IList ReadLines(string text) { var lines = new List(); diff --git a/src/GitHub.App/Services/PullRequestService.cs b/src/GitHub.App/Services/PullRequestService.cs index ce921a03e6..a66a36f502 100644 --- a/src/GitHub.App/Services/PullRequestService.cs +++ b/src/GitHub.App/Services/PullRequestService.cs @@ -16,6 +16,8 @@ using System.Collections.Generic; using LibGit2Sharp; using GitHub.Logging; +using GitHub.Extensions; +using static System.FormattableString; namespace GitHub.Services { @@ -326,6 +328,20 @@ public IObservable CalculateHistoryDivergence(ILocalRepos }); } + public async Task GetMergeBase(ILocalRepositoryModel repository, IPullRequestModel pullRequest) + { + using (var repo = gitService.GetRepository(repository.LocalPath)) + { + return await gitClient.GetPullRequestMergeBase( + repo, + pullRequest.Base.RepositoryCloneUrl, + pullRequest.Base.Sha, + pullRequest.Head.Sha, + pullRequest.Base.Ref, + pullRequest.Number); + } + } + public IObservable GetTreeChanges(ILocalRepositoryModel repository, IPullRequestModel pullRequest) { return Observable.Defer(async () => @@ -446,46 +462,25 @@ public IObservable> GetPullRequestForCurrentBranch(ILocalRepo }); } - public IObservable ExtractFile( + public async Task ExtractToTempFile( ILocalRepositoryModel repository, IPullRequestModel pullRequest, - string fileName, - bool head, + string relativePath, + string commitSha, Encoding encoding) { - return Observable.Defer(async () => + var tempFilePath = CalculateTempFileName(relativePath, commitSha, encoding); + + if (!File.Exists(tempFilePath)) { using (var repo = gitService.GetRepository(repository.LocalPath)) { var remote = await gitClient.GetHttpRemote(repo, "origin"); - string sha; - - if (head) - { - sha = pullRequest.Head.Sha; - } - else - { - try - { - sha = await gitClient.GetPullRequestMergeBase( - repo, - pullRequest.Base.RepositoryCloneUrl, - pullRequest.Base.Sha, - pullRequest.Head.Sha, - pullRequest.Base.Ref, - pullRequest.Number); - } - catch (NotFoundException ex) - { - throw new NotFoundException($"The Pull Request file failed to load. Please check your network connection and click refresh to try again. If this issue persists, please let us know at support@github.com", ex); - } - } - - var file = await ExtractToTempFile(repo, pullRequest.Number, sha, fileName, encoding); - return Observable.Return(file); + await ExtractToTempFile(repo, pullRequest.Number, commitSha, relativePath, encoding, tempFilePath); } - }); + } + + return tempFilePath; } public Encoding GetEncoding(ILocalRepositoryModel repository, string relativePath) @@ -576,39 +571,30 @@ string CreateUniqueRemoteName(IRepository repo, string name) return uniqueName; } - async Task ExtractToTempFile( + async Task ExtractToTempFile( IRepository repo, int pullRequestNumber, string commitSha, - string fileName, - Encoding encoding) + string relativePath, + Encoding encoding, + string tempFilePath) { string contents; - + try { - contents = await gitClient.ExtractFile(repo, commitSha, fileName) ?? string.Empty; + contents = await gitClient.ExtractFile(repo, commitSha, relativePath) ?? string.Empty; } catch (FileNotFoundException) { var pullHeadRef = $"refs/pull/{pullRequestNumber}/head"; var remote = await gitClient.GetHttpRemote(repo, "origin"); await gitClient.Fetch(repo, remote.Name, commitSha, pullHeadRef); - contents = await gitClient.ExtractFile(repo, commitSha, fileName) ?? string.Empty; + contents = await gitClient.ExtractFile(repo, commitSha, relativePath) ?? string.Empty; } - return CreateTempFile(fileName, commitSha, contents, encoding); - } - - static string CreateTempFile(string fileName, string commitSha, string contents, Encoding encoding) - { - var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - var tempFileName = $"{Path.GetFileNameWithoutExtension(fileName)}@{commitSha}{Path.GetExtension(fileName)}"; - var tempFile = Path.Combine(tempDir, tempFileName); - - Directory.CreateDirectory(tempDir); - File.WriteAllText(tempFile, contents, encoding); - return tempFile; + Directory.CreateDirectory(Path.GetDirectoryName(tempFilePath)); + File.WriteAllText(tempFilePath, contents, encoding); } IEnumerable GetLocalBranchesInternal( @@ -688,6 +674,17 @@ static string GetSafeBranchName(string name) } } + static string CalculateTempFileName(string relativePath, string commitSha, Encoding encoding) + { + // The combination of relative path, commit SHA and encoding should be sufficient to uniquely identify a file. + var relativeDir = Path.GetDirectoryName(relativePath) ?? string.Empty; + var key = relativeDir + '|' + encoding.WebName; + var relativePathHash = key.GetSha256Hash(); + var tempDir = Path.Combine(Path.GetTempPath(), "GitHubVisualStudio", "FileContents", relativePathHash); + var tempFileName = Invariant($"{Path.GetFileNameWithoutExtension(relativePath)}@{commitSha}{Path.GetExtension(relativePath)}"); + return Path.Combine(tempDir, tempFileName); + } + static string BuildGHfVSConfigKeyValue(IPullRequestModel pullRequest) { return pullRequest.Base.RepositoryCloneUrl.Owner + '#' + diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/TextViewCommandDispatcher.cs b/src/GitHub.App/Services/TextViewCommandDispatcher.cs similarity index 98% rename from src/GitHub.VisualStudio/Views/GitHubPane/TextViewCommandDispatcher.cs rename to src/GitHub.App/Services/TextViewCommandDispatcher.cs index 10c7da216e..dd0f91f582 100644 --- a/src/GitHub.VisualStudio/Views/GitHubPane/TextViewCommandDispatcher.cs +++ b/src/GitHub.App/Services/TextViewCommandDispatcher.cs @@ -3,7 +3,7 @@ using Microsoft.VisualStudio.OLE.Interop; using Microsoft.VisualStudio.TextManager.Interop; -namespace GitHub.VisualStudio.Views.GitHubPane +namespace GitHub.Services { /// /// Intercepts all commands sent to a and fires when a specified command is encountered. diff --git a/src/GitHub.App/ViewModels/GitHubPane/GitHubPaneViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/GitHubPaneViewModel.cs index d4784540a4..353e52d3c4 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/GitHubPaneViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/GitHubPaneViewModel.cs @@ -33,6 +33,8 @@ 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 pullNewReviewUri = CreateRoute("/:owner/:repo/pull/:number/review/new"); + static readonly Regex pullUserReviewsUri = CreateRoute("/:owner/:repo/pull/:number/reviews/:login"); readonly IViewViewModelFactory viewModelFactory; readonly ISimpleApiClientFactory apiClientFactory; @@ -243,6 +245,21 @@ public async Task NavigateTo(Uri uri) var number = int.Parse(match.Groups["number"].Value); await ShowPullRequest(owner, repo, number); } + else if ((match = pullNewReviewUri.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); + await ShowPullRequestReviewAuthoring(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 +299,33 @@ 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); + } + + /// + public Task ShowPullRequestReviewAuthoring(string owner, string repo, int number) + { + Guard.ArgumentNotNull(owner, nameof(owner)); + Guard.ArgumentNotNull(repo, nameof(repo)); + + return NavigateTo( + x => x.InitializeAsync(LocalRepository, Connection, owner, repo, number), + x => x.RemoteRepositoryOwner == owner && + x.LocalRepository.Name == repo && + x.PullRequestModel.Number == number); + } + async Task CreateInitializeTask(IServiceProvider paneServiceProvider) { await UpdateContent(teamExplorerContext.ActiveRepository); @@ -408,7 +452,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 20d7a3d3d2..21c88ea6b7 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 { @@ -42,7 +43,7 @@ public sealed class PullRequestDetailViewModel : PanePageViewModelBase, IPullReq string targetBranchDisplayName; int commentCount; string body; - IReadOnlyList changedFilesTree; + IReadOnlyList reviews; IPullRequestCheckoutState checkoutState; IPullRequestUpdateState updateState; string operationError; @@ -52,6 +53,7 @@ public sealed class PullRequestDetailViewModel : PanePageViewModelBase, IPullReq bool active; bool refreshOnActivate; Uri webUrl; + IDisposable sessionSubscription; /// /// Initializes a new instance of the class. @@ -69,7 +71,8 @@ public PullRequestDetailViewModel( IModelServiceFactory modelServiceFactory, IUsageTracker usageTracker, ITeamExplorerContext teamExplorerContext, - IStatusBarNotificationService statusBarNotificationService) + IStatusBarNotificationService statusBarNotificationService, + IPullRequestFilesViewModel files) { Guard.ArgumentNotNull(pullRequestsService, nameof(pullRequestsService)); Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); @@ -84,6 +87,7 @@ public PullRequestDetailViewModel( this.usageTracker = usageTracker; this.teamExplorerContext = teamExplorerContext; this.statusBarNotificationService = statusBarNotificationService; + Files = files; Checkout = ReactiveCommand.CreateAsyncObservable( this.WhenAnyValue(x => x.CheckoutState) @@ -116,10 +120,7 @@ public PullRequestDetailViewModel( SubscribeOperationError(SyncSubmodules); OpenOnGitHub = ReactiveCommand.Create(); - DiffFile = ReactiveCommand.Create(); - DiffFileWithWorkingDirectory = ReactiveCommand.Create(this.WhenAnyValue(x => x.IsCheckedOut)); - OpenFileInWorkingDirectory = ReactiveCommand.Create(this.WhenAnyValue(x => x.IsCheckedOut)); - ViewFile = ReactiveCommand.Create(); + ShowReview = ReactiveCommand.Create().OnExecuteCompleted(DoShowReview); } /// @@ -248,14 +249,19 @@ public string OperationError } /// - /// Gets the changed files as a tree. + /// Gets the latest pull request review for each user. /// - public IReadOnlyList ChangedFilesTree + public IReadOnlyList Reviews { - get { return changedFilesTree; } - private set { this.RaiseAndSetIfChanged(ref changedFilesTree, value); } + get { return reviews; } + private set { this.RaiseAndSetIfChanged(ref reviews, value); } } + /// + /// Gets the pull request's changed files. + /// + public IPullRequestFilesViewModel Files { get; } + /// /// Gets the web URL for the pull request. /// @@ -291,25 +297,9 @@ public Uri WebUrl public ReactiveCommand OpenOnGitHub { get; } /// - /// Gets a command that diffs an between BASE and HEAD. - /// - public ReactiveCommand DiffFile { get; } - - /// - /// Gets a command that diffs an between the version in - /// the working directory and HEAD. - /// - public ReactiveCommand DiffFileWithWorkingDirectory { get; } - - /// - /// Gets a command that opens an from disk. + /// Gets a command that navigates to a pull request review. /// - public ReactiveCommand OpenFileInWorkingDirectory { get; } - - /// - /// Gets a command that opens an as it appears in the PR. - /// - public ReactiveCommand ViewFile { get; } + public ReactiveCommand ShowReview { get; } /// /// Initializes the view model. @@ -385,9 +375,9 @@ public async Task Load(IPullRequestModel pullRequest) TargetBranchDisplayName = GetBranchDisplayName(IsFromFork, pullRequest.Base?.Label); CommentCount = pullRequest.Comments.Count + pullRequest.ReviewComments.Count; Body = !string.IsNullOrWhiteSpace(pullRequest.Body) ? pullRequest.Body : Resources.NoDescriptionProvidedMarkdown; + Reviews = PullRequestReviewSummaryViewModel.BuildByUser(Session.User, pullRequest).ToList(); - var changes = await pullRequestsService.GetTreeChanges(LocalRepository, pullRequest); - ChangedFilesTree = (await CreateChangedFilesTree(pullRequest, changes)).Children.ToList(); + await Files.InitializeAsync(Session); var localBranches = await pullRequestsService.GetLocalBranches(LocalRepository, pullRequest).ToList(); @@ -456,6 +446,11 @@ public async Task Load(IPullRequestModel pullRequest) UpdateState = null; } + sessionSubscription?.Dispose(); + sessionSubscription = Session.WhenAnyValue(x => x.HasPendingReview) + .Skip(1) + .Subscribe(x => Reviews = PullRequestReviewSummaryViewModel.BuildByUser(Session.User, Session.PullRequest).ToList()); + if (firstLoad) { usageTracker.IncrementCounter(x => x.NumberOfPullRequestsOpened).Forget(); @@ -501,27 +496,6 @@ public override async Task Refresh() } } - /// - /// Gets a file as it appears in the pull request. - /// - /// The changed file. - /// - /// If true, gets the file at the PR head, otherwise gets the file at the PR merge base. - /// - /// The path to a temporary file. - public Task ExtractFile(IPullRequestFileNode file, bool head) - { - var relativePath = Path.Combine(file.DirectoryPath, file.FileName); - var encoding = pullRequestsService.GetEncoding(LocalRepository, relativePath); - - if (!head && file.OldPath != null) - { - relativePath = file.OldPath; - } - - return pullRequestsService.ExtractFile(LocalRepository, model, relativePath, head, encoding).ToTask(); - } - /// /// Gets the full path to a file in the working directory. /// @@ -529,7 +503,7 @@ public Task ExtractFile(IPullRequestFileNode file, bool head) /// The full path to the file in the working directory. public string GetLocalFilePath(IPullRequestFileNode file) { - return Path.Combine(LocalRepository.LocalPath, file.DirectoryPath, file.FileName); + return Path.Combine(LocalRepository.LocalPath, file.RelativePath); } /// @@ -564,54 +538,6 @@ void SubscribeOperationError(ReactiveCommand command) command.IsExecuting.Select(x => x).Subscribe(x => OperationError = null); } - async Task CreateChangedFilesTree(IPullRequestModel pullRequest, TreeChanges changes) - { - var dirs = new Dictionary - { - { string.Empty, new PullRequestDirectoryNode(string.Empty) } - }; - - foreach (var changedFile in pullRequest.ChangedFiles) - { - var node = new PullRequestFileNode( - LocalRepository.LocalPath, - changedFile.FileName, - changedFile.Sha, - changedFile.Status, - GetOldFileName(changedFile, changes)); - - var file = await Session.GetFile(changedFile.FileName); - var fileCommentCount = file?.WhenAnyValue(x => x.InlineCommentThreads) - .Subscribe(x => node.CommentCount = x.Count(y => y.LineNumber != -1)); - - var dir = GetDirectory(node.DirectoryPath, dirs); - dir.Files.Add(node); - } - - return dirs[string.Empty]; - } - - static PullRequestDirectoryNode GetDirectory(string path, Dictionary dirs) - { - PullRequestDirectoryNode dir; - - if (!dirs.TryGetValue(path, out dir)) - { - var parentPath = Path.GetDirectoryName(path); - var parentDir = GetDirectory(parentPath, dirs); - - dir = new PullRequestDirectoryNode(path); - - if (!parentDir.Directories.Any(x => x.DirectoryName == dir.DirectoryName)) - { - parentDir.Directories.Add(dir); - dirs.Add(path, dir); - } - } - - return dir; - } - static string GetBranchDisplayName(bool isFromFork, string targetBranchLabel) { if (targetBranchLabel != null) @@ -624,17 +550,6 @@ static string GetBranchDisplayName(bool isFromFork, string targetBranchLabel) } } - string GetOldFileName(IPullRequestFileModel file, TreeChanges changes) - { - if (file.Status == PullRequestFileStatus.Renamed) - { - var fileName = file.FileName.Replace("/", "\\"); - return changes?.Renamed.FirstOrDefault(x => x.Path == fileName)?.OldPath; - } - - return null; - } - IObservable DoCheckout(object unused) { return Observable.Defer(async () => @@ -709,6 +624,20 @@ async Task DoSyncSubmodules(object unused) } } + void DoShowReview(object item) + { + var review = (PullRequestReviewSummaryViewModel)item; + + if (review.State == PullRequestReviewState.Pending) + { + NavigateTo(Invariant($"{RemoteRepositoryOwner}/{LocalRepository.Name}/pull/{Number}/review/new")); + } + else + { + NavigateTo(Invariant($"{RemoteRepositoryOwner}/{LocalRepository.Name}/pull/{Number}/reviews/{review.User.Login}")); + } + } + class CheckoutCommandState : IPullRequestCheckoutState { public CheckoutCommandState(string caption, string disabledMessage) diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestDirectoryNode.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDirectoryNode.cs index 26b12b1dad..6a9d46ebde 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestDirectoryNode.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDirectoryNode.cs @@ -13,10 +13,10 @@ public class PullRequestDirectoryNode : IPullRequestDirectoryNode /// Initializes a new instance of the class. /// /// The path to the directory, relative to the repository. - public PullRequestDirectoryNode(string fullPath) + public PullRequestDirectoryNode(string relativePath) { - DirectoryName = System.IO.Path.GetFileName(fullPath); - DirectoryPath = fullPath; + DirectoryName = System.IO.Path.GetFileName(relativePath); + RelativePath = relativePath.Replace("/", "\\"); Directories = new List(); Files = new List(); } @@ -27,9 +27,9 @@ public PullRequestDirectoryNode(string fullPath) public string DirectoryName { get; } /// - /// Gets the full directory path, relative to the root of the repository. + /// Gets the path to the directory, relative to the root of the repository. /// - public string DirectoryPath { get; } + public string RelativePath { get; } /// /// Gets the directory children of the node. diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestFileNode.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestFileNode.cs index ed9246612c..f9e1e4e164 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestFileNode.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestFileNode.cs @@ -18,7 +18,7 @@ public class PullRequestFileNode : ReactiveObject, IPullRequestFileNode /// Initializes a new instance of the class. /// /// The absolute path to the repository. - /// The path to the file, relative to the repository. + /// The path to the file, relative to the repository. /// The SHA of the file. /// The way the file was changed. /// The string to display in the [message] box next to the filename. @@ -28,17 +28,17 @@ public class PullRequestFileNode : ReactiveObject, IPullRequestFileNode /// public PullRequestFileNode( string repositoryPath, - string path, + string relativePath, string sha, PullRequestFileStatus status, string oldPath) { Guard.ArgumentNotEmptyString(repositoryPath, nameof(repositoryPath)); - Guard.ArgumentNotEmptyString(path, nameof(path)); + Guard.ArgumentNotEmptyString(relativePath, nameof(relativePath)); Guard.ArgumentNotEmptyString(sha, nameof(sha)); - FileName = Path.GetFileName(path); - DirectoryPath = Path.GetDirectoryName(path); + FileName = Path.GetFileName(relativePath); + RelativePath = relativePath.Replace("/", "\\"); Sha = sha; Status = status; OldPath = oldPath; @@ -51,7 +51,7 @@ public PullRequestFileNode( { if (oldPath != null) { - StatusDisplay = Path.GetDirectoryName(oldPath) == Path.GetDirectoryName(path) ? + StatusDisplay = Path.GetDirectoryName(oldPath) == Path.GetDirectoryName(relativePath) ? Path.GetFileName(oldPath) : oldPath; } else @@ -67,9 +67,9 @@ public PullRequestFileNode( public string FileName { get; } /// - /// Gets the path to the file's directory, relative to the root of the repository. + /// Gets the path to the file, relative to the root of the repository. /// - public string DirectoryPath { get; } + public string RelativePath { get; } /// /// Gets the old path of a moved/renamed file, relative to the root of the repository. diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestFilesViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestFilesViewModel.cs new file mode 100644 index 0000000000..6448d906b4 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestFilesViewModel.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Services; +using LibGit2Sharp; +using ReactiveUI; +using Task = System.Threading.Tasks.Task; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// View model displaying a tree of changed files in a pull request. + /// + [Export(typeof(IPullRequestFilesViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public sealed class PullRequestFilesViewModel : ViewModelBase, IPullRequestFilesViewModel + { + readonly IPullRequestService service; + readonly BehaviorSubject isBranchCheckedOut = new BehaviorSubject(false); + + IPullRequestSession pullRequestSession; + Func commentFilter; + int changedFilesCount; + IReadOnlyList items; + CompositeDisposable subscriptions; + + [ImportingConstructor] + public PullRequestFilesViewModel( + IPullRequestService service, + IPullRequestEditorService editorService) + { + Guard.ArgumentNotNull(service, nameof(service)); + Guard.ArgumentNotNull(editorService, nameof(editorService)); + + this.service = service; + + DiffFile = ReactiveCommand.CreateAsyncTask(x => + editorService.OpenDiff(pullRequestSession, ((IPullRequestFileNode)x).RelativePath, "HEAD")); + ViewFile = ReactiveCommand.CreateAsyncTask(x => + editorService.OpenFile(pullRequestSession, ((IPullRequestFileNode)x).RelativePath, false)); + DiffFileWithWorkingDirectory = ReactiveCommand.CreateAsyncTask( + isBranchCheckedOut, + x => editorService.OpenDiff(pullRequestSession, ((IPullRequestFileNode)x).RelativePath)); + OpenFileInWorkingDirectory = ReactiveCommand.CreateAsyncTask( + isBranchCheckedOut, + x => editorService.OpenFile(pullRequestSession, ((IPullRequestFileNode)x).RelativePath, true)); + + OpenFirstComment = ReactiveCommand.CreateAsyncTask(async x => + { + var file = (IPullRequestFileNode)x; + var thread = await GetFirstCommentThread(file); + + if (thread != null) + { + await editorService.OpenDiff(pullRequestSession, file.RelativePath, thread); + } + }); + } + + /// + public int ChangedFilesCount + { + get { return changedFilesCount; } + private set { this.RaiseAndSetIfChanged(ref changedFilesCount, value); } + } + + /// + public IReadOnlyList Items + { + get { return items; } + private set { this.RaiseAndSetIfChanged(ref items, value); } + } + + /// + public void Dispose() + { + subscriptions?.Dispose(); + subscriptions = null; + } + + /// + public async Task InitializeAsync( + IPullRequestSession session, + Func filter = null) + { + Guard.ArgumentNotNull(session, nameof(session)); + + subscriptions?.Dispose(); + this.pullRequestSession = session; + this.commentFilter = filter; + subscriptions = new CompositeDisposable(); + subscriptions.Add(session.WhenAnyValue(x => x.IsCheckedOut).Subscribe(isBranchCheckedOut)); + + var dirs = new Dictionary + { + { string.Empty, new PullRequestDirectoryNode(string.Empty) } + }; + + using (var changes = await service.GetTreeChanges(session.LocalRepository, session.PullRequest)) + { + foreach (var changedFile in session.PullRequest.ChangedFiles) + { + var node = new PullRequestFileNode( + session.LocalRepository.LocalPath, + changedFile.FileName, + changedFile.Sha, + changedFile.Status, + GetOldFileName(changedFile, changes)); + var file = await session.GetFile(changedFile.FileName); + + if (file != null) + { + subscriptions.Add(file.WhenAnyValue(x => x.InlineCommentThreads) + .Subscribe(x => node.CommentCount = CountComments(x, filter))); + } + + var dir = GetDirectory(Path.GetDirectoryName(node.RelativePath), dirs); + dir.Files.Add(node); + } + } + + ChangedFilesCount = session.PullRequest.ChangedFiles.Count; + Items = dirs[string.Empty].Children.ToList(); + } + + /// + public ReactiveCommand DiffFile { get; } + + /// + public ReactiveCommand ViewFile { get; } + + /// + public ReactiveCommand DiffFileWithWorkingDirectory { get; } + + /// + public ReactiveCommand OpenFileInWorkingDirectory { get; } + + /// + public ReactiveCommand OpenFirstComment { get; } + + static int CountComments( + IEnumerable thread, + Func commentFilter) + { + return thread.Count(x => x.LineNumber != -1 && (commentFilter?.Invoke(x) ?? true)); + } + + static PullRequestDirectoryNode GetDirectory(string path, Dictionary dirs) + { + PullRequestDirectoryNode dir; + + if (!dirs.TryGetValue(path, out dir)) + { + var parentPath = Path.GetDirectoryName(path); + var parentDir = GetDirectory(parentPath, dirs); + + dir = new PullRequestDirectoryNode(path); + + if (!parentDir.Directories.Any(x => x.DirectoryName == dir.DirectoryName)) + { + parentDir.Directories.Add(dir); + dirs.Add(path, dir); + } + } + + return dir; + } + + static string GetOldFileName(IPullRequestFileModel file, TreeChanges changes) + { + if (file.Status == PullRequestFileStatus.Renamed) + { + var fileName = file.FileName.Replace("/", "\\"); + return changes?.Renamed.FirstOrDefault(x => x.Path == fileName)?.OldPath; + } + + return null; + } + + async Task GetFirstCommentThread(IPullRequestFileNode file) + { + var sessionFile = await pullRequestSession.GetFile(file.RelativePath); + var threads = sessionFile.InlineCommentThreads.AsEnumerable(); + + if (commentFilter != null) + { + threads = threads.Where(commentFilter); + } + + return threads.FirstOrDefault(); + } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs index 88f79ffb9d..bed4cb897f 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs @@ -309,7 +309,7 @@ public IAccount SelectedAssignee set { this.RaiseAndSetIfChanged(ref selectedAssignee, value); } } - IAccount emptyUser = new Account("[None]", false, false, 0, 0, Observable.Empty()); + IAccount emptyUser = new Account("[None]", false, false, 0, 0, string.Empty, Observable.Empty()); public IAccount EmptyUser { get { return emptyUser; } diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs new file mode 100644 index 0000000000..38d6f4c176 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive; +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; + +namespace GitHub.ViewModels.GitHubPane +{ + [Export(typeof(IPullRequestReviewAuthoringViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class PullRequestReviewAuthoringViewModel : PanePageViewModelBase, IPullRequestReviewAuthoringViewModel + { + static readonly ILogger log = LogManager.ForContext(); + + readonly IPullRequestEditorService editorService; + readonly IPullRequestSessionManager sessionManager; + readonly IModelServiceFactory modelServiceFactory; + IModelService modelService; + IPullRequestSession session; + IDisposable sessionSubscription; + IPullRequestReviewModel model; + IPullRequestModel pullRequestModel; + string body; + ObservableAsPropertyHelper canApproveRequestChanges; + IReadOnlyList fileComments; + string operationError; + + [ImportingConstructor] + public PullRequestReviewAuthoringViewModel( + IPullRequestEditorService editorService, + IPullRequestSessionManager sessionManager, + IModelServiceFactory modelServiceFactory, + IPullRequestFilesViewModel files) + { + Guard.ArgumentNotNull(editorService, nameof(editorService)); + Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); + Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory)); + Guard.ArgumentNotNull(files, nameof(files)); + + this.editorService = editorService; + this.sessionManager = sessionManager; + this.modelServiceFactory = modelServiceFactory; + + canApproveRequestChanges = this.WhenAnyValue( + x => x.Model, + x => x.PullRequestModel, + (review, pr) => review != null && pr != null && review.User.Login != pr.Author.Login) + .ToProperty(this, x => x.CanApproveRequestChanges); + + Files = files; + + var hasBodyOrComments = this.WhenAnyValue( + x => x.Body, + x => x.FileComments.Count, + (body, comments) => !string.IsNullOrWhiteSpace(body) || comments > 0); + + Approve = ReactiveCommand.CreateAsyncTask(_ => DoSubmit(Octokit.PullRequestReviewEvent.Approve)); + Comment = ReactiveCommand.CreateAsyncTask( + hasBodyOrComments, + _ => DoSubmit(Octokit.PullRequestReviewEvent.Comment)); + RequestChanges = ReactiveCommand.CreateAsyncTask( + hasBodyOrComments, + _ => DoSubmit(Octokit.PullRequestReviewEvent.RequestChanges)); + Cancel = ReactiveCommand.CreateAsyncTask(DoCancel); + } + + /// + public ILocalRepositoryModel LocalRepository { get; private set; } + + /// + public string RemoteRepositoryOwner { get; private set; } + + /// + public IPullRequestReviewModel Model + { + get { return model; } + private set { this.RaiseAndSetIfChanged(ref model, value); } + } + + /// + public IPullRequestModel PullRequestModel + { + get { return pullRequestModel; } + private set { this.RaiseAndSetIfChanged(ref pullRequestModel, value); } + } + + /// + public IPullRequestFilesViewModel Files { get; } + + /// + public string Body + { + get { return body; } + set { this.RaiseAndSetIfChanged(ref body, value); } + } + + /// + public bool CanApproveRequestChanges => canApproveRequestChanges.Value; + + /// + /// Gets the error message to be displayed in the action area as a result of an error in a + /// git operation. + /// + public string OperationError + { + get { return operationError; } + private set { this.RaiseAndSetIfChanged(ref operationError, value); } + } + + /// + public IReadOnlyList FileComments + { + get { return fileComments; } + private set { this.RaiseAndSetIfChanged(ref fileComments, value); } + } + + public ReactiveCommand NavigateToPullRequest { get; } + public ReactiveCommand Approve { get; } + public ReactiveCommand Comment { get; } + public ReactiveCommand RequestChanges { get; } + public ReactiveCommand Cancel { get; } + + public async Task InitializeAsync( + ILocalRepositoryModel localRepository, + IConnection connection, + string owner, + string repo, + int pullRequestNumber) + { + if (repo != localRepository.Name) + { + throw new NotSupportedException(); + } + + IsLoading = true; + + try + { + LocalRepository = localRepository; + RemoteRepositoryOwner = owner; + modelService = await modelServiceFactory.CreateAsync(connection); + var pullRequest = await modelService.GetPullRequest( + RemoteRepositoryOwner, + LocalRepository.Name, + pullRequestNumber); + await Load(pullRequest); + } + finally + { + IsLoading = false; + } + } + + /// + public override async Task Refresh() + { + try + { + Error = null; + IsBusy = true; + var pullRequest = await modelService.GetPullRequest( + RemoteRepositoryOwner, + LocalRepository.Name, + PullRequestModel.Number); + await Load(pullRequest); + } + catch (Exception ex) + { + log.Error( + ex, + "Error loading pull request review {Owner}/{Repo}/{Number}/{PullRequestReviewId} from {Address}", + RemoteRepositoryOwner, + LocalRepository.Name, + PullRequestModel.Number, + Model.Id, + modelService.ApiClient.HostAddress.Title); + Error = ex; + IsBusy = false; + } + } + + async Task Load(IPullRequestModel pullRequest) + { + try + { + session = await sessionManager.GetSession(pullRequest); + PullRequestModel = pullRequest; + + Model = pullRequest.Reviews.FirstOrDefault(x => + x.State == PullRequestReviewState.Pending && x.User.Login == session.User.Login) ?? + new PullRequestReviewModel + { + Body = string.Empty, + User = session.User, + State = PullRequestReviewState.Pending, + }; + + Body = Model.Body; + + sessionSubscription?.Dispose(); + await UpdateFileComments(); + sessionSubscription = session.PullRequestChanged.Subscribe(_ => UpdateFileComments().Forget()); + } + finally + { + IsBusy = false; + } + } + + bool FilterComments(IInlineCommentThreadModel thread) + { + return thread.Comments.Any(x => x.PullRequestReviewId == Model.Id); + } + + async Task UpdateFileComments() + { + var result = new List(); + + if (Model.Id == 0 && session.PendingReviewId != 0) + { + ((PullRequestReviewModel)Model).Id = session.PendingReviewId; + } + + foreach (var file in await session.GetAllFiles()) + { + foreach (var thread in file.InlineCommentThreads) + { + foreach (var comment in thread.Comments) + { + if (comment.PullRequestReviewId == Model.Id) + { + result.Add(new PullRequestReviewFileCommentViewModel( + editorService, + session, + comment)); + } + } + } + } + + FileComments = result; + await Files.InitializeAsync(session, FilterComments); + } + + async Task DoSubmit(Octokit.PullRequestReviewEvent e) + { + OperationError = null; + IsBusy = true; + + try + { + await session.PostReview(Body, e); + Close(); + } + catch (Exception ex) + { + OperationError = ex.Message; + } + finally + { + IsBusy = false; + } + } + + async Task DoCancel(object arg) + { + OperationError = null; + IsBusy = true; + + try + { + if (Model?.Id != 0) + { + await session.CancelReview(); + } + + Close(); + } + catch (Exception ex) + { + OperationError = ex.Message; + } + finally + { + IsBusy = false; + } + } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewFileCommentViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewFileCommentViewModel.cs new file mode 100644 index 0000000000..010a2d480c --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewFileCommentViewModel.cs @@ -0,0 +1,69 @@ +using System; +using System.Linq; +using System.Reactive; +using System.Threading.Tasks; +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 + { + readonly IPullRequestEditorService editorService; + readonly IPullRequestSession session; + readonly IPullRequestReviewCommentModel model; + IInlineCommentThreadModel thread; + + 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; + + Open = ReactiveCommand.CreateAsyncTask(DoOpen); + } + + /// + public string Body => model.Body; + + /// + public string RelativePath => model.Path; + + /// + public ReactiveCommand Open { get; } + + async Task DoOpen(object o) + { + try + { + if (thread == null) + { + var commit = model.Position.HasValue ? model.CommitId : model.OriginalCommitId; + var file = await session.GetFile(RelativePath, commit); + thread = file.InlineCommentThreads.FirstOrDefault(t => t.Comments.Any(c => c.Id == model.Id)); + } + + if (thread != null && thread.LineNumber != -1) + { + await editorService.OpenDiff(session, RelativePath, thread); + } + } + catch (Exception) + { + // TODO: Show error. + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewSummaryViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewSummaryViewModel.cs new file mode 100644 index 0000000000..b321b35bbe --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewSummaryViewModel.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GitHub.App; +using GitHub.Models; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Displays a short overview of a pull request review in the . + /// + public class PullRequestReviewSummaryViewModel : IPullRequestReviewSummaryViewModel + { + /// + public long Id { get; set; } + + /// + public IAccount User { get; set; } + + /// + public PullRequestReviewState State { get; set; } + + /// + public string StateDisplay => ToString(State); + + /// + public int FileCommentCount { get; set; } + + /// + /// Builds a collection of s by user. + /// + /// The current user. + /// The pull request model. + /// + /// This method builds a list similar to that found in the "Reviewers" section at the top- + /// right of the Pull Request page on GitHub. + /// + public static IEnumerable BuildByUser( + IAccount currentUser, + IPullRequestModel pullRequest) + { + var existing = new Dictionary(); + + foreach (var review in pullRequest.Reviews.OrderBy(x => x.Id)) + { + if (review.State == PullRequestReviewState.Pending && review.User.Login != currentUser.Login) + continue; + + PullRequestReviewSummaryViewModel previous; + existing.TryGetValue(review.User.Login, out previous); + + var previousPriority = ToPriority(previous); + var reviewPriority = ToPriority(review.State); + + if (reviewPriority >= previousPriority) + { + var count = pullRequest.ReviewComments + .Where(x => x.PullRequestReviewId == review.Id) + .Count(); + existing[review.User.Login] = new PullRequestReviewSummaryViewModel + { + Id = review.Id, + User = review.User, + State = review.State, + FileCommentCount = count + }; + } + } + + var result = existing.Values.OrderBy(x => x.User).AsEnumerable(); + + if (!result.Any(x => x.State == PullRequestReviewState.Pending)) + { + var newReview = new PullRequestReviewSummaryViewModel + { + State = PullRequestReviewState.Pending, + User = currentUser, + }; + result = result.Concat(new[] { newReview }); + } + + return result; + } + + static int ToPriority(PullRequestReviewSummaryViewModel review) + { + return review != null ? ToPriority(review.State) : 0; + } + + static int ToPriority(PullRequestReviewState state) + { + switch (state) + { + case PullRequestReviewState.Approved: + case PullRequestReviewState.ChangesRequested: + return 1; + case PullRequestReviewState.Pending: + return 2; + default: + return 0; + } + } + + static string ToString(PullRequestReviewState state) + { + switch (state) + { + case PullRequestReviewState.Approved: + return Resources.Approved; + case PullRequestReviewState.ChangesRequested: + return Resources.ChangesRequested; + case PullRequestReviewState.Commented: + case PullRequestReviewState.Dismissed: + return Resources.Commented; + case PullRequestReviewState.Pending: + return Resources.InProgress; + default: + throw new NotSupportedException(); + } + } + } +} 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.App/packages.config b/src/GitHub.App/packages.config index dfe0b889c6..aadd56f421 100644 --- a/src/GitHub.App/packages.config +++ b/src/GitHub.App/packages.config @@ -3,13 +3,25 @@ - + + + - - - - + + + + + + + + + + + + + + diff --git a/src/GitHub.Exports.Reactive/Api/IApiClient.cs b/src/GitHub.Exports.Reactive/Api/IApiClient.cs index 66e0a19321..8867a9e145 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. @@ -38,6 +39,24 @@ public interface IApiClient IObservable GetPullRequestsForRepository(string owner, string name); IObservable CreatePullRequest(NewPullRequest pullRequest, string owner, string repo); + /// + /// Posts a new PR review. + /// + /// The repository owner. + /// The repository name. + /// The pull request number. + /// The SHA of the commit being reviewed. + /// The review body. + /// The review event. + /// + IObservable PostPullRequestReview( + string owner, + string name, + int number, + string commitId, + string body, + PullRequestReviewEvent e); + /// /// Creates a new PR review comment. /// diff --git a/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj b/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj index 8b399b9220..21e4f0270c 100644 --- a/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj +++ b/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj @@ -182,6 +182,7 @@ + @@ -194,7 +195,13 @@ + + + + + + diff --git a/src/GitHub.Exports.Reactive/Models/IInlineCommentThreadModel.cs b/src/GitHub.Exports.Reactive/Models/IInlineCommentThreadModel.cs index fcd2621dbc..1036eae959 100644 --- a/src/GitHub.Exports.Reactive/Models/IInlineCommentThreadModel.cs +++ b/src/GitHub.Exports.Reactive/Models/IInlineCommentThreadModel.cs @@ -36,19 +36,14 @@ public interface IInlineCommentThreadModel bool IsStale { get; set; } /// - /// Gets or sets the 0-based line number of the comment. + /// Gets or sets the 0-based line number of the comment, or -1 of the thread is outdated. /// int LineNumber { get; set; } /// - /// Gets the SHA of the commit that the thread was left con. + /// Gets the SHA of the commit that the thread appears on. /// - string OriginalCommitSha { get; } - - /// - /// Gets the 1-based line number in the original diff that the thread was left on. - /// - int OriginalPosition { get; } + string CommitSha { get; } /// /// Gets the relative path to the file that the thread is on. diff --git a/src/GitHub.Exports.Reactive/Models/IPullRequestSessionFile.cs b/src/GitHub.Exports.Reactive/Models/IPullRequestSessionFile.cs index 34bc72ccfe..18492e7ed4 100644 --- a/src/GitHub.Exports.Reactive/Models/IPullRequestSessionFile.cs +++ b/src/GitHub.Exports.Reactive/Models/IPullRequestSessionFile.cs @@ -33,6 +33,12 @@ public interface IPullRequestSessionFile : INotifyPropertyChanged /// string CommitSha { get; } + /// + /// Gets a value indicating whether is tracking the related pull + /// request HEAD or whether it is pinned at a particular commit. + /// + bool IsTrackingHead { get; } + /// /// Gets the path to the file relative to the repository. /// diff --git a/src/GitHub.Exports.Reactive/Models/PullRequestTextBufferInfo.cs b/src/GitHub.Exports.Reactive/Models/PullRequestTextBufferInfo.cs index e4bab6b4c9..704002064c 100644 --- a/src/GitHub.Exports.Reactive/Models/PullRequestTextBufferInfo.cs +++ b/src/GitHub.Exports.Reactive/Models/PullRequestTextBufferInfo.cs @@ -1,12 +1,13 @@ using System; +using GitHub.Extensions; using GitHub.Services; namespace GitHub.Models { /// /// When attached as a property to a Visual Studio ITextBuffer, informs the inline comment - /// tagger that the buffer represents a buffer opened from a pull request at the HEAD commit - /// of a pull request. + /// tagger that the buffer represents a buffer opened from a pull request at the specified + /// commit of a pull request. /// public class PullRequestTextBufferInfo { @@ -15,14 +16,21 @@ public class PullRequestTextBufferInfo /// /// The pull request session. /// The relative path to the file in the repository. + /// The SHA of the commit. /// Which side of a diff comparision the buffer represents. public PullRequestTextBufferInfo( IPullRequestSession session, string relativePath, + string commitSha, DiffSide? side) { + Guard.ArgumentNotNull(session, nameof(session)); + Guard.ArgumentNotEmptyString(relativePath, nameof(relativePath)); + Guard.ArgumentNotEmptyString(commitSha, nameof(commitSha)); + Session = session; RelativePath = relativePath; + CommitSha = commitSha; Side = side; } @@ -36,6 +44,11 @@ public PullRequestTextBufferInfo( /// public string RelativePath { get; } + /// + /// Gets the SHA of the commit. + /// + public string CommitSha { get; } + /// /// Gets a value indicating which side of a diff comparision the buffer represents. /// diff --git a/src/GitHub.Exports.Reactive/Services/IAvatarProvider.cs b/src/GitHub.Exports.Reactive/Services/IAvatarProvider.cs index ccf656280a..1006256d96 100644 --- a/src/GitHub.Exports.Reactive/Services/IAvatarProvider.cs +++ b/src/GitHub.Exports.Reactive/Services/IAvatarProvider.cs @@ -10,6 +10,7 @@ public interface IAvatarProvider : IDisposable BitmapImage DefaultUserBitmapImage { get; } BitmapImage DefaultOrgBitmapImage { get; } IObservable GetAvatar(IAvatarContainer account); + IObservable GetAvatar(string avatarUri); IObservable InvalidateAvatar(IAvatarContainer account); } } 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/Services/IPullRequestEditorService.cs b/src/GitHub.Exports.Reactive/Services/IPullRequestEditorService.cs index 0f5f779c8c..5b859bd16f 100644 --- a/src/GitHub.Exports.Reactive/Services/IPullRequestEditorService.cs +++ b/src/GitHub.Exports.Reactive/Services/IPullRequestEditorService.cs @@ -1,9 +1,48 @@ -using Microsoft.VisualStudio.TextManager.Interop; +using System.Threading.Tasks; +using GitHub.Models; +using Microsoft.VisualStudio.TextManager.Interop; namespace GitHub.Services { + /// + /// Services for opening views of pull request files in Visual Studio. + /// public interface IPullRequestEditorService { + /// + /// Opens an editor for a file in a pull request. + /// + /// The pull request session. + /// The path to the file, relative to the repository. + /// + /// If true opens the file in the working directory, if false opens the file in the HEAD + /// commit of the pull request. + /// + /// A task tracking the operation. + Task OpenFile(IPullRequestSession session, string relativePath, bool workingDirectory); + + /// + /// Opens an diff viewer for a file in a pull request. + /// + /// The pull request session. + /// The path to the file, relative to the repository. + /// + /// The commit SHA of the right hand side of the diff. Pass null to compare with the + /// working directory, or "HEAD" to compare with the HEAD commit of the pull request. + /// + /// A task tracking the operation. + Task OpenDiff(IPullRequestSession session, string relativePath, string headSha = null); + + /// + /// Opens an diff viewer for a file in a pull request with the specified inline comment + /// thread open. + /// + /// The pull request session. + /// The path to the file, relative to the repository. + /// The thread to open + /// A task tracking the operation. + Task OpenDiff(IPullRequestSession session, string relativePath, IInlineCommentThreadModel thread); + /// /// Find the active text view. /// diff --git a/src/GitHub.Exports.Reactive/Services/IPullRequestService.cs b/src/GitHub.Exports.Reactive/Services/IPullRequestService.cs index 4d8ab5e19f..7ad69d7866 100644 --- a/src/GitHub.Exports.Reactive/Services/IPullRequestService.cs +++ b/src/GitHub.Exports.Reactive/Services/IPullRequestService.cs @@ -5,8 +5,6 @@ using System.Threading.Tasks; using GitHub.Models; using LibGit2Sharp; -using Octokit; -using IConnection = GitHub.Models.IConnection; namespace GitHub.Services { @@ -115,6 +113,14 @@ IObservable CreatePullRequest(IModelService modelService, /// IObservable CalculateHistoryDivergence(ILocalRepositoryModel repository, int pullRequestNumber); + /// + /// Gets the SHA of the merge base for a pull request. + /// + /// The repository. + /// The pull request details. + /// + Task GetMergeBase(ILocalRepositoryModel repository, IPullRequestModel pullRequest); + /// /// Gets the changes between the pull request base and head. /// @@ -148,19 +154,19 @@ IObservable CreatePullRequest(IModelService modelService, Encoding GetEncoding(ILocalRepositoryModel repository, string relativePath); /// - /// Gets a file as it appears in a pull request. + /// Extracts a file at the specified commit to a temporary file. /// /// The repository. /// The pull request details. - /// The filename relative to the repository root. - /// If true, gets the file at the PR head, otherwise gets the file at the PR base. + /// The path to the file, relative to the repository root. + /// The SHA of the commit. /// The encoding to use. - /// The paths of the left and right files for the diff. - IObservable ExtractFile( + /// The path to the temporary file. + Task ExtractToTempFile( ILocalRepositoryModel repository, IPullRequestModel pullRequest, - string fileName, - bool head, + string relativePath, + string commitSha, Encoding encoding); /// diff --git a/src/GitHub.Exports.Reactive/Services/IPullRequestSession.cs b/src/GitHub.Exports.Reactive/Services/IPullRequestSession.cs index a2ee33578c..cff17965b8 100644 --- a/src/GitHub.Exports.Reactive/Services/IPullRequestSession.cs +++ b/src/GitHub.Exports.Reactive/Services/IPullRequestSession.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using GitHub.Models; +using Octokit; namespace GitHub.Services { @@ -50,6 +51,17 @@ public interface IPullRequestSession /// string RepositoryOwner { get; } + /// + /// Gets a value indicating whether the pull request has a pending review for the current + /// user. + /// + bool HasPendingReview { get; } + + /// + /// Gets the ID of the current pending pull request review for the user. + /// + long PendingReviewId { get; } + /// /// Gets all files touched by the pull request. /// @@ -62,11 +74,14 @@ public interface IPullRequestSession /// Gets a file touched by the pull request. /// /// The relative path to the file. + /// + /// The commit at which to get the file contents, or "HEAD" to track the pull request head. + /// /// /// A object or null if the file was not touched by /// the pull request. /// - Task GetFile(string relativePath); + Task GetFile(string relativePath, string commitSha = "HEAD"); /// /// Gets the merge base SHA for the pull request. @@ -80,17 +95,48 @@ public interface IPullRequestSession /// The comment body. /// THe SHA of the commit to comment on. /// The relative path of the file to comment on. + /// The diff between the PR head and base. /// The line index in the diff to comment on. /// A comment model. - Task PostReviewComment(string body, string commitId, string path, int position); + Task PostReviewComment( + string body, + string commitId, + string path, + IReadOnlyList fileDiff, + int position); /// /// Posts a PR review comment reply. /// /// The comment body. - /// The comment ID to reply to. + /// The REST ID of the comment to reply to. + /// The GraphQL ID of the comment to reply to. /// - Task PostReviewComment(string body, int inReplyTo); + Task PostReviewComment( + string body, + int inReplyTo, + string inReplyToNodeId); + + /// + /// Starts a new pending pull request review. + /// + Task StartReview(); + + /// + /// Cancels the currently pending review. + /// + /// + /// There is no pending review. + /// + Task CancelReview(); + + /// + /// Posts the currently pending review. + /// + /// The review body. + /// The review event. + /// The review model. + Task PostReview(string body, PullRequestReviewEvent e); /// /// Updates the pull request session with a new pull request model in response to a refresh diff --git a/src/GitHub.Exports.Reactive/Services/PullRequestSessionExtensions.cs b/src/GitHub.Exports.Reactive/Services/PullRequestSessionExtensions.cs new file mode 100644 index 0000000000..4d8f1dca8f --- /dev/null +++ b/src/GitHub.Exports.Reactive/Services/PullRequestSessionExtensions.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using GitHub.Extensions; + +namespace GitHub.Services +{ + /// + /// Extension methods for . + /// + public static class PullRequestSessionExtensions + { + /// + /// Gets the head (source) branch label for a pull request, stripping the owner if the pull + /// request is not from a fork. + /// + /// The pull request session. + /// The head branch label + public static string GetHeadBranchDisplay(this IPullRequestSession session) + { + Guard.ArgumentNotNull(session, nameof(session)); + return GetBranchDisplay(session.IsPullRequestFromFork(), session.PullRequest?.Head?.Label); + } + + /// + /// Gets the head (target) branch label for a pull request, stripping the owner if the pull + /// request is not from a fork. + /// + /// The pull request session. + /// The head branch label + public static string GetBaseBranchDisplay(this IPullRequestSession session) + { + Guard.ArgumentNotNull(session, nameof(session)); + return GetBranchDisplay(session.IsPullRequestFromFork(), session.PullRequest?.Base?.Label); + } + + /// + /// Returns a value that determines whether the pull request comes from a fork. + /// + /// The pull request session. + /// True if the pull request is from a fork, otherwise false. + public static bool IsPullRequestFromFork(this IPullRequestSession session) + { + Guard.ArgumentNotNull(session, nameof(session)); + + var headUrl = session.PullRequest.Head.RepositoryCloneUrl?.ToRepositoryUrl(); + var localUrl = session.LocalRepository.CloneUrl?.ToRepositoryUrl(); + return headUrl != null && localUrl != null ? headUrl != localUrl : false; + } + + static string GetBranchDisplay(bool fork, string label) + { + if (label != null) + { + return fork ? label : label.Split(':').Last(); + } + + return "[invalid]"; + } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestChangeNode.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestChangeNode.cs index 2661367609..a47f4889d1 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestChangeNode.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestChangeNode.cs @@ -8,9 +8,8 @@ namespace GitHub.ViewModels.GitHubPane public interface IPullRequestChangeNode { /// - /// Gets the path to the file (not including the filename) or directory, relative to the - /// root of the repository. + /// Gets the path to the file or directory, relative to the root of the repository. /// - string DirectoryPath { get; } + string RelativePath { get; } } } \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDetailViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDetailViewModel.cs index 078692d948..41e20eb3f3 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDetailViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDetailViewModel.cs @@ -126,9 +126,14 @@ public interface IPullRequestDetailViewModel : IPanePageViewModel, IOpenInBrowse string Body { get; } /// - /// Gets the changed files as a tree. + /// Gets the latest pull request review for each user. /// - IReadOnlyList ChangedFilesTree { get; } + IReadOnlyList Reviews { get; } + + /// + /// Gets the pull request's changed files. + /// + IPullRequestFilesViewModel Files { get; } /// /// Gets the state associated with the command. @@ -166,25 +171,9 @@ public interface IPullRequestDetailViewModel : IPanePageViewModel, IOpenInBrowse ReactiveCommand OpenOnGitHub { get; } /// - /// Gets a command that diffs an between BASE and HEAD. - /// - ReactiveCommand DiffFile { get; } - - /// - /// Gets a command that diffs an between the version in - /// the working directory and HEAD. + /// Gets a command that navigates to a pull request review. /// - ReactiveCommand DiffFileWithWorkingDirectory { get; } - - /// - /// Gets a command that opens an from disk. - /// - ReactiveCommand OpenFileInWorkingDirectory { get; } - - /// - /// Gets a command that opens an as it appears in the PR. - /// - ReactiveCommand ViewFile { get; } + ReactiveCommand ShowReview { get; } /// /// Initializes the view model. @@ -201,16 +190,6 @@ Task InitializeAsync( string repo, int number); - /// - /// Gets a file as it appears in the pull request. - /// - /// The changed file. - /// - /// If true, gets the file at the PR head, otherwise gets the file at the PR merge base. - /// - /// The path to a temporary file. - Task ExtractFile(IPullRequestFileNode file, bool head); - /// /// Gets the full path to a file in the working directory. /// diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestFilesViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestFilesViewModel.cs new file mode 100644 index 0000000000..6953271341 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestFilesViewModel.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using LibGit2Sharp; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Represents a tree of changed files in a pull request. + /// + public interface IPullRequestFilesViewModel : IViewModel, IDisposable + { + /// + /// Gets the number of changed files in the pull request. + /// + int ChangedFilesCount { get; } + + /// + /// Gets the root nodes of the tree. + /// + IReadOnlyList Items { get; } + + /// + /// Gets a command that diffs an between BASE and HEAD. + /// + ReactiveCommand DiffFile { get; } + + /// + /// Gets a command that opens an as it appears in the PR. + /// + ReactiveCommand ViewFile { get; } + + /// + /// Gets a command that diffs an between the version in + /// the working directory and HEAD. + /// + ReactiveCommand DiffFileWithWorkingDirectory { get; } + + /// + /// Gets a command that opens an from disk. + /// + ReactiveCommand OpenFileInWorkingDirectory { get; } + + /// + /// Gets a command that opens the first comment for a in + /// the diff viewer. + /// + ReactiveCommand OpenFirstComment { get; } + + /// + /// Initializes the view model. + /// + /// The pull request session. + /// An optional review comment filter. + Task InitializeAsync( + IPullRequestSession session, + Func commentFilter = null); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs new file mode 100644 index 0000000000..764027f559 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Represents a view model for displaying details of a pull request review that is being + /// authored. + /// + public interface IPullRequestReviewAuthoringViewModel : IPanePageViewModel, IDisposable + { + /// + /// 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 underlying pull request review model. + /// + IPullRequestReviewModel Model { get; } + + /// + /// Gets the underlying pull request model. + /// + IPullRequestModel PullRequestModel { get; } + + /// + /// Gets or sets the body of the pull request review to be submitted. + /// + string Body { get; } + + /// + /// Gets a value indicating whether the user can approve/request changes on the pull request. + /// + bool CanApproveRequestChanges { get; } + + /// + /// Gets the pull request's changed files. + /// + IPullRequestFilesViewModel Files { get; } + + /// + /// Gets a list of the file comments in the review. + /// + IReadOnlyList FileComments { get; } + + /// + /// Gets the error message to be displayed in the action area as a result of an error submitting. + /// + string OperationError { get; } + + /// + /// Gets a command which navigates to the parent pull request. + /// + ReactiveCommand NavigateToPullRequest { get; } + + /// + /// Gets a command which submits the review as an approval. + /// + ReactiveCommand Approve { get; } + + /// + /// Gets a command which submits the review as a comment. + /// + ReactiveCommand Comment { get; } + + /// + /// Gets a command which submits the review requesting changes. + /// + ReactiveCommand RequestChanges { get; } + + /// + /// Gets a command which cancels the review. + /// + ReactiveCommand Cancel { get; } + + /// + /// Initializes the view model for creating a new review. + /// + /// 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. + Task InitializeAsync( + ILocalRepositoryModel localRepository, + IConnection connection, + string owner, + string repo, + int pullRequestNumber); + } +} 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/IPullRequestReviewSummaryViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewSummaryViewModel.cs new file mode 100644 index 0000000000..178a1d8d0f --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewSummaryViewModel.cs @@ -0,0 +1,35 @@ +using GitHub.Models; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Displays a short overview of a pull request review in the . + /// + public interface IPullRequestReviewSummaryViewModel + { + /// + /// Gets the ID of the pull request review. + /// + long Id { get; set; } + + /// + /// Gets the user who submitted the review. + /// + IAccount User { get; set; } + + /// + /// Gets the state of the review. + /// + PullRequestReviewState State { get; set; } + + /// + /// Gets a string representing the state of the review. + /// + string StateDisplay { get; } + + /// + /// Gets the number of file comments in the review. + /// + int FileCommentCount { get; set; } + } +} \ 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/GitHub.Exports.csproj b/src/GitHub.Exports/GitHub.Exports.csproj index ea23e9f106..2ac5424a96 100644 --- a/src/GitHub.Exports/GitHub.Exports.csproj +++ b/src/GitHub.Exports/GitHub.Exports.csproj @@ -165,9 +165,9 @@ - + diff --git a/src/GitHub.Exports/Models/IAccount.cs b/src/GitHub.Exports/Models/IAccount.cs index 313d677944..617d770b91 100644 --- a/src/GitHub.Exports/Models/IAccount.cs +++ b/src/GitHub.Exports/Models/IAccount.cs @@ -14,6 +14,7 @@ public interface IAccount : ICopyable, string Login { get; } int OwnedPrivateRepos { get; } long PrivateReposInPlan { get; } - BitmapSource Avatar { get; } + string AvatarUrl { get; } + BitmapSource Avatar { get; } } } diff --git a/src/GitHub.Exports/Models/ICommentModel.cs b/src/GitHub.Exports/Models/ICommentModel.cs index 82fc036c30..121c7862fd 100644 --- a/src/GitHub.Exports/Models/ICommentModel.cs +++ b/src/GitHub.Exports/Models/ICommentModel.cs @@ -12,6 +12,11 @@ public interface ICommentModel /// int Id { get; } + /// + /// Gets the GraphQL ID of the comment. + /// + string NodeId { get; } + /// /// Gets the author of the comment. /// diff --git a/src/GitHub.Exports/Models/IInlineCommentModel.cs b/src/GitHub.Exports/Models/IInlineCommentModel.cs deleted file mode 100644 index 40bbcb09c4..0000000000 --- a/src/GitHub.Exports/Models/IInlineCommentModel.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; - -namespace GitHub.Models -{ - /// - /// Represents an pull request review comment that can be displayed inline in a code editor. - /// - public interface IInlineCommentModel - { - /// - /// Gets the 0-based line number of the comment. - /// - int LineNumber { get; } - - /// - /// Gets a value indicating whether the model is stale due to a change in the underlying - /// file. - /// - bool IsStale { get; } - - /// - /// Gets the original pull request review comment. - /// - IPullRequestReviewCommentModel Original { get; } - } -} diff --git a/src/GitHub.Exports/Models/IPullRequestModel.cs b/src/GitHub.Exports/Models/IPullRequestModel.cs index f185c2ef44..40996c78dc 100644 --- a/src/GitHub.Exports/Models/IPullRequestModel.cs +++ b/src/GitHub.Exports/Models/IPullRequestModel.cs @@ -31,8 +31,9 @@ public interface IPullRequestModel : ICopyable, DateTimeOffset UpdatedAt { get; } IAccount Author { get; } IAccount Assignee { get; } - IReadOnlyCollection ChangedFiles { get; } - IReadOnlyCollection Comments { get; } - IReadOnlyCollection ReviewComments { get; set; } + IReadOnlyList ChangedFiles { get; } + IReadOnlyList Comments { get; } + IReadOnlyList Reviews { get; set; } + IReadOnlyList ReviewComments { get; set; } } } diff --git a/src/GitHub.Exports/Models/IPullRequestReviewCommentModel.cs b/src/GitHub.Exports/Models/IPullRequestReviewCommentModel.cs index b695b116d9..b2399aa15a 100644 --- a/src/GitHub.Exports/Models/IPullRequestReviewCommentModel.cs +++ b/src/GitHub.Exports/Models/IPullRequestReviewCommentModel.cs @@ -7,6 +7,11 @@ namespace GitHub.Models /// public interface IPullRequestReviewCommentModel : ICommentModel { + /// + /// Gets the ID of the related pull request review. + /// + int PullRequestReviewId { get; set; } + /// /// The relative path to the file that the comment was made on. /// @@ -38,5 +43,10 @@ public interface IPullRequestReviewCommentModel : ICommentModel /// The diff hunk used to match the pull request. /// string DiffHunk { get; } + + /// + /// Gets a value indicating whether the comment is part of a pending review. + /// + bool IsPending { get; set; } } } diff --git a/src/GitHub.Exports/Models/IPullRequestReviewModel.cs b/src/GitHub.Exports/Models/IPullRequestReviewModel.cs new file mode 100644 index 0000000000..4e633ccb68 --- /dev/null +++ b/src/GitHub.Exports/Models/IPullRequestReviewModel.cs @@ -0,0 +1,76 @@ +using System; + +namespace GitHub.Models +{ + /// + /// The possible states of a pull request review. + /// + public enum PullRequestReviewState + { + /// + /// A review that has not yet been submitted. + /// + Pending, + + /// + /// An informational review. + /// + Commented, + + /// + /// A review allowing the pull request to merge. + /// + Approved, + + /// + /// A review blocking the pull request from merging. + /// + ChangesRequested, + + /// + /// A review that has been dismissed. + /// + Dismissed, + } + + /// + /// Represents a review of a pull request. + /// + public interface IPullRequestReviewModel + { + /// + /// Gets the ID of the review. + /// + long Id { get; } + + /// + /// Gets the GraphQL ID for the review. + /// + string NodeId { get; set; } + + /// + /// Gets the author of the review. + /// + IAccount User { get; } + + /// + /// Gets the body of the review. + /// + string Body { get; } + + /// + /// Gets the state of the review. + /// + PullRequestReviewState State { get; } + + /// + /// 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/Models/UsageModel.cs b/src/GitHub.Exports/Models/UsageModel.cs index f1ece0eb34..2d4d3dc396 100644 --- a/src/GitHub.Exports/Models/UsageModel.cs +++ b/src/GitHub.Exports/Models/UsageModel.cs @@ -63,6 +63,8 @@ public class MeasuresModel public int NumberOfPRDetailsNavigateToEditor { get; set; } public int NumberOfPRReviewDiffViewInlineCommentOpen { get; set; } public int NumberOfPRReviewDiffViewInlineCommentPost { get; set; } + public int NumberOfPRReviewDiffViewInlineCommentStartReview { get; set; } + public int NumberOfPRReviewPosts { get; set; } public int NumberOfShowCurrentPullRequest { get; set; } } } diff --git a/src/GitHub.Exports/Primitives/HostAddress.cs b/src/GitHub.Exports/Primitives/HostAddress.cs index 61b3e215c4..bcea51d364 100644 --- a/src/GitHub.Exports/Primitives/HostAddress.cs +++ b/src/GitHub.Exports/Primitives/HostAddress.cs @@ -44,7 +44,7 @@ private HostAddress(Uri enterpriseUri) { WebUri = new Uri(enterpriseUri, new Uri("/", UriKind.Relative)); ApiUri = new Uri(enterpriseUri, new Uri("/api/v3/", UriKind.Relative)); - //CredentialCacheKeyHost = ApiUri.Host; + GraphQLUri = new Uri(enterpriseUri, new Uri("/api/graphql", UriKind.Relative)); CredentialCacheKeyHost = WebUri.ToString(); } @@ -52,7 +52,7 @@ public HostAddress() { WebUri = new Uri("https://github.com"); ApiUri = new Uri("https://api.github.com"); - //CredentialCacheKeyHost = "github.com"; + GraphQLUri = new Uri("https://api.github.com/graphql"); CredentialCacheKeyHost = WebUri.ToString(); } @@ -67,6 +67,12 @@ public HostAddress() /// public Uri ApiUri { get; set; } + /// + /// The Base Url to the host's GraphQL API endpoint. For example, "https://api.github.com/graphql" or + /// "https://github-enterprise.com/api/graphql" + /// + public Uri GraphQLUri { get; set; } + // If the host name is "api.github.com" or "gist.github.com", we really only want "github.com", // since that's the same cache key for all the other github.com operations. public string CredentialCacheKeyHost { get; private set; } diff --git a/src/GitHub.Exports/ViewModels/GitHubPane/IGitHubPaneViewModel.cs b/src/GitHub.Exports/ViewModels/GitHubPane/IGitHubPaneViewModel.cs index 5b6771c4d3..6e44ca5bf0 100644 --- a/src/GitHub.Exports/ViewModels/GitHubPane/IGitHubPaneViewModel.cs +++ b/src/GitHub.Exports/ViewModels/GitHubPane/IGitHubPaneViewModel.cs @@ -90,5 +90,22 @@ 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); + + /// + /// Shows a pane authoring a pull request review. + /// + /// The repository owner. + /// The repository name. + /// The pull rqeuest number. + Task ShowPullRequestReviewAuthoring(string owner, string repo, int number); } } \ No newline at end of file diff --git a/src/GitHub.Extensions/StringExtensions.cs b/src/GitHub.Extensions/StringExtensions.cs index f7b01a2447..9e3c649e25 100644 --- a/src/GitHub.Extensions/StringExtensions.cs +++ b/src/GitHub.Extensions/StringExtensions.cs @@ -4,8 +4,11 @@ using System.Globalization; using System.IO; using System.Linq; +using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; +using GitHub.Logging; +using Splat; namespace GitHub.Extensions { @@ -222,5 +225,23 @@ public static string Humanize(this string s) var combined = String.Join(" ", result); return Char.ToUpper(combined[0], CultureInfo.InvariantCulture) + combined.Substring(1); } + + /// + /// Generates a SHA256 hash for a string. + /// + /// The input string. + /// The SHA256 hash. + public static string GetSha256Hash(this string input) + { + Guard.ArgumentNotNull(input, nameof(input)); + + using (var sha256 = SHA256.Create()) + { + var bytes = Encoding.UTF8.GetBytes(input); + var hash = sha256.ComputeHash(bytes); + + return string.Join("", hash.Select(b => b.ToString("x2", CultureInfo.InvariantCulture))); + } + } } } diff --git a/src/GitHub.InlineReviews/GitHub.InlineReviews.csproj b/src/GitHub.InlineReviews/GitHub.InlineReviews.csproj index cd778f5bd5..c743cdc468 100644 --- a/src/GitHub.InlineReviews/GitHub.InlineReviews.csproj +++ b/src/GitHub.InlineReviews/GitHub.InlineReviews.csproj @@ -93,8 +93,6 @@ - - @@ -103,32 +101,19 @@ - - - + - - - - + - - DiffCommentThreadView.xaml - - GlyphMarginGrid.xaml InlineCommentPeekView.xaml - - - PullRequestCommentsView.xaml - @@ -178,6 +163,10 @@ {252ce1c2-027a-4445-a3c2-e4d6c80a935a} Splat-Net45 + + {b389adaf-62cc-486e-85b4-2d8b078df763} + GitHub.Api + {1A1DA411-8D1F-4578-80A6-04576BEA2DC5} GitHub.App @@ -202,10 +191,6 @@ {2d3d2834-33be-45ca-b3cc-12f853557d7b} GitHub.Services.Vssdk - - {DDB343BA-2EC8-40D4-B991-951ABFF2F5DB} - GitHub.Services - {158b05e8-fdbc-4d71-b871-c96e28d5adf5} GitHub.UI.Reactive @@ -366,6 +351,18 @@ ..\..\packages\Microsoft.VisualStudio.Validation.14.1.111\lib\net45\Microsoft.VisualStudio.Validation.dll True + + ..\..\packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll + True + + + ..\..\packages\Octokit.GraphQL.0.0.2-alpha\lib\netstandard1.1\Octokit.GraphQL.dll + True + + + ..\..\packages\Octokit.GraphQL.0.0.2-alpha\lib\netstandard1.1\Octokit.GraphQL.Core.dll + True + @@ -423,10 +420,6 @@ Designer true - - Designer - MSBuild:Compile - Designer MSBuild:Compile @@ -435,10 +428,6 @@ Designer MSBuild:Compile - - Designer - MSBuild:Compile - MSBuild:Compile Designer diff --git a/src/GitHub.InlineReviews/InlineReviewsPackage.cs b/src/GitHub.InlineReviews/InlineReviewsPackage.cs index 1bb4310743..736139be3a 100644 --- a/src/GitHub.InlineReviews/InlineReviewsPackage.cs +++ b/src/GitHub.InlineReviews/InlineReviewsPackage.cs @@ -3,7 +3,6 @@ using System.Runtime.InteropServices; using System.Threading; using GitHub.Commands; -using GitHub.InlineReviews.Views; using GitHub.Services.Vssdk.Commands; using GitHub.VisualStudio; using Microsoft.VisualStudio.ComponentModelHost; @@ -17,7 +16,6 @@ namespace GitHub.InlineReviews [Guid(Guids.InlineReviewsPackageId)] [ProvideAutoLoad(Guids.UIContext_Git, PackageAutoLoadFlags.BackgroundLoad)] [ProvideMenuResource("Menus.ctmenu", 1)] - [ProvideToolWindow(typeof(PullRequestCommentsPane), DocumentLikeTool = true)] public class InlineReviewsPackage : AsyncPackage { protected override async Task InitializeAsync( diff --git a/src/GitHub.InlineReviews/Models/InlineCommentThreadModel.cs b/src/GitHub.InlineReviews/Models/InlineCommentThreadModel.cs index 4a6fac84e4..4619d3c545 100644 --- a/src/GitHub.InlineReviews/Models/InlineCommentThreadModel.cs +++ b/src/GitHub.InlineReviews/Models/InlineCommentThreadModel.cs @@ -19,7 +19,7 @@ class InlineCommentThreadModel : ReactiveObject, IInlineCommentThreadModel /// Initializes a new instance of the class. /// /// The relative path to the file that the thread is on. - /// The SHA of the commit that the thread was left con. + /// The SHA of the commit that the thread appears on. /// /// The 1-based line number in the original diff that the thread was left on. /// @@ -28,20 +28,18 @@ class InlineCommentThreadModel : ReactiveObject, IInlineCommentThreadModel /// public InlineCommentThreadModel( string relativePath, - string originalCommitSha, - int originalPosition, + string commitSha, IList diffMatch, IEnumerable comments) { Guard.ArgumentNotNull(relativePath, nameof(relativePath)); - Guard.ArgumentNotNull(originalCommitSha, nameof(originalCommitSha)); + Guard.ArgumentNotNull(commitSha, nameof(commitSha)); Guard.ArgumentNotNull(diffMatch, nameof(diffMatch)); Comments = comments.ToList(); DiffMatch = diffMatch; DiffLineType = diffMatch[0].Type; - OriginalCommitSha = originalCommitSha; - OriginalPosition = originalPosition; + CommitSha = commitSha; RelativePath = relativePath; } @@ -69,10 +67,7 @@ public int LineNumber } /// - public string OriginalCommitSha { get; } - - /// - public int OriginalPosition { get; } + public string CommitSha { get; } /// public string RelativePath { get; } diff --git a/src/GitHub.InlineReviews/Models/PullRequestSessionFile.cs b/src/GitHub.InlineReviews/Models/PullRequestSessionFile.cs index d625d931f6..a38b0d22c7 100644 --- a/src/GitHub.InlineReviews/Models/PullRequestSessionFile.cs +++ b/src/GitHub.InlineReviews/Models/PullRequestSessionFile.cs @@ -18,7 +18,7 @@ namespace GitHub.InlineReviews.Models /// /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable", - Justification = "linesChanged is sharred and shouldn't be disposed")] + Justification = "linesChanged is shared and shouldn't be disposed")] public class PullRequestSessionFile : ReactiveObject, IPullRequestSessionFile { readonly Subject>> linesChanged = new Subject>>(); @@ -32,9 +32,14 @@ public class PullRequestSessionFile : ReactiveObject, IPullRequestSessionFile /// /// The relative path to the file in the repository. /// - public PullRequestSessionFile(string relativePath) + /// + /// The commit to pin the file to, or "HEAD" to follow the pull request head. + /// + public PullRequestSessionFile(string relativePath, string commitSha = "HEAD") { RelativePath = relativePath; + this.commitSha = commitSha; + IsTrackingHead = commitSha == "HEAD"; } /// @@ -54,9 +59,24 @@ public IReadOnlyList Diff public string CommitSha { get { return commitSha; } - internal set { this.RaiseAndSetIfChanged(ref commitSha, value); } + internal set + { + if (value != commitSha) + { + if (!IsTrackingHead) + { + throw new GitHubLogicException( + "Cannot change the CommitSha of a PullRequestSessionFile that is not tracking HEAD."); + } + + this.RaiseAndSetIfChanged(ref commitSha, value); + } + } } + /// + public bool IsTrackingHead { get; } + /// public IReadOnlyList InlineCommentThreads { @@ -69,7 +89,9 @@ public IReadOnlyList InlineCommentThreads .Where(x => x.Item1 >= 0) .Distinct() .ToList(); + this.RaisePropertyChanging(); inlineCommentThreads = value; + this.RaisePropertyChanged(); NotifyLinesChanged(lines); } } diff --git a/src/GitHub.InlineReviews/SampleData/CommentViewModelDesigner.cs b/src/GitHub.InlineReviews/SampleData/CommentViewModelDesigner.cs index 2a158c45ae..d6cf93a107 100644 --- a/src/GitHub.InlineReviews/SampleData/CommentViewModelDesigner.cs +++ b/src/GitHub.InlineReviews/SampleData/CommentViewModelDesigner.cs @@ -18,10 +18,12 @@ public CommentViewModelDesigner() } public int Id { get; set; } + public string NodeId { get; set; } public string Body { get; set; } public string ErrorMessage { get; set; } public CommentEditState EditState { get; set; } public bool IsReadOnly { get; set; } + public bool IsSubmitting { get; set; } public ICommentThreadViewModel Thread { get; } public DateTimeOffset UpdatedAt => DateTime.Now.Subtract(TimeSpan.FromDays(3)); public IAccount User { get; set; } diff --git a/src/GitHub.InlineReviews/SampleData/DiffCommentThreadViewModelDesigner.cs b/src/GitHub.InlineReviews/SampleData/DiffCommentThreadViewModelDesigner.cs deleted file mode 100644 index ebf8dc18df..0000000000 --- a/src/GitHub.InlineReviews/SampleData/DiffCommentThreadViewModelDesigner.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using GitHub.InlineReviews.ViewModels; - -namespace GitHub.InlineReviews.SampleData -{ - [SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses")] - class DiffCommentThreadViewModelDesigner : IDiffCommentThreadViewModel - { - public string DiffHunk { get; set; } - public int LineNumber { get; set; } - public string Path { get; set; } - public ICommentThreadViewModel Comments { get; set; } - } -} diff --git a/src/GitHub.InlineReviews/SampleData/PullRequestCommentsViewModelDesigner.cs b/src/GitHub.InlineReviews/SampleData/PullRequestCommentsViewModelDesigner.cs deleted file mode 100644 index bf1799d22e..0000000000 --- a/src/GitHub.InlineReviews/SampleData/PullRequestCommentsViewModelDesigner.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using GitHub.InlineReviews.ViewModels; -using GitHub.Models; -using ReactiveUI; - -namespace GitHub.InlineReviews.SampleData -{ - [SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses")] - class PullRequestCommentsViewModelDesigner : IPullRequestCommentsViewModel - { - public IRepositoryModel Repository { get; set; } - public int Number { get; set; } - public string Title { get; set; } - public ICommentThreadViewModel Conversation { get; set; } - public IReactiveList FileComments { get; } - = new ReactiveList(); - } -} diff --git a/src/GitHub.InlineReviews/Services/IPullRequestSessionService.cs b/src/GitHub.InlineReviews/Services/IPullRequestSessionService.cs index 5f82830fd9..8a12f9f7ac 100644 --- a/src/GitHub.InlineReviews/Services/IPullRequestSessionService.cs +++ b/src/GitHub.InlineReviews/Services/IPullRequestSessionService.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using GitHub.Models; using Microsoft.VisualStudio.Text; +using Octokit; namespace GitHub.InlineReviews.Services { @@ -48,13 +49,15 @@ Task> Diff( /// The pull request session. /// The relative path to the file. /// The diff. + /// The SHA of the HEAD. /// /// A collection of objects with updated line numbers. /// IReadOnlyList BuildCommentThreads( IPullRequestModel pullRequest, string relativePath, - IReadOnlyList diff); + IReadOnlyList diff, + string headSha); /// /// Updates a set of comment thread models for a file based on a new diff. @@ -140,6 +143,18 @@ Task ExtractFileFromGit( /// Task GetPullRequestMergeBase(ILocalRepositoryModel repository, IPullRequestModel pullRequest); + /// + /// Gets the GraphQL ID for a pull request. + /// + /// The local repository. + /// The owner of the remote fork. + /// The pull request number. + /// + Task GetGraphQLPullRequestId( + ILocalRepositoryModel localRepository, + string repositoryOwner, + int number); + /// /// Creates a rebuild signal subject for a . /// @@ -155,7 +170,92 @@ Task ExtractFileFromGit( ISubject CreateRebuildSignal(); /// - /// Posts a new PR review comment. + /// Creates a new pending review on the server. + /// + /// The local repository. + /// The user posting the review. + /// The GraphQL ID of the pull request. + /// + Task CreatePendingReview( + ILocalRepositoryModel localRepository, + IAccount user, + string pullRequestId); + + /// + /// Cancels a pending review on the server. + /// + /// The GraphQL ID of the review. + Task CancelPendingReview( + ILocalRepositoryModel localRepository, + string reviewId); + + /// + /// Posts PR review with no comments. + /// + /// The local repository. + /// The owner of the repository fork to post to. + /// The user posting the review. + /// The pull request number. + /// The SHA of the commit being reviewed. + /// The review body. + /// The review event. + Task PostReview( + ILocalRepositoryModel localRepository, + string remoteRepositoryOwner, + IAccount user, + int number, + string commitId, + string body, + PullRequestReviewEvent e); + + /// + /// Submits a pending PR review. + /// + /// The local repository. + /// The user posting the review. + /// The GraphQL ID of the pending review. + /// The review body. + /// The review event. + Task SubmitPendingReview( + ILocalRepositoryModel localRepository, + IAccount user, + string pendingReviewId, + string body, + PullRequestReviewEvent e); + + /// + /// Posts a new pending PR review comment. + /// + /// The local repository. + /// The user posting the comment. + /// The GraphQL ID of the pending review. + /// The comment body. + /// THe SHA of the commit to comment on. + /// The relative path of the file to comment on. + /// The line index in the diff to comment on. + /// A model representing the posted comment. + /// + /// The method posts a new pull request comment to a pending review started by + /// . + /// + Task PostPendingReviewComment( + ILocalRepositoryModel localRepository, + IAccount user, + string pendingReviewId, + string body, + string commitId, + string path, + int position); + + Task PostPendingReviewCommentReply( + ILocalRepositoryModel localRepository, + IAccount user, + string pendingReviewId, + string body, + string inReplyTo); + + /// + /// Posts a new standalone PR review comment. /// /// The local repository. /// The owner of the repository fork to post to. @@ -166,7 +266,11 @@ Task ExtractFileFromGit( /// The relative path of the file to comment on. /// The line index in the diff to comment on. /// A model representing the posted comment. - Task PostReviewComment( + /// + /// The method posts a new standalone pull request comment that is not attached to a pending + /// pull request review. + /// + Task PostStandaloneReviewComment( ILocalRepositoryModel localRepository, string remoteRepositoryOwner, IAccount user, @@ -186,7 +290,7 @@ Task PostReviewComment( /// The comment body. /// The comment ID to reply to. /// A model representing the posted comment. - Task PostReviewComment( + Task PostStandaloneReviewCommentRepy( ILocalRepositoryModel localRepository, string remoteRepositoryOwner, IAccount user, diff --git a/src/GitHub.InlineReviews/Services/PullRequestSession.cs b/src/GitHub.InlineReviews/Services/PullRequestSession.cs index 29680ffe15..335f4b21d9 100644 --- a/src/GitHub.InlineReviews/Services/PullRequestSession.cs +++ b/src/GitHub.InlineReviews/Services/PullRequestSession.cs @@ -10,6 +10,7 @@ using ReactiveUI; using System.Threading; using System.Reactive.Subjects; +using static System.FormattableString; namespace GitHub.InlineReviews.Services { @@ -32,7 +33,10 @@ public class PullRequestSession : ReactiveObject, IPullRequestSession string mergeBase; IReadOnlyList files; IPullRequestModel pullRequest; + string pullRequestNodeId; Subject pullRequestChanged = new Subject(); + bool hasPendingReview; + string pendingReviewNodeId { get; set; } public PullRequestSession( IPullRequestSessionService service, @@ -53,6 +57,7 @@ public PullRequestSession( User = user; LocalRepository = localRepository; RepositoryOwner = repositoryOwner; + UpdatePendingReview(); } /// @@ -67,21 +72,23 @@ public async Task> GetAllFiles() } /// - public async Task GetFile(string relativePath) + public async Task GetFile( + string relativePath, + string commitSha = "HEAD") { await getFilesLock.WaitAsync(); try { PullRequestSessionFile file; + var normalizedPath = relativePath.Replace("\\", "/"); + var key = normalizedPath + '@' + commitSha; - relativePath = relativePath.Replace("\\", "/"); - - if (!fileIndex.TryGetValue(relativePath, out file)) + if (!fileIndex.TryGetValue(key, out file)) { - file = new PullRequestSessionFile(relativePath); + file = new PullRequestSessionFile(normalizedPath, commitSha); await UpdateFile(file); - fileIndex.Add(relativePath, file); + fileIndex.Add(key, file); } return file; @@ -120,35 +127,143 @@ public string GetRelativePath(string path) } /// - public async Task PostReviewComment(string body, string commitId, string path, int position) + public async Task PostReviewComment( + string body, + string commitId, + string path, + IReadOnlyList diff, + int position) { - var model = await service.PostReviewComment( - LocalRepository, - RepositoryOwner, - User, - PullRequest.Number, - body, - commitId, - path, - position); + IPullRequestReviewCommentModel model; + + if (!HasPendingReview) + { + model = await service.PostStandaloneReviewComment( + LocalRepository, + RepositoryOwner, + User, + PullRequest.Number, + body, + commitId, + path, + position); + } + else + { + model = await service.PostPendingReviewComment( + LocalRepository, + User, + pendingReviewNodeId, + body, + commitId, + path, + position); + } + + await AddComment(model); + return model; + } + + /// + public async Task PostReviewComment( + string body, + int inReplyTo, + string inReplyToNodeId) + { + IPullRequestReviewCommentModel model; + + if (!HasPendingReview) + { + model = await service.PostStandaloneReviewCommentRepy( + LocalRepository, + RepositoryOwner, + User, + PullRequest.Number, + body, + inReplyTo); + } + else + { + model = await service.PostPendingReviewCommentReply( + LocalRepository, + User, + pendingReviewNodeId, + body, + inReplyToNodeId); + } + await AddComment(model); return model; } /// - public async Task PostReviewComment(string body, int inReplyTo) + public async Task StartReview() { - var model = await service.PostReviewComment( + if (HasPendingReview) + { + throw new InvalidOperationException("A pending review is already underway."); + } + + var model = await service.CreatePendingReview( LocalRepository, - RepositoryOwner, User, - PullRequest.Number, - body, - inReplyTo); - await AddComment(model); + await GetPullRequestNodeId()); + + await AddReview(model); return model; } + /// + public async Task CancelReview() + { + if (!HasPendingReview) + { + throw new InvalidOperationException("There is no pending review to cancel."); + } + + await service.CancelPendingReview(LocalRepository, pendingReviewNodeId); + + PullRequest.Reviews = PullRequest.Reviews + .Where(x => x.NodeId != pendingReviewNodeId) + .ToList(); + PullRequest.ReviewComments = PullRequest.ReviewComments + .Where(x => x.PullRequestReviewId != PendingReviewId) + .ToList(); + + await Update(PullRequest); + } + + /// + public async Task PostReview(string body, Octokit.PullRequestReviewEvent e) + { + IPullRequestReviewModel model; + + if (pendingReviewNodeId == null) + { + model = await service.PostReview( + LocalRepository, + RepositoryOwner, + User, + PullRequest.Number, + PullRequest.Head.Sha, + body, + e); + } + else + { + model = await service.SubmitPendingReview( + LocalRepository, + User, + pendingReviewNodeId, + body, + e); + } + + await AddReview(model); + return model; + } + + /// public async Task Update(IPullRequestModel pullRequestModel) { PullRequest = pullRequestModel; @@ -159,6 +274,7 @@ public async Task Update(IPullRequestModel pullRequestModel) await UpdateFile(file); } + UpdatePendingReview(); pullRequestChanged.OnNext(pullRequestModel); } @@ -170,13 +286,53 @@ async Task AddComment(IPullRequestReviewCommentModel comment) await Update(PullRequest); } + async Task AddReview(IPullRequestReviewModel review) + { + PullRequest.Reviews = PullRequest.Reviews + .Where(x => x.NodeId != review.NodeId) + .Concat(new[] { review }) + .ToList(); + + if (review.State != PullRequestReviewState.Pending) + { + foreach (var comment in PullRequest.ReviewComments) + { + if (comment.PullRequestReviewId == review.Id) + { + comment.IsPending = false; + } + } + } + + await Update(PullRequest); + } + async Task UpdateFile(PullRequestSessionFile file) { var mergeBaseSha = await GetMergeBase(); file.BaseSha = PullRequest.Base.Sha; - file.CommitSha = PullRequest.Head.Sha; + file.CommitSha = file.IsTrackingHead ? PullRequest.Head.Sha : file.CommitSha; file.Diff = await service.Diff(LocalRepository, mergeBaseSha, file.CommitSha, file.RelativePath); - file.InlineCommentThreads = service.BuildCommentThreads(PullRequest, file.RelativePath, file.Diff); + file.InlineCommentThreads = service.BuildCommentThreads(PullRequest, file.RelativePath, file.Diff, file.CommitSha); + } + + void UpdatePendingReview() + { + var pendingReview = PullRequest.Reviews + .FirstOrDefault(x => x.State == PullRequestReviewState.Pending && x.User.Login == User.Login); + + if (pendingReview != null) + { + HasPendingReview = true; + pendingReviewNodeId = pendingReview.NodeId; + PendingReviewId = pendingReview.Id; + } + else + { + HasPendingReview = false; + pendingReviewNodeId = null; + PendingReviewId = 0; + } } async Task> CreateAllFiles() @@ -192,22 +348,32 @@ async Task> CreateAllFiles() return result; } - async Task CalculateContentCommitSha(IPullRequestSessionFile file, byte[] content) + string GetFullPath(string relativePath) { - if (IsCheckedOut) - { - return await service.IsUnmodifiedAndPushed(LocalRepository, file.RelativePath, content) ? - await service.GetTipSha(LocalRepository) : null; - } - else + return Path.Combine(LocalRepository.LocalPath, relativePath); + } + + async Task GetPullRequestNodeId() + { + if (pullRequestNodeId == null) { - return PullRequest.Head.Sha; + pullRequestNodeId = await service.GetGraphQLPullRequestId( + LocalRepository, + RepositoryOwner, + PullRequest.Number); } + + return pullRequestNodeId; } - string GetFullPath(string relativePath) + static string BuildDiffHunk(IReadOnlyList diff, int position) { - return Path.Combine(LocalRepository.LocalPath, relativePath); + var lines = diff.SelectMany(x => x.Lines).Reverse(); + var context = lines.SkipWhile(x => x.DiffLineNumber != position).Take(5).Reverse().ToList(); + var oldLineNumber = context.Select(x => x.OldLineNumber).Where(x => x != -1).FirstOrDefault(); + var newLineNumber = context.Select(x => x.NewLineNumber).Where(x => x != -1).FirstOrDefault(); + var header = Invariant($"@@ -{oldLineNumber},5 +{newLineNumber},5 @@"); + return header + '\n' + string.Join("\n", context); } /// @@ -247,6 +413,16 @@ private set /// public string RepositoryOwner { get; } + /// + public bool HasPendingReview + { + get { return hasPendingReview; } + private set { this.RaiseAndSetIfChanged(ref hasPendingReview, value); } + } + + /// + public long PendingReviewId { get; private set; } + IEnumerable FilePaths { get { return PullRequest.ChangedFiles.Select(x => x.FileName); } diff --git a/src/GitHub.InlineReviews/Services/PullRequestSessionManager.cs b/src/GitHub.InlineReviews/Services/PullRequestSessionManager.cs index 13d2577956..50e5b0dbba 100644 --- a/src/GitHub.InlineReviews/Services/PullRequestSessionManager.cs +++ b/src/GitHub.InlineReviews/Services/PullRequestSessionManager.cs @@ -301,7 +301,8 @@ async Task UpdateLiveFile(PullRequestSessionLiveFile file, bool rebuildThreads) file.InlineCommentThreads = sessionService.BuildCommentThreads( session.PullRequest, file.RelativePath, - file.Diff); + file.Diff, + session.PullRequest.Head.Sha); } else { diff --git a/src/GitHub.InlineReviews/Services/PullRequestSessionService.cs b/src/GitHub.InlineReviews/Services/PullRequestSessionService.cs index 8165159d01..ad54647b22 100644 --- a/src/GitHub.InlineReviews/Services/PullRequestSessionService.cs +++ b/src/GitHub.InlineReviews/Services/PullRequestSessionService.cs @@ -7,6 +7,7 @@ using System.Reactive.Subjects; using System.Text; using System.Threading.Tasks; +using GitHub.Api; using GitHub.Factories; using GitHub.InlineReviews.Models; using GitHub.Models; @@ -16,8 +17,14 @@ using LibGit2Sharp; using Microsoft.VisualStudio.Text; using Microsoft.VisualStudio.Text.Projection; +using Octokit.GraphQL; +using Octokit.GraphQL.Model; using ReactiveUI; using Serilog; +using PullRequestReviewEvent = Octokit.PullRequestReviewEvent; + +// GraphQL DatabaseId field are marked as deprecated, but we need them for interop with REST. +#pragma warning disable CS0618 namespace GitHub.InlineReviews.Services { @@ -33,6 +40,7 @@ public class PullRequestSessionService : IPullRequestSessionService readonly IGitClient gitClient; readonly IDiffService diffService; readonly IApiClientFactory apiClientFactory; + readonly IGraphQLClientFactory graphqlFactory; readonly IUsageTracker usageTracker; readonly IDictionary, string> mergeBaseCache; @@ -43,12 +51,14 @@ public PullRequestSessionService( IGitClient gitClient, IDiffService diffService, IApiClientFactory apiClientFactory, + IGraphQLClientFactory graphqlFactory, IUsageTracker usageTracker) { this.gitService = gitService; this.gitClient = gitClient; this.diffService = diffService; this.apiClientFactory = apiClientFactory; + this.graphqlFactory = graphqlFactory; this.usageTracker = usageTracker; mergeBaseCache = new Dictionary, string>(); @@ -76,7 +86,8 @@ public virtual async Task> Diff(ILocalRepositoryModel r public IReadOnlyList BuildCommentThreads( IPullRequestModel pullRequest, string relativePath, - IReadOnlyList diff) + IReadOnlyList diff, + string headSha) { relativePath = relativePath.Replace("\\", "/"); @@ -101,8 +112,7 @@ public IReadOnlyList BuildCommentThreads( var thread = new InlineCommentThreadModel( relativePath, - comments.Key.Item1, - comments.Key.Item2, + headSha, diffLines, comments); threads.Add(thread); @@ -249,6 +259,22 @@ public async Task ReadFileAsync(string path) return null; } + public async Task GetGraphQLPullRequestId( + ILocalRepositoryModel localRepository, + string repositoryOwner, + int number) + { + var address = HostAddress.Create(localRepository.CloneUrl.Host); + var graphql = await graphqlFactory.CreateConnection(address); + + var query = new Query() + .Repository(repositoryOwner, localRepository.Name) + .PullRequest(number) + .Select(x => x.Id); + + return await graphql.Run(query); + } + /// public virtual async Task GetPullRequestMergeBase(ILocalRepositoryModel repository, IPullRequestModel pullRequest) { @@ -293,7 +319,213 @@ public virtual ISubject CreateRebuildSignal() } /// - public async Task PostReviewComment( + public async Task CreatePendingReview( + ILocalRepositoryModel localRepository, + IAccount user, + string pullRequestId) + { + var address = HostAddress.Create(localRepository.CloneUrl.Host); + var graphql = await graphqlFactory.CreateConnection(address); + + var review = new AddPullRequestReviewInput + { + PullRequestId = pullRequestId, + }; + + var addReview = new Mutation() + .AddPullRequestReview(review) + .Select(x => new PullRequestReviewModel + { + Id = x.PullRequestReview.DatabaseId.Value, + Body = x.PullRequestReview.Body, + CommitId = x.PullRequestReview.Commit.Oid, + NodeId = x.PullRequestReview.Id, + State = FromGraphQL(x.PullRequestReview.State), + User = user, + }); + + var result = await graphql.Run(addReview); + await usageTracker.IncrementCounter(x => x.NumberOfPRReviewDiffViewInlineCommentStartReview); + return result; + } + + /// + public async Task CancelPendingReview( + ILocalRepositoryModel localRepository, + string reviewId) + { + var address = HostAddress.Create(localRepository.CloneUrl.Host); + var graphql = await graphqlFactory.CreateConnection(address); + + var delete = new DeletePullRequestReviewInput + { + PullRequestReviewId = reviewId, + }; + + var deleteReview = new Mutation() + .DeletePullRequestReview(delete) + .Select(x => x.ClientMutationId); + + await graphql.Run(deleteReview); + } + + /// + public async Task PostReview( + ILocalRepositoryModel localRepository, + string remoteRepositoryOwner, + IAccount user, + int number, + string commitId, + string body, + PullRequestReviewEvent e) + { + var address = HostAddress.Create(localRepository.CloneUrl.Host); + var apiClient = await apiClientFactory.Create(address); + + var result = await apiClient.PostPullRequestReview( + remoteRepositoryOwner, + localRepository.Name, + number, + commitId, + body, + e); + + await usageTracker.IncrementCounter(x => x.NumberOfPRReviewPosts); + + return new PullRequestReviewModel + { + Id = result.Id, + Body = result.Body, + CommitId = result.CommitId, + State = (GitHub.Models.PullRequestReviewState)result.State.Value, + User = user, + }; + } + + public async Task SubmitPendingReview( + ILocalRepositoryModel localRepository, + IAccount user, + string pendingReviewId, + string body, + PullRequestReviewEvent e) + { + var address = HostAddress.Create(localRepository.CloneUrl.Host); + var graphql = await graphqlFactory.CreateConnection(address); + + var submit = new SubmitPullRequestReviewInput + { + Body = body, + Event = ToGraphQl(e), + PullRequestReviewId = pendingReviewId, + }; + + var mutation = new Mutation() + .SubmitPullRequestReview(submit) + .Select(x => new PullRequestReviewModel + { + Body = body, + CommitId = x.PullRequestReview.Commit.Oid, + Id = x.PullRequestReview.DatabaseId.Value, + NodeId = x.PullRequestReview.Id, + State = (GitHub.Models.PullRequestReviewState)x.PullRequestReview.State, + User = user, + }); + + var result = await graphql.Run(mutation); + await usageTracker.IncrementCounter(x => x.NumberOfPRReviewPosts); + return result; + } + + /// + public async Task PostPendingReviewComment( + ILocalRepositoryModel localRepository, + IAccount user, + string pendingReviewId, + string body, + string commitId, + string path, + int position) + { + var address = HostAddress.Create(localRepository.CloneUrl.Host); + var graphql = await graphqlFactory.CreateConnection(address); + + var comment = new AddPullRequestReviewCommentInput + { + Body = body, + CommitOID = commitId, + Path = path, + Position = position, + PullRequestReviewId = pendingReviewId, + }; + + var addComment = new Mutation() + .AddPullRequestReviewComment(comment) + .Select(x => new PullRequestReviewCommentModel + { + Id = x.Comment.DatabaseId.Value, + NodeId = x.Comment.Id, + Body = x.Comment.Body, + CommitId = x.Comment.Commit.Oid, + Path = x.Comment.Path, + Position = x.Comment.Position, + CreatedAt = x.Comment.CreatedAt.Value, + DiffHunk = x.Comment.DiffHunk, + OriginalPosition = x.Comment.OriginalPosition, + OriginalCommitId = x.Comment.OriginalCommit.Oid, + PullRequestReviewId = x.Comment.PullRequestReview.DatabaseId.Value, + User = user, + IsPending = true, + }); + + var result = await graphql.Run(addComment); + await usageTracker.IncrementCounter(x => x.NumberOfPRReviewDiffViewInlineCommentPost); + return result; + } + + /// + public async Task PostPendingReviewCommentReply( + ILocalRepositoryModel localRepository, + IAccount user, + string pendingReviewId, + string body, + string inReplyTo) + { + var address = HostAddress.Create(localRepository.CloneUrl.Host); + var graphql = await graphqlFactory.CreateConnection(address); + + var comment = new AddPullRequestReviewCommentInput + { + Body = body, + InReplyTo = inReplyTo, + PullRequestReviewId = pendingReviewId, + }; + + var addComment = new Mutation() + .AddPullRequestReviewComment(comment) + .Select(x => new PullRequestReviewCommentModel + { + Id = x.Comment.DatabaseId.Value, + NodeId = x.Comment.Id, + Body = x.Comment.Body, + CommitId = x.Comment.Commit.Oid, + Path = x.Comment.Path, + Position = x.Comment.Position, + CreatedAt = x.Comment.CreatedAt.Value, + DiffHunk = x.Comment.DiffHunk, + OriginalPosition = x.Comment.OriginalPosition, + OriginalCommitId = x.Comment.OriginalCommit.Oid, + PullRequestReviewId = x.Comment.PullRequestReview.DatabaseId.Value, + User = user, + IsPending = true, + }); + + var result = await graphql.Run(addComment); + await usageTracker.IncrementCounter(x => x.NumberOfPRReviewDiffViewInlineCommentPost); + return result; + } + + /// + public async Task PostStandaloneReviewComment( ILocalRepositoryModel localRepository, string remoteRepositoryOwner, IAccount user, @@ -333,7 +565,7 @@ public async Task PostReviewComment( } /// - public async Task PostReviewComment( + public async Task PostStandaloneReviewCommentRepy( ILocalRepositoryModel localRepository, string remoteRepositoryOwner, IAccount user, @@ -386,5 +618,26 @@ Task GetRepository(ILocalRepositoryModel repository) { return Task.Factory.StartNew(() => gitService.GetRepository(repository.LocalPath)); } + + + static GitHub.Models.PullRequestReviewState FromGraphQL(Octokit.GraphQL.Model.PullRequestReviewState s) + { + return (GitHub.Models.PullRequestReviewState)s; + } + + static Octokit.GraphQL.Model.PullRequestReviewEvent ToGraphQl(Octokit.PullRequestReviewEvent e) + { + switch (e) + { + case Octokit.PullRequestReviewEvent.Approve: + return Octokit.GraphQL.Model.PullRequestReviewEvent.Approve; + case Octokit.PullRequestReviewEvent.Comment: + return Octokit.GraphQL.Model.PullRequestReviewEvent.Comment; + case Octokit.PullRequestReviewEvent.RequestChanges: + return Octokit.GraphQL.Model.PullRequestReviewEvent.RequestChanges; + default: + throw new NotSupportedException(); + } + } } } diff --git a/src/GitHub.InlineReviews/Tags/InlineCommentTagger.cs b/src/GitHub.InlineReviews/Tags/InlineCommentTagger.cs index 41b93e82c1..4ea56eb4de 100644 --- a/src/GitHub.InlineReviews/Tags/InlineCommentTagger.cs +++ b/src/GitHub.InlineReviews/Tags/InlineCommentTagger.cs @@ -136,9 +136,10 @@ async Task Initialize() if (bufferInfo != null) { + var commitSha = bufferInfo.Side == DiffSide.Left ? "HEAD" : bufferInfo.CommitSha; session = bufferInfo.Session; relativePath = bufferInfo.RelativePath; - file = await session.GetFile(relativePath); + file = await session.GetFile(relativePath, commitSha); fileSubscription = file.LinesChanged.Subscribe(LinesChanged); side = bufferInfo.Side ?? DiffSide.Right; NotifyTagsChanged(); diff --git a/src/GitHub.InlineReviews/ViewModels/CommentViewModel.cs b/src/GitHub.InlineReviews/ViewModels/CommentViewModel.cs index af5982e418..be3c623afb 100644 --- a/src/GitHub.InlineReviews/ViewModels/CommentViewModel.cs +++ b/src/GitHub.InlineReviews/ViewModels/CommentViewModel.cs @@ -1,5 +1,4 @@ using System; -using System.ComponentModel; using System.Linq; using System.Reactive; using System.Reactive.Linq; @@ -7,8 +6,6 @@ using GitHub.Extensions; using GitHub.Logging; using GitHub.Models; -using GitHub.UI; -using Octokit; using ReactiveUI; using Serilog; @@ -23,6 +20,7 @@ public class CommentViewModel : ReactiveObject, ICommentViewModel string body; string errorMessage; bool isReadOnly; + bool isSubmitting; CommentEditState state; DateTimeOffset updatedAt; string undoBody; @@ -33,14 +31,16 @@ public class CommentViewModel : ReactiveObject, ICommentViewModel /// The thread that the comment is a part of. /// The current user. /// The ID of the comment. + /// The GraphQL ID of the comment. /// The comment body. /// The comment edit state. /// The author of the comment. /// The modified date of the comment. - public CommentViewModel( + protected CommentViewModel( ICommentThreadViewModel thread, IAccount currentUser, int commentId, + string commentNodeId, string body, CommentEditState state, IAccount user, @@ -54,6 +54,7 @@ public CommentViewModel( Thread = thread; CurrentUser = currentUser; Id = commentId; + NodeId = commentNodeId; Body = body; EditState = state; User = user; @@ -89,35 +90,15 @@ public CommentViewModel( /// The thread that the comment is a part of. /// The current user. /// The comment model. - public CommentViewModel( + protected CommentViewModel( ICommentThreadViewModel thread, IAccount currentUser, ICommentModel model) - : this(thread, currentUser, model.Id, model.Body, CommentEditState.None, model.User, model.CreatedAt) + : this(thread, currentUser, model.Id, model.NodeId, model.Body, CommentEditState.None, model.User, model.CreatedAt) { } - /// - /// Creates a placeholder comment which can be used to add a new comment to a thread. - /// - /// The comment thread. - /// The current user. - /// THe placeholder comment. - public static CommentViewModel CreatePlaceholder( - ICommentThreadViewModel thread, - IAccount currentUser) - { - return new CommentViewModel( - thread, - currentUser, - 0, - string.Empty, - CommentEditState.Placeholder, - currentUser, - DateTimeOffset.MinValue); - } - - void AddErrorHandler(ReactiveCommand command) + protected void AddErrorHandler(ReactiveCommand command) { command.ThrownExceptions.Subscribe(x => ErrorMessage = x.Message); } @@ -147,31 +128,32 @@ async Task DoCommitEdit(object unused) try { ErrorMessage = null; - Id = (await Thread.PostComment.ExecuteAsyncTask(Body)).Id; + IsSubmitting = true; + + var model = await Thread.PostComment.ExecuteAsyncTask(Body); + Id = model.Id; + NodeId = model.NodeId; EditState = CommentEditState.None; UpdatedAt = DateTimeOffset.Now; } catch (Exception e) { var message = e.Message; - - if (e is ApiValidationException) - { - // HACK: If the user has pending review comments on the server then we can't - // post new comments. The correct way to test for this would be to make a - // request to /repos/:owner/:repo/pulls/:number/reviews and check for comments - // with a PENDING state. For the moment however we'll just display a message. - message += ". Do you have pending review comments?"; - } - ErrorMessage = message; - log.Error(e, "Error posting inline comment"); + log.Error(e, "Error posting comment"); + } + finally + { + IsSubmitting = false; } } /// public int Id { get; private set; } + /// + public string NodeId { get; private set; } + /// public string Body { @@ -200,6 +182,13 @@ public bool IsReadOnly set { this.RaiseAndSetIfChanged(ref isReadOnly, value); } } + /// + public bool IsSubmitting + { + get { return isSubmitting; } + protected set { this.RaiseAndSetIfChanged(ref isSubmitting, value); } + } + /// public DateTimeOffset UpdatedAt { @@ -207,14 +196,10 @@ public DateTimeOffset UpdatedAt private set { this.RaiseAndSetIfChanged(ref updatedAt, value); } } - /// - /// Gets the current user. - /// + /// public IAccount CurrentUser { get; } - /// - /// Gets the thread that the comment is a part of. - /// + /// public ICommentThreadViewModel Thread { get; } /// diff --git a/src/GitHub.InlineReviews/ViewModels/DiffCommentThreadViewModel.cs b/src/GitHub.InlineReviews/ViewModels/DiffCommentThreadViewModel.cs deleted file mode 100644 index e30b0d0a8a..0000000000 --- a/src/GitHub.InlineReviews/ViewModels/DiffCommentThreadViewModel.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using ReactiveUI; - -namespace GitHub.InlineReviews.ViewModels -{ - class DiffCommentThreadViewModel : ReactiveObject, IDiffCommentThreadViewModel - { - public DiffCommentThreadViewModel( - string diffHunk, - int lineNumber, - string path, - InlineCommentThreadViewModel comments) - { - DiffHunk = diffHunk; - LineNumber = lineNumber; - Path = path; - Comments = comments; - } - - public string DiffHunk { get; } - public int LineNumber { get; } - public string Path { get; } - public ICommentThreadViewModel Comments { get; } - } -} diff --git a/src/GitHub.InlineReviews/ViewModels/ICommentViewModel.cs b/src/GitHub.InlineReviews/ViewModels/ICommentViewModel.cs index fdfab31eff..05080e2052 100644 --- a/src/GitHub.InlineReviews/ViewModels/ICommentViewModel.cs +++ b/src/GitHub.InlineReviews/ViewModels/ICommentViewModel.cs @@ -13,6 +13,9 @@ public enum CommentEditState Placeholder, } + /// + /// View model for an issue or pull request comment. + /// public interface ICommentViewModel : IViewModel { /// @@ -20,6 +23,11 @@ public interface ICommentViewModel : IViewModel /// int Id { get; } + /// + /// Gets the GraphQL ID of the comment. + /// + string NodeId { get; } + /// /// Gets or sets the body of the comment. /// @@ -40,6 +48,12 @@ public interface ICommentViewModel : IViewModel /// bool IsReadOnly { get; set; } + /// + /// Gets a value indicating whether the comment is currently in the process of being + /// submitted. + /// + bool IsSubmitting { get; } + /// /// Gets the modified date of the comment. /// diff --git a/src/GitHub.InlineReviews/ViewModels/IDiffCommentThreadViewModel.cs b/src/GitHub.InlineReviews/ViewModels/IDiffCommentThreadViewModel.cs deleted file mode 100644 index 808f10d5e0..0000000000 --- a/src/GitHub.InlineReviews/ViewModels/IDiffCommentThreadViewModel.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace GitHub.InlineReviews.ViewModels -{ - interface IDiffCommentThreadViewModel - { - string DiffHunk { get; } - int LineNumber { get; } - string Path { get; } - ICommentThreadViewModel Comments { get; } - } -} \ No newline at end of file diff --git a/src/GitHub.InlineReviews/ViewModels/IInlineCommentViewModel.cs b/src/GitHub.InlineReviews/ViewModels/IInlineCommentViewModel.cs deleted file mode 100644 index 036cb5eefd..0000000000 --- a/src/GitHub.InlineReviews/ViewModels/IInlineCommentViewModel.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; - -namespace GitHub.InlineReviews.ViewModels -{ - /// - /// View model for an inline comment (aka Pull Request Review Comment). - /// - interface IInlineCommentViewModel : ICommentViewModel - { - /// - /// Gets the SHA of the commit that the comment was left on. - /// - string CommitSha { get; } - - /// - /// Gets the line on the diff between PR.Base and that - /// the comment was left on. - /// - int DiffLine { get; } - } -} diff --git a/src/GitHub.InlineReviews/ViewModels/IPullRequestCommentsViewModel.cs b/src/GitHub.InlineReviews/ViewModels/IPullRequestCommentsViewModel.cs deleted file mode 100644 index 441b4fc93d..0000000000 --- a/src/GitHub.InlineReviews/ViewModels/IPullRequestCommentsViewModel.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Collections.ObjectModel; -using GitHub.Models; -using ReactiveUI; - -namespace GitHub.InlineReviews.ViewModels -{ - interface IPullRequestCommentsViewModel - { - IRepositoryModel Repository { get; } - int Number { get; } - string Title { get; } - ICommentThreadViewModel Conversation { get; } - IReactiveList FileComments { get; } - } -} \ No newline at end of file diff --git a/src/GitHub.InlineReviews/ViewModels/IPullRequestReviewCommentViewModel.cs b/src/GitHub.InlineReviews/ViewModels/IPullRequestReviewCommentViewModel.cs new file mode 100644 index 0000000000..63bb86d96d --- /dev/null +++ b/src/GitHub.InlineReviews/ViewModels/IPullRequestReviewCommentViewModel.cs @@ -0,0 +1,36 @@ +using System; +using System.Reactive; +using ReactiveUI; + +namespace GitHub.InlineReviews.ViewModels +{ + /// + /// View model for a pull request review comment. + /// + public interface IPullRequestReviewCommentViewModel : ICommentViewModel + { + /// + /// Gets a value indicating whether the user can start a new review with this comment. + /// + bool CanStartReview { get; } + + /// + /// Gets the caption for the "Commit" button. + /// + /// + /// This will be "Add a single comment" when not in review mode and "Add review comment" + /// when in review mode. + /// + string CommitCaption { get; } + + /// + /// Gets a value indicating whether this comment is part of a pending pull request review. + /// + bool IsPending { get; } + + /// + /// Gets a command which will commit a new comment and start a review. + /// + ReactiveCommand StartReview { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.InlineReviews/ViewModels/InlineCommentPeekViewModel.cs b/src/GitHub.InlineReviews/ViewModels/InlineCommentPeekViewModel.cs index 25a560f668..3c018872f0 100644 --- a/src/GitHub.InlineReviews/ViewModels/InlineCommentPeekViewModel.cs +++ b/src/GitHub.InlineReviews/ViewModels/InlineCommentPeekViewModel.cs @@ -114,9 +114,10 @@ public async Task Initialize() if (info != null) { + var commitSha = info.Side == DiffSide.Left ? "HEAD" : info.CommitSha; relativePath = info.RelativePath; side = info.Side ?? DiffSide.Right; - file = await info.Session.GetFile(relativePath); + file = await info.Session.GetFile(relativePath, commitSha); session = info.Session; await UpdateThread(); } @@ -153,7 +154,7 @@ async void LinesChanged(IReadOnlyList> lines) async Task UpdateThread() { - var placeholderBody = await GetPlaceholderBodyToPreserve(); + var placeholderBody = GetPlaceholderBodyToPreserve(); Thread = null; threadSubscription?.Dispose(); @@ -207,14 +208,13 @@ async Task SessionChanged(IPullRequestSession pullRequestSession) } } - async Task GetPlaceholderBodyToPreserve() + string GetPlaceholderBodyToPreserve() { var lastComment = Thread?.Comments.LastOrDefault(); if (lastComment?.EditState == CommentEditState.Editing) { - var executing = await lastComment.CommitEdit.IsExecuting.FirstAsync(); - if (!executing) return lastComment.Body; + if (!lastComment.IsSubmitting) return lastComment.Body; } return null; diff --git a/src/GitHub.InlineReviews/ViewModels/InlineCommentThreadViewModel.cs b/src/GitHub.InlineReviews/ViewModels/InlineCommentThreadViewModel.cs index 113b7de294..0634156491 100644 --- a/src/GitHub.InlineReviews/ViewModels/InlineCommentThreadViewModel.cs +++ b/src/GitHub.InlineReviews/ViewModels/InlineCommentThreadViewModel.cs @@ -37,10 +37,10 @@ public InlineCommentThreadViewModel( foreach (var comment in comments) { - Comments.Add(new CommentViewModel(this, CurrentUser, comment)); + Comments.Add(new PullRequestReviewCommentViewModel(session, this, CurrentUser, comment)); } - Comments.Add(CommentViewModel.CreatePlaceholder(this, CurrentUser)); + Comments.Add(PullRequestReviewCommentViewModel.CreatePlaceholder(session, this, CurrentUser)); } /// @@ -65,7 +65,8 @@ async Task DoPostComment(object parameter) var body = (string)parameter; var replyId = Comments[0].Id; - return await Session.PostReviewComment(body, replyId); + var nodeId = Comments[0].NodeId; + return await Session.PostReviewComment(body, replyId, nodeId); } } } diff --git a/src/GitHub.InlineReviews/ViewModels/InlineCommentViewModel.cs b/src/GitHub.InlineReviews/ViewModels/InlineCommentViewModel.cs deleted file mode 100644 index c7e1e65c75..0000000000 --- a/src/GitHub.InlineReviews/ViewModels/InlineCommentViewModel.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using GitHub.Extensions; -using GitHub.Models; - -namespace GitHub.InlineReviews.ViewModels -{ - /// - /// View model for an inline comment (aka Pull Request Review Comment). - /// - public class InlineCommentViewModel : CommentViewModel, IInlineCommentViewModel - { - /// - /// Initializes a new instance of the class. - /// - /// The thread that the comment is a part of. - /// The current user. - /// The ID of the comment. - /// The comment body. - /// The comment edit state. - /// The author of the comment. - /// The modified date of the comment. - public InlineCommentViewModel( - ICommentThreadViewModel thread, - IAccount currentUser, - int commentId, - string body, - CommentEditState state, - IAccount user, - DateTimeOffset updatedAt, - string commitSha, - int diffLine) - : base(thread, currentUser, commentId, body, state, user, updatedAt) - { - Guard.ArgumentNotNull(commitSha, nameof(commitSha)); - - CommitSha = commitSha; - DiffLine = diffLine; - } - - /// - /// Initializes a new instance of the class. - /// - /// The thread that the comment is a part of. - /// The current user. - /// The comment model. - public InlineCommentViewModel( - ICommentThreadViewModel thread, - IAccount currentUser, - IPullRequestReviewCommentModel model) - : base(thread, currentUser, model) - { - CommitSha = model.OriginalCommitId; - DiffLine = model.OriginalPosition.Value; - } - - /// - public string CommitSha { get; } - - /// - public int DiffLine { get; } - } -} diff --git a/src/GitHub.InlineReviews/ViewModels/IssueCommentThreadViewModel.cs b/src/GitHub.InlineReviews/ViewModels/IssueCommentThreadViewModel.cs deleted file mode 100644 index 31f8123ae7..0000000000 --- a/src/GitHub.InlineReviews/ViewModels/IssueCommentThreadViewModel.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Threading.Tasks; -using GitHub.Models; -using ReactiveUI; - -namespace GitHub.InlineReviews.ViewModels -{ - class IssueCommentThreadViewModel : CommentThreadViewModel - { - public IssueCommentThreadViewModel( - IRepositoryModel repository, - int number, - IAccount currentUser) - : base(currentUser) - { - Repository = repository; - Number = number; - } - - /// - public override Uri GetCommentUrl(int id) - { - throw new NotImplementedException(); - } - - public IRepositoryModel Repository { get; } - public int Number { get; } - } -} diff --git a/src/GitHub.InlineReviews/ViewModels/NewInlineCommentThreadViewModel.cs b/src/GitHub.InlineReviews/ViewModels/NewInlineCommentThreadViewModel.cs index 9b625b004c..599b58eb34 100644 --- a/src/GitHub.InlineReviews/ViewModels/NewInlineCommentThreadViewModel.cs +++ b/src/GitHub.InlineReviews/ViewModels/NewInlineCommentThreadViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Reactive; using System.Reactive.Linq; using System.Threading.Tasks; using GitHub.Extensions; @@ -44,7 +45,7 @@ public NewInlineCommentThreadViewModel( this.WhenAnyValue(x => x.NeedsPush, x => !x), DoPostComment); - var placeholder = CommentViewModel.CreatePlaceholder(this, CurrentUser); + var placeholder = PullRequestReviewCommentViewModel.CreatePlaceholder(session, this, CurrentUser); placeholder.BeginEdit.Execute(null); this.WhenAnyValue(x => x.NeedsPush).Subscribe(x => placeholder.IsReadOnly = x); Comments.Add(placeholder); @@ -110,6 +111,7 @@ async Task DoPostComment(object parameter) body, File.CommitSha, File.RelativePath.Replace("\\", "/"), + File.Diff, diffPosition.DiffLineNumber); return model; diff --git a/src/GitHub.InlineReviews/ViewModels/PullRequestCommentsViewModel.cs b/src/GitHub.InlineReviews/ViewModels/PullRequestCommentsViewModel.cs deleted file mode 100644 index 9babec2f20..0000000000 --- a/src/GitHub.InlineReviews/ViewModels/PullRequestCommentsViewModel.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using GitHub.Api; -using GitHub.Models; -using GitHub.Services; -using ReactiveUI; - -namespace GitHub.InlineReviews.ViewModels -{ - class PullRequestCommentsViewModel : ReactiveObject, IPullRequestCommentsViewModel, IDisposable - { - readonly IPullRequestSession session; - - public PullRequestCommentsViewModel( - IPullRequestSession session) - { - this.session = session; - - Repository = session.LocalRepository; - Number = session.PullRequest.Number; - Title = session.PullRequest.Title; - - Conversation = new IssueCommentThreadViewModel(Repository, Number, session.User); - - foreach (var comment in session.PullRequest.Comments) - { - Conversation.Comments.Add(new CommentViewModel( - Conversation, - session.User, - comment)); - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - bool disposed = false; - - protected virtual void Dispose(bool disposing) - { - if (!disposed) - { - disposed = true; - - if (disposing) - { - (Conversation as IDisposable)?.Dispose(); - } - } - } - - public IRepositoryModel Repository { get; } - public int Number { get; } - public string Title { get; } - public ICommentThreadViewModel Conversation { get; } - public IReactiveList FileComments { get; } - = new ReactiveList(); - - public async Task Initialize() - { - var files = await session.GetAllFiles(); - - foreach (var file in files) - { - foreach (var thread in file.InlineCommentThreads) - { - var threadViewModel = new InlineCommentThreadViewModel( - session, - thread.Comments); - - FileComments.Add(new DiffCommentThreadViewModel( - ToString(thread.DiffMatch), - thread.LineNumber, - file.RelativePath, - threadViewModel)); - } - } - } - - private string ToString(IList diffMatch) - { - var b = new StringBuilder(); - - for (var i = diffMatch.Count - 1; i >= 0; --i) - { - b.AppendLine(diffMatch[i].Content); - } - - return b.ToString(); - } - } -} diff --git a/src/GitHub.InlineReviews/ViewModels/PullRequestReviewCommentViewModel.cs b/src/GitHub.InlineReviews/ViewModels/PullRequestReviewCommentViewModel.cs new file mode 100644 index 0000000000..3f0182aed8 --- /dev/null +++ b/src/GitHub.InlineReviews/ViewModels/PullRequestReviewCommentViewModel.cs @@ -0,0 +1,136 @@ +using System; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Services; +using GitHub.VisualStudio.UI; +using ReactiveUI; +using Serilog; + +namespace GitHub.InlineReviews.ViewModels +{ + /// + /// View model for a pull request review comment. + /// + public class PullRequestReviewCommentViewModel : CommentViewModel, IPullRequestReviewCommentViewModel + { + readonly IPullRequestSession session; + ObservableAsPropertyHelper canStartReview; + ObservableAsPropertyHelper commitCaption; + + /// + /// Initializes a new instance of the class. + /// + /// The pull request session. + /// The thread that the comment is a part of. + /// The current user. + /// The REST ID of the comment. + /// The GraphQL ID of the comment. + /// The comment body. + /// The comment edit state. + /// The author of the comment. + /// The modified date of the comment. + /// Whether this is a pending comment. + public PullRequestReviewCommentViewModel( + IPullRequestSession session, + ICommentThreadViewModel thread, + IAccount currentUser, + int commentId, + string commentNodeId, + string body, + CommentEditState state, + IAccount user, + DateTimeOffset updatedAt, + bool isPending) + : base(thread, currentUser, commentId, commentNodeId, body, state, user, updatedAt) + { + Guard.ArgumentNotNull(session, nameof(session)); + + this.session = session; + IsPending = isPending; + + canStartReview = session.WhenAnyValue(x => x.HasPendingReview, x => !x) + .ToProperty(this, x => x.CanStartReview); + commitCaption = session.WhenAnyValue( + x => x.HasPendingReview, + x => x ? Resources.AddReviewComment : Resources.AddSingleComment) + .ToProperty(this, x => x.CommitCaption); + + StartReview = ReactiveCommand.CreateAsyncTask( + CommitEdit.CanExecuteObservable, + DoStartReview); + AddErrorHandler(StartReview); + } + + /// + /// Initializes a new instance of the class. + /// + /// The pull request session. + /// The thread that the comment is a part of. + /// The current user. + /// The comment model. + public PullRequestReviewCommentViewModel( + IPullRequestSession session, + ICommentThreadViewModel thread, + IAccount currentUser, + IPullRequestReviewCommentModel model) + : this(session, thread, currentUser, model.Id, model.NodeId, model.Body, CommentEditState.None, model.User, model.CreatedAt, model.IsPending) + { + } + + /// + /// Creates a placeholder comment which can be used to add a new comment to a thread. + /// + /// The comment thread. + /// The current user. + /// THe placeholder comment. + public static CommentViewModel CreatePlaceholder( + IPullRequestSession session, + ICommentThreadViewModel thread, + IAccount currentUser) + { + return new PullRequestReviewCommentViewModel( + session, + thread, + currentUser, + 0, + null, + string.Empty, + CommentEditState.Placeholder, + currentUser, + DateTimeOffset.MinValue, + false); + } + + /// + public bool CanStartReview => canStartReview.Value; + + /// + public string CommitCaption => commitCaption.Value; + + /// + public bool IsPending { get; } + + /// + public ReactiveCommand StartReview { get; } + + async Task DoStartReview(object unused) + { + IsSubmitting = true; + + try + { + await session.StartReview(); + await CommitEdit.ExecuteAsync(null); + } + finally + { + IsSubmitting = false; + } + } + } +} diff --git a/src/GitHub.InlineReviews/Views/CommentView.xaml b/src/GitHub.InlineReviews/Views/CommentView.xaml index 94fb253a8a..0d7499806f 100644 --- a/src/GitHub.InlineReviews/Views/CommentView.xaml +++ b/src/GitHub.InlineReviews/Views/CommentView.xaml @@ -60,6 +60,14 @@ Command="{Binding OpenOnGitHub}" Foreground="{DynamicResource GitHubVsToolWindowText}" Opacity="0.75" /> + + Pending + @@ -168,7 +176,12 @@ - + diff --git a/src/GitHub.InlineReviews/Views/DiffCommentThreadView.xaml b/src/GitHub.InlineReviews/Views/DiffCommentThreadView.xaml deleted file mode 100644 index c77a0030ed..0000000000 --- a/src/GitHub.InlineReviews/Views/DiffCommentThreadView.xaml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - .Subscribe(a => UpdateFilter(SelectedState, SelectedAssignee, a)); - -+ this.WhenAny(x => x.SelectedSortOrder, x => x.Value) -+ .Where(x => pullRequests != null) - - - - - - - @StanleyGoldman Ooops, I missed a x != null check here, and it's breaking the tests. Do you want to add it or shall I? - - - - - - - - - - - - - - - - - - - - - - - : - - - - - - - - - diff --git a/src/GitHub.InlineReviews/Views/DiffView.cs b/src/GitHub.InlineReviews/Views/DiffView.cs deleted file mode 100644 index f0233bf970..0000000000 --- a/src/GitHub.InlineReviews/Views/DiffView.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.IO; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; - -namespace GitHub.InlineReviews.Views -{ - public class DiffView : StackPanel - { - static readonly Brush AddedBrush = new SolidColorBrush(Color.FromRgb(0xD7, 0xE3, 0xBC)); - static readonly Brush DeletedBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0x99, 0x99)); - - public static readonly DependencyProperty DiffProperty = - DependencyProperty.Register( - nameof(Diff), - typeof(string), - typeof(DiffView), - new PropertyMetadata(DiffChanged)); - - public string Diff - { - get { return (string)GetValue(DiffProperty); } - set { SetValue(DiffProperty, value); } - } - - void UpdateContents() - { - Children.Clear(); - - if (Diff != null) - { - using (var reader = new StringReader(Diff)) - { - string line; - - while ((line = reader.ReadLine()) != null) - { - var textBlock = new TextBlock(); - textBlock.Text = line; - - if (line.Length > 0) - { - switch (line[0]) - { - case '+': - textBlock.Background = AddedBrush; - break; - case '-': - textBlock.Background = DeletedBrush; - break; - } - } - - Children.Add(textBlock); - } - } - } - } - - static void DiffChanged(object sender, DependencyPropertyChangedEventArgs e) - { - ((DiffView)sender).UpdateContents(); - } - } -} diff --git a/src/GitHub.InlineReviews/Views/PullRequestCommentsPane.cs b/src/GitHub.InlineReviews/Views/PullRequestCommentsPane.cs deleted file mode 100644 index 3bc70a5a5f..0000000000 --- a/src/GitHub.InlineReviews/Views/PullRequestCommentsPane.cs +++ /dev/null @@ -1,49 +0,0 @@ -//------------------------------------------------------------------------------ -// -// Copyright (c) Company. All rights reserved. -// -//------------------------------------------------------------------------------ - -using System.Runtime.InteropServices; -using GitHub.Api; -using GitHub.Extensions; -using GitHub.InlineReviews.ViewModels; -using GitHub.Services; -using Microsoft.VisualStudio.Shell; -using Task = System.Threading.Tasks.Task; - -namespace GitHub.InlineReviews.Views -{ - [Guid("aa280a78-f2fa-49cd-b2f9-21426b40501f")] - public class PullRequestCommentsPane : ToolWindowPane - { - readonly PullRequestCommentsView view; - IPullRequestSession session; - - /// - /// Initializes a new instance of the class. - /// - public PullRequestCommentsPane() : base(null) - { - this.Caption = "Pull Request Comments"; - this.Content = view = new PullRequestCommentsView(); - } - - public async Task Initialize( - IPullRequestSession pullRequestSession, - IApiClient apiClient) - { - Guard.ArgumentNotNull(pullRequestSession, nameof(pullRequestSession)); - Guard.ArgumentNotNull(apiClient, nameof(apiClient)); - - if (this.session != null) - return; - - this.session = pullRequestSession; - - var viewModel = new PullRequestCommentsViewModel(pullRequestSession); - await viewModel.Initialize(); - view.DataContext = viewModel; - } - } -} diff --git a/src/GitHub.InlineReviews/Views/PullRequestCommentsView.xaml b/src/GitHub.InlineReviews/Views/PullRequestCommentsView.xaml deleted file mode 100644 index 025200f53a..0000000000 --- a/src/GitHub.InlineReviews/Views/PullRequestCommentsView.xaml +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - - - - - - - Thanks @StanleyGoldman! Might also be nice to also be able to sort by PR number? - - - - - - - - - .Subscribe(a => UpdateFilter(SelectedState, SelectedAssignee, a)); - -+ this.WhenAny(x => x.SelectedSortOrder, x => x.Value) -+ .Where(x => pullRequests != null) - - - - - - - @StanleyGoldman Ooops, I missed a x != null check here, and it's breaking the tests. Do you want to add it or shall I? - - - - - - - - - - - - - - - - - - - - - - - - - - / - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/GitHub.InlineReviews/Views/PullRequestCommentsView.xaml.cs b/src/GitHub.InlineReviews/Views/PullRequestCommentsView.xaml.cs deleted file mode 100644 index b95d9e7d5e..0000000000 --- a/src/GitHub.InlineReviews/Views/PullRequestCommentsView.xaml.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Windows.Controls; -using GitHub.VisualStudio.UI.Helpers; - -namespace GitHub.InlineReviews -{ - public partial class PullRequestCommentsView : UserControl - { - public PullRequestCommentsView() - { - this.InitializeComponent(); - PreviewMouseWheel += ScrollViewerUtilities.FixMouseWheelScroll; - } - } -} \ No newline at end of file diff --git a/src/GitHub.InlineReviews/packages.config b/src/GitHub.InlineReviews/packages.config index 52a4e8fd39..9c2010990b 100644 --- a/src/GitHub.InlineReviews/packages.config +++ b/src/GitHub.InlineReviews/packages.config @@ -33,6 +33,8 @@ + + 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/DropDownButton.cs b/src/GitHub.UI/Controls/DropDownButton.cs new file mode 100644 index 0000000000..7e06388725 --- /dev/null +++ b/src/GitHub.UI/Controls/DropDownButton.cs @@ -0,0 +1,80 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; + +namespace GitHub.UI +{ + public class DropDownButton : ContentControl + { + public static readonly DependencyProperty AutoCloseOnClickProperty = + DependencyProperty.Register("AutoCloseOnClick", typeof(bool), typeof(DropDownButton)); + public static readonly DependencyProperty DropDownContentProperty = + DependencyProperty.Register(nameof(DropDownContent), typeof(object), typeof(DropDownButton)); + public static readonly DependencyProperty IsOpenProperty = + Popup.IsOpenProperty.AddOwner(typeof(DropDownButton)); + + Button button; + Popup popup; + + static DropDownButton() + { + DefaultStyleKeyProperty.OverrideMetadata( + typeof(DropDownButton), + new FrameworkPropertyMetadata(typeof(DropDownButton))); + } + + public bool AutoCloseOnClick + { + get { return (bool)GetValue(AutoCloseOnClickProperty); } + set { SetValue(AutoCloseOnClickProperty, value); } + } + + public object DropDownContent + { + get { return GetValue(DropDownContentProperty); } + set { SetValue(DropDownContentProperty, value); } + } + + public bool IsOpen + { + get { return (bool)GetValue(IsOpenProperty); } + set { SetValue(IsOpenProperty, value); } + } + + public override void OnApplyTemplate() + { + base.OnApplyTemplate(); + button = (Button)Template.FindName("PART_Button", this); + popup = (Popup)Template.FindName("PART_Popup", this); + button.Click += ButtonClick; + popup.Opened += PopupOpened; + popup.Closed += PopupClosed; + popup.AddHandler(MouseUpEvent, new RoutedEventHandler(PopupMouseUp), true); + } + + void ButtonClick(object sender, RoutedEventArgs e) + { + IsOpen = true; + } + + private void PopupOpened(object sender, EventArgs e) + { + IsHitTestVisible = false; + } + + private void PopupClosed(object sender, EventArgs e) + { + IsOpen = false; + IsHitTestVisible = true; + } + + private void PopupMouseUp(object sender, RoutedEventArgs e) + { + if (AutoCloseOnClick) + { + IsOpen = false; + } + } + } +} diff --git a/src/GitHub.UI/Controls/DropDownButton.xaml b/src/GitHub.UI/Controls/DropDownButton.xaml new file mode 100644 index 0000000000..995237b5c1 --- /dev/null +++ b/src/GitHub.UI/Controls/DropDownButton.xaml @@ -0,0 +1,24 @@ + + + \ No newline at end of file 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/NotEqualsToVisibilityConverter.cs b/src/GitHub.UI/Converters/NotEqualsToVisibilityConverter.cs new file mode 100644 index 0000000000..42d186302b --- /dev/null +++ b/src/GitHub.UI/Converters/NotEqualsToVisibilityConverter.cs @@ -0,0 +1,30 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; +using System.Windows.Markup; + +namespace GitHub.UI +{ + public class NotEqualsToVisibilityConverter : MarkupExtension, IValueConverter + { + readonly string collapsedValue; + + public NotEqualsToVisibilityConverter(string collapsedValue) + { + this.collapsedValue = collapsedValue; + } + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value?.ToString() != collapsedValue ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + + public override object ProvideValue(IServiceProvider serviceProvider) => this; + } +} 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 ed3bbb281e..b5e6c4b839 100644 --- a/src/GitHub.UI/GitHub.UI.csproj +++ b/src/GitHub.UI/GitHub.UI.csproj @@ -79,6 +79,7 @@ + True True @@ -88,15 +89,18 @@ Spinner.xaml + + + @@ -217,6 +221,10 @@ MSBuild:Compile Designer + + MSBuild:Compile + Designer + MSBuild:Compile @@ -239,6 +247,10 @@ MSBuild:Compile + + MSBuild:Compile + Designer + diff --git a/src/GitHub.UI/Themes/Generic.xaml b/src/GitHub.UI/Themes/Generic.xaml new file mode 100644 index 0000000000..41baf6c6ff --- /dev/null +++ b/src/GitHub.UI/Themes/Generic.xaml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Resources.Designer.cs b/src/GitHub.VisualStudio.UI/Resources.Designer.cs index 419c0cdf9d..b829867d16 100644 --- a/src/GitHub.VisualStudio.UI/Resources.Designer.cs +++ b/src/GitHub.VisualStudio.UI/Resources.Designer.cs @@ -60,6 +60,33 @@ internal Resources() { } } + /// + /// Looks up a localized string similar to Add review comment. + /// + public static string AddReviewComment { + get { + return ResourceManager.GetString("AddReviewComment", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add a single comment. + /// + public static string AddSingleComment { + get { + return ResourceManager.GetString("AddSingleComment", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add your review. + /// + public static string AddYourReview { + get { + return ResourceManager.GetString("AddYourReview", resourceCulture); + } + } + /// /// Looks up a localized string similar to Invalid authentication code. /// @@ -150,6 +177,15 @@ public static string CompareFileAsDefaultAction { } } + /// + /// Looks up a localized string similar to Continue your review. + /// + public static string ContinueYourReview { + get { + return ResourceManager.GetString("ContinueYourReview", resourceCulture); + } + } + /// /// Looks up a localized string similar to Could not connect to github.com. /// @@ -771,6 +807,15 @@ public static string resendCodeButtonToolTip { } } + /// + /// Looks up a localized string similar to Reviewers. + /// + public static string Reviewers { + get { + return ResourceManager.GetString("Reviewers", resourceCulture); + } + } + /// /// Looks up a localized string similar to Sign in.... /// diff --git a/src/GitHub.VisualStudio.UI/Resources.resx b/src/GitHub.VisualStudio.UI/Resources.resx index 27da1f9985..c54e0086db 100644 --- a/src/GitHub.VisualStudio.UI/Resources.resx +++ b/src/GitHub.VisualStudio.UI/Resources.resx @@ -401,6 +401,21 @@ Token + + Continue your review + + + Add your review + + + Reviewers + + + Add review comment + + + Add a single comment + Debugging diff --git a/src/GitHub.VisualStudio.UI/Styles/GitHubActionLink.xaml b/src/GitHub.VisualStudio.UI/Styles/GitHubActionLink.xaml index b4a2cd459d..60218fc5ff 100644 --- a/src/GitHub.VisualStudio.UI/Styles/GitHubActionLink.xaml +++ b/src/GitHub.VisualStudio.UI/Styles/GitHubActionLink.xaml @@ -13,6 +13,15 @@ + + + @@ -40,8 +49,7 @@ - - + diff --git a/src/GitHub.VisualStudio.UI/Styles/GitHubTabControl.xaml b/src/GitHub.VisualStudio.UI/Styles/GitHubTabControl.xaml index b041d7bc99..5b9b6c0dc3 100644 --- a/src/GitHub.VisualStudio.UI/Styles/GitHubTabControl.xaml +++ b/src/GitHub.VisualStudio.UI/Styles/GitHubTabControl.xaml @@ -1,7 +1,7 @@  - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - + + + + + + + + + + + + + + + + - - - View conversation on GitHub - + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + HeaderText="{Binding Files.ChangedFilesCount, StringFormat={x:Static prop:Resources.ChangesCountFormat}}" + Margin="0 8 10 0"> + - + \ No newline at end of file diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml.cs b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml.cs index 038160144a..542a62b614 100644 --- a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml.cs +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml.cs @@ -1,32 +1,17 @@ using System; using System.ComponentModel.Composition; using System.Globalization; -using System.Linq; using System.Reactive.Linq; using System.Windows; -using System.Windows.Controls; -using System.Windows.Documents; using System.Windows.Input; -using System.Windows.Media; -using GitHub.Commands; using GitHub.Exports; using GitHub.Extensions; -using GitHub.Models; using GitHub.Services; using GitHub.UI; using GitHub.UI.Helpers; using GitHub.ViewModels.GitHubPane; using GitHub.VisualStudio.UI.Helpers; -using Microsoft.VisualStudio; -using Microsoft.VisualStudio.Editor; -using Microsoft.VisualStudio.Shell; -using Microsoft.VisualStudio.Shell.Interop; -using Microsoft.VisualStudio.Text; -using Microsoft.VisualStudio.Text.Editor; -using Microsoft.VisualStudio.Text.Projection; -using Microsoft.VisualStudio.TextManager.Interop; using ReactiveUI; -using Task = System.Threading.Tasks.Task; namespace GitHub.VisualStudio.Views.GitHubPane { @@ -47,38 +32,12 @@ public PullRequestDetailView() this.WhenActivated(d => { d(ViewModel.OpenOnGitHub.Subscribe(_ => DoOpenOnGitHub())); - d(ViewModel.DiffFile.Subscribe(x => DoDiffFile((IPullRequestFileNode)x, false).Forget())); - d(ViewModel.ViewFile.Subscribe(x => DoOpenFile((IPullRequestFileNode)x, false).Forget())); - d(ViewModel.DiffFileWithWorkingDirectory.Subscribe(x => DoDiffFile((IPullRequestFileNode)x, true).Forget())); - d(ViewModel.OpenFileInWorkingDirectory.Subscribe(x => DoOpenFile((IPullRequestFileNode)x, true).Forget())); }); - - bodyGrid.RequestBringIntoView += BodyFocusHack; } - [Import] - ITeamExplorerServiceHolder TeamExplorerServiceHolder { get; set; } - [Import] IVisualStudioBrowser VisualStudioBrowser { get; set; } - [Import] - IEditorOptionsFactoryService EditorOptionsFactoryService { get; set; } - - [Import] - IUsageTracker UsageTracker { get; set; } - - [Import] - IPullRequestEditorService NavigationService { get; set; } - - [Import] - IVsEditorAdaptersFactoryService EditorAdaptersFactoryService { get; set; } - - protected override void OnVisualParentChanged(DependencyObject oldParent) - { - base.OnVisualParentChanged(oldParent); - } - void DoOpenOnGitHub() { var browser = VisualStudioBrowser; @@ -93,315 +52,6 @@ static Uri ToPullRequestUrl(string host, string owner, string repositoryName, in return new Uri(url); } - async Task DoOpenFile(IPullRequestFileNode file, bool workingDirectory) - { - try - { - var fullPath = ViewModel.GetLocalFilePath(file); - var fileName = workingDirectory ? fullPath : await ViewModel.ExtractFile(file, true); - - using (workingDirectory ? null : OpenInProvisionalTab()) - { - var window = GitHub.VisualStudio.Services.Dte.ItemOperations.OpenFile(fileName); - window.Document.ReadOnly = !workingDirectory; - - var buffer = GetBufferAt(fileName); - - if (!workingDirectory) - { - AddBufferTag(buffer, ViewModel.Session, fullPath, null); - - var textView = NavigationService.FindActiveView(); - EnableNavigateToEditor(textView, file); - } - } - - if (workingDirectory) - await UsageTracker.IncrementCounter(x => x.NumberOfPRDetailsOpenFileInSolution); - else - await UsageTracker.IncrementCounter(x => x.NumberOfPRDetailsViewFile); - } - catch (Exception e) - { - ShowErrorInStatusBar("Error opening file", e); - } - } - - async Task DoNavigateToEditor(IPullRequestFileNode file) - { - try - { - if (!ViewModel.IsCheckedOut) - { - ShowInfoMessage("Checkout PR branch before opening file in solution."); - return; - } - - var fullPath = ViewModel.GetLocalFilePath(file); - - var activeView = NavigationService.FindActiveView(); - if (activeView == null) - { - ShowErrorInStatusBar("Couldn't find active view"); - return; - } - - NavigationService.NavigateToEquivalentPosition(activeView, fullPath); - - await UsageTracker.IncrementCounter(x => x.NumberOfPRDetailsNavigateToEditor); - } - catch (Exception e) - { - ShowErrorInStatusBar("Error navigating to editor", e); - } - } - - static void ShowInfoMessage(string message) - { - ErrorHandler.ThrowOnFailure(VsShellUtilities.ShowMessageBox( - Services.GitHubServiceProvider, message, null, - OLEMSGICON.OLEMSGICON_INFO, OLEMSGBUTTON.OLEMSGBUTTON_OK, OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST)); - } - - async Task DoDiffFile(IPullRequestFileNode file, bool workingDirectory) - { - try - { - var rightPath = System.IO.Path.Combine(file.DirectoryPath, file.FileName); - var leftPath = file.OldPath ?? rightPath; - var rightFile = workingDirectory ? ViewModel.GetLocalFilePath(file) : await ViewModel.ExtractFile(file, true); - var leftFile = await ViewModel.ExtractFile(file, false); - var leftLabel = $"{leftPath};{ViewModel.TargetBranchDisplayName}"; - var rightLabel = workingDirectory ? rightPath : $"{rightPath};PR {ViewModel.Model.Number}"; - var caption = $"Diff - {file.FileName}"; - var options = __VSDIFFSERVICEOPTIONS.VSDIFFOPT_DetectBinaryFiles | - __VSDIFFSERVICEOPTIONS.VSDIFFOPT_LeftFileIsTemporary; - - if (!workingDirectory) - { - options |= __VSDIFFSERVICEOPTIONS.VSDIFFOPT_RightFileIsTemporary; - } - - IVsWindowFrame frame; - using (OpenInProvisionalTab()) - { - var tooltip = $"{leftLabel}\nvs.\n{rightLabel}"; - - // Diff window will open in provisional (right hand) tab until document is touched. - frame = GitHub.VisualStudio.Services.DifferenceService.OpenComparisonWindow2( - leftFile, - rightFile, - caption, - tooltip, - leftLabel, - rightLabel, - string.Empty, - string.Empty, - (uint)options); - } - - object docView; - frame.GetProperty((int)__VSFPROPID.VSFPROPID_DocView, out docView); - var diffViewer = ((IVsDifferenceCodeWindow)docView).DifferenceViewer; - - var session = ViewModel.Session; - AddBufferTag(diffViewer.LeftView.TextBuffer, session, leftPath, DiffSide.Left); - - if (!workingDirectory) - { - AddBufferTag(diffViewer.RightView.TextBuffer, session, rightPath, DiffSide.Right); - EnableNavigateToEditor(diffViewer.LeftView, file); - EnableNavigateToEditor(diffViewer.RightView, file); - EnableNavigateToEditor(diffViewer.InlineView, file); - } - - if (workingDirectory) - await UsageTracker.IncrementCounter(x => x.NumberOfPRDetailsCompareWithSolution); - else - await UsageTracker.IncrementCounter(x => x.NumberOfPRDetailsViewChanges); - } - catch (Exception e) - { - ShowErrorInStatusBar("Error opening file", e); - } - } - - void AddBufferTag(ITextBuffer buffer, IPullRequestSession session, string path, DiffSide? side) - { - buffer.Properties.GetOrCreateSingletonProperty( - typeof(PullRequestTextBufferInfo), - () => new PullRequestTextBufferInfo(session, path, side)); - - var projection = buffer as IProjectionBuffer; - - if (projection != null) - { - foreach (var source in projection.SourceBuffers) - { - AddBufferTag(source, session, path, side); - } - } - } - - void EnableNavigateToEditor(IWpfTextView textView, IPullRequestFileNode file) - { - var view = EditorAdaptersFactoryService.GetViewAdapter(textView); - EnableNavigateToEditor(view, file); - } - - void EnableNavigateToEditor(IVsTextView textView, IPullRequestFileNode file) - { - var commandGroup = VSConstants.CMDSETID.StandardCommandSet2K_guid; - var commandId = (int)VSConstants.VSStd2KCmdID.RETURN; - new TextViewCommandDispatcher(textView, commandGroup, commandId).Exec += async (s, e) => await DoNavigateToEditor(file); - - var contextMenuCommandGroup = new Guid(Guids.guidContextMenuSetString); - var goToCommandId = PkgCmdIDList.openFileInSolutionCommand; - new TextViewCommandDispatcher(textView, contextMenuCommandGroup, goToCommandId).Exec += async (s, e) => await DoNavigateToEditor(file); - } - - void ShowErrorInStatusBar(string message, Exception e = null) - { - var ns = GitHub.VisualStudio.Services.DefaultExportProvider.GetExportedValue(); - if (e != null) - { - message += ": " + e.Message; - } - ns?.ShowMessage(message); - } - - private void FileListKeyUp(object sender, KeyEventArgs e) - { - if (e.Key == Key.Return) - { - var file = (e.OriginalSource as FrameworkElement)?.DataContext as IPullRequestFileNode; - if (file != null) - { - DoDiffFile(file, false).Forget(); - } - } - } - - void FileListMouseDoubleClick(object sender, MouseButtonEventArgs e) - { - var file = (e.OriginalSource as FrameworkElement)?.DataContext as IPullRequestFileNode; - - if (file != null) - { - DoDiffFile(file, false).Forget(); - } - } - - void FileListMouseRightButtonDown(object sender, MouseButtonEventArgs e) - { - var item = (e.OriginalSource as Visual)?.GetSelfAndVisualAncestors().OfType().FirstOrDefault(); - - if (item != null) - { - // Select tree view item on right click. - item.IsSelected = true; - } - } - - ITextBuffer GetBufferAt(string filePath) - { - var editorAdapterFactoryService = GitHub.VisualStudio.Services.ComponentModel.GetService(); - IVsUIHierarchy uiHierarchy; - uint itemID; - IVsWindowFrame windowFrame; - - if (VsShellUtilities.IsDocumentOpen( - GitHub.VisualStudio.Services.GitHubServiceProvider, - filePath, - Guid.Empty, - out uiHierarchy, - out itemID, - out windowFrame)) - { - IVsTextView view = VsShellUtilities.GetTextView(windowFrame); - IVsTextLines lines; - if (view.GetBuffer(out lines) == 0) - { - var buffer = lines as IVsTextBuffer; - if (buffer != null) - return editorAdapterFactoryService.GetDataBuffer(buffer); - } - } - - return null; - } - - void TreeView_ContextMenuOpening(object sender, ContextMenuEventArgs e) - { - ApplyContextMenuBinding(sender, e); - } - - void ApplyContextMenuBinding(object sender, ContextMenuEventArgs e) where TItem : Control - { - var container = (Control)sender; - var item = (e.OriginalSource as Visual)?.GetSelfAndVisualAncestors().OfType().FirstOrDefault(); - - e.Handled = true; - - if (item != null) - { - var fileNode = item.DataContext as IPullRequestFileNode; - - if (fileNode != null) - { - container.ContextMenu.DataContext = this.DataContext; - - foreach (var menuItem in container.ContextMenu.Items.OfType()) - { - menuItem.CommandParameter = fileNode; - } - - e.Handled = false; - } - } - } - - void BodyFocusHack(object sender, RequestBringIntoViewEventArgs e) - { - if (e.TargetObject == bodyMarkdown) - { - // Hack to prevent pane scrolling to top. Instead focus selected tree view item. - // See https://github.com/github/VisualStudio/issues/1042 - var node = changesTree.GetTreeViewItem(changesTree.SelectedItem); - node?.Focus(); - e.Handled = true; - } - } - - async void ViewFileCommentsClick(object sender, RoutedEventArgs e) - { - try - { - var file = (e.OriginalSource as Hyperlink)?.DataContext as IPullRequestFileNode; - - if (file != null) - { - var param = (object)new InlineCommentNavigationParams - { - FromLine = -1, - }; - - await DoDiffFile(file, false); - - // HACK: We need to wait here for the diff view to set itself up and move its cursor - // to the first changed line. There must be a better way of doing this. - await Task.Delay(1500); - - GitHub.VisualStudio.Services.Dte.Commands.Raise( - Guids.CommandSetString, - PkgCmdIDList.NextInlineCommentId, - ref param, - null); - } - } - catch { } - } - void OpenHyperlink(object sender, ExecutedRoutedEventArgs e) { Uri uri; @@ -411,12 +61,5 @@ void OpenHyperlink(object sender, ExecutedRoutedEventArgs e) VisualStudioBrowser.OpenUrl(uri); } } - - static IDisposable OpenInProvisionalTab() - { - return new NewDocumentStateScope - (__VSNEWDOCUMENTSTATE.NDS_Provisional, - VSConstants.NewDocumentStateReason.SolutionExplorer); - } } } 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.InlineReviews/Views/DiffCommentThreadView.xaml.cs b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFileCommentsView.xaml.cs similarity index 68% rename from src/GitHub.InlineReviews/Views/DiffCommentThreadView.xaml.cs rename to src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFileCommentsView.xaml.cs index 83952c4938..ac60ef8035 100644 --- a/src/GitHub.InlineReviews/Views/DiffCommentThreadView.xaml.cs +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFileCommentsView.xaml.cs @@ -13,14 +13,14 @@ using System.Windows.Navigation; using System.Windows.Shapes; -namespace GitHub.InlineReviews.Views +namespace GitHub.VisualStudio.Views.GitHubPane { /// - /// Interaction logic for DiffCommentThreadView.xaml + /// Interaction logic for PullRequestFileCommentsView.xaml /// - public partial class DiffCommentThreadView : UserControl + public partial class PullRequestFileCommentsView : UserControl { - public DiffCommentThreadView() + public PullRequestFileCommentsView() { InitializeComponent(); } diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFilesView.xaml b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFilesView.xaml new file mode 100644 index 0000000000..b344d21c01 --- /dev/null +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFilesView.xaml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFilesView.xaml.cs b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFilesView.xaml.cs new file mode 100644 index 0000000000..2f2956ef45 --- /dev/null +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFilesView.xaml.cs @@ -0,0 +1,87 @@ +using System.ComponentModel.Composition; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using GitHub.Exports; +using GitHub.UI.Helpers; +using GitHub.ViewModels.GitHubPane; + +namespace GitHub.VisualStudio.Views.GitHubPane +{ + [ExportViewFor(typeof(IPullRequestFilesViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public partial class PullRequestFilesView : UserControl + { + public PullRequestFilesView() + { + InitializeComponent(); + } + + protected override void OnMouseDown(MouseButtonEventArgs e) + { + base.OnMouseDown(e); + } + + void changesTree_ContextMenuOpening(object sender, ContextMenuEventArgs e) + { + ApplyContextMenuBinding(sender, e); + } + + void changesTree_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + var file = (e.OriginalSource as FrameworkElement)?.DataContext as IPullRequestFileNode; + (DataContext as IPullRequestFilesViewModel)?.DiffFile.Execute(file); + } + + void changesTree_MouseRightButtonDown(object sender, MouseButtonEventArgs e) + { + var item = (e.OriginalSource as Visual)?.GetSelfAndVisualAncestors().OfType().FirstOrDefault(); + + if (item != null) + { + // Select tree view item on right click. + item.IsSelected = true; + } + } + + void ApplyContextMenuBinding(object sender, ContextMenuEventArgs e) where TItem : Control + { + var container = (Control)sender; + var item = (e.OriginalSource as Visual)?.GetSelfAndVisualAncestors().OfType().FirstOrDefault(); + + e.Handled = true; + + if (item != null) + { + var fileNode = item.DataContext as IPullRequestFileNode; + + if (fileNode != null) + { + container.ContextMenu.DataContext = this.DataContext; + + foreach (var menuItem in container.ContextMenu.Items.OfType()) + { + menuItem.CommandParameter = fileNode; + } + + e.Handled = false; + } + } + } + + private void changesTree_KeyUp(object sender, KeyEventArgs e) + { + if (e.Key == Key.Return) + { + var file = (e.OriginalSource as FrameworkElement)?.DataContext as IPullRequestFileNode; + + if (file != null) + { + (DataContext as IPullRequestFilesViewModel)?.DiffFile.Execute(file); + } + } + } + } +} diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestReviewAuthoringView.xaml b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestReviewAuthoringView.xaml new file mode 100644 index 0000000000..6aeb7c32ab --- /dev/null +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestReviewAuthoringView.xaml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + Submit your review for + + # + + + + Your review summary + + + + + + + + Comment only + + Approve + + + Request changes + + + + + + + Cancel + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestReviewAuthoringView.xaml.cs b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestReviewAuthoringView.xaml.cs new file mode 100644 index 0000000000..56a263c11a --- /dev/null +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestReviewAuthoringView.xaml.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.Composition; +using GitHub.Exports; +using GitHub.UI; +using GitHub.ViewModels.GitHubPane; + +namespace GitHub.VisualStudio.Views.GitHubPane +{ + public class GenericPullRequestReviewAuthoringView : ViewBase + { } + + [ExportViewFor(typeof(IPullRequestReviewAuthoringViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public partial class PullRequestReviewAuthoringView : GenericPullRequestReviewAuthoringView + { + public PullRequestReviewAuthoringView() + { + InitializeComponent(); + } + } +} diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestReviewSummaryView.xaml b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestReviewSummaryView.xaml new file mode 100644 index 0000000000..80e4b85d52 --- /dev/null +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestReviewSummaryView.xaml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestReviewSummaryView.xaml.cs b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestReviewSummaryView.xaml.cs new file mode 100644 index 0000000000..a2c397b589 --- /dev/null +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestReviewSummaryView.xaml.cs @@ -0,0 +1,12 @@ +using System.Windows.Controls; + +namespace GitHub.VisualStudio.Views.GitHubPane +{ + public partial class PullRequestReviewSummaryView : UserControl + { + public PullRequestReviewSummaryView() + { + 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/src/GitHub.VisualStudio/packages.config b/src/GitHub.VisualStudio/packages.config index df355004a5..dfdd36c63a 100644 --- a/src/GitHub.VisualStudio/packages.config +++ b/src/GitHub.VisualStudio/packages.config @@ -34,7 +34,8 @@ - + + diff --git a/src/GitHub.VisualStudio/source.extension.vsixmanifest b/src/GitHub.VisualStudio/source.extension.vsixmanifest index 6b1eef68fa..b5e3a64f6b 100644 --- a/src/GitHub.VisualStudio/source.extension.vsixmanifest +++ b/src/GitHub.VisualStudio/source.extension.vsixmanifest @@ -1,7 +1,7 @@  - + GitHub Extension for Visual Studio A Visual Studio Extension that brings the GitHub Flow into Visual Studio. GitHub.VisualStudio @@ -39,4 +39,4 @@ - + \ No newline at end of file diff --git a/src/common/SolutionInfo.cs b/src/common/SolutionInfo.cs index d1797cd9ab..a993052159 100644 --- a/src/common/SolutionInfo.cs +++ b/src/common/SolutionInfo.cs @@ -18,6 +18,6 @@ namespace System { internal static class AssemblyVersionInformation { - internal const string Version = "2.4.4.0"; + internal const string Version = "2.4.99.0"; } } diff --git a/submodules/octokit.net b/submodules/octokit.net index 8420cb8fcd..d807d06526 160000 --- a/submodules/octokit.net +++ b/submodules/octokit.net @@ -1 +1 @@ -Subproject commit 8420cb8fcdfecf67412cbdabca61f793c666f51f +Subproject commit d807d065263f140575d4e5e755c40f459a72c262 diff --git a/test/GitHub.InlineReviews.UnitTests/GitHub.InlineReviews.UnitTests.csproj b/test/GitHub.InlineReviews.UnitTests/GitHub.InlineReviews.UnitTests.csproj index 1af4aa3380..2dd110ce3f 100644 --- a/test/GitHub.InlineReviews.UnitTests/GitHub.InlineReviews.UnitTests.csproj +++ b/test/GitHub.InlineReviews.UnitTests/GitHub.InlineReviews.UnitTests.csproj @@ -129,6 +129,7 @@ + @@ -136,6 +137,10 @@ + + {b389adaf-62cc-486e-85b4-2d8b078df763} + GitHub.Api + {e4ed0537-d1d9-44b6-9212-3096d7c3f7a1} GitHub.Exports.Reactive diff --git a/test/GitHub.InlineReviews.UnitTests/Services/PullRequestSessionManagerTests.cs b/test/GitHub.InlineReviews.UnitTests/Services/PullRequestSessionManagerTests.cs index 56771a4d49..275bd74f10 100644 --- a/test/GitHub.InlineReviews.UnitTests/Services/PullRequestSessionManagerTests.cs +++ b/test/GitHub.InlineReviews.UnitTests/Services/PullRequestSessionManagerTests.cs @@ -19,6 +19,7 @@ using NSubstitute; using NUnit.Framework; using System.ComponentModel; +using GitHub.Api; namespace GitHub.InlineReviews.UnitTests.Services { @@ -260,7 +261,8 @@ public async Task InlineCommentThreadsIsSet() sessionService.BuildCommentThreads( target.CurrentSession.PullRequest, FilePath, - Arg.Any>()) + Arg.Any>(), + Arg.Any()) .Returns(threads); var file = await target.GetLiveFile(FilePath, textView, textView.TextBuffer); @@ -284,7 +286,8 @@ public async Task CreatesTrackingPointsForThreads() sessionService.BuildCommentThreads( target.CurrentSession.PullRequest, FilePath, - Arg.Any>()) + Arg.Any>(), + Arg.Any()) .Returns(threads); var file = (PullRequestSessionLiveFile)await target.GetLiveFile(FilePath, textView, textView.TextBuffer); @@ -307,7 +310,8 @@ public async Task MovingToNoRepositoryShouldNullOutProperties() sessionService.BuildCommentThreads( target.CurrentSession.PullRequest, FilePath, - Arg.Any>()) + Arg.Any>(), + Arg.Any()) .Returns(threads); var file = (PullRequestSessionLiveFile)await target.GetLiveFile(FilePath, textView, textView.TextBuffer); @@ -346,7 +350,8 @@ public async Task ModifyingBufferMarksThreadsAsStaleAndSignalsRebuild() sessionService.BuildCommentThreads( target.CurrentSession.PullRequest, FilePath, - Arg.Any>()) + Arg.Any>(), + Arg.Any()) .Returns(threads); var file = (PullRequestSessionLiveFile)await target.GetLiveFile(FilePath, textView, textView.TextBuffer); @@ -635,7 +640,7 @@ public async Task UpdatingCurrentSessionPullRequestTriggersLinesChanged() CreateInlineCommentThreadModel(expectedLineNumber), }; - sessionService.BuildCommentThreads(null, null, null).ReturnsForAnyArgs(threads); + sessionService.BuildCommentThreads(null, null, null, null).ReturnsForAnyArgs(threads); var target = CreateTarget(sessionService: sessionService); var file = await target.GetLiveFile(FilePath, textView, textView.TextBuffer); @@ -671,6 +676,7 @@ IPullRequestSessionService CreateRealSessionService(IDiffService diff) Substitute.For(), diff, Substitute.For(), + Substitute.For(), Substitute.For()); result.CreateRebuildSignal().Returns(new Subject()); result.GetPullRequestMergeBase(Arg.Any(), Arg.Any()) diff --git a/test/GitHub.InlineReviews.UnitTests/Services/PullRequestSessionServiceTests.cs b/test/GitHub.InlineReviews.UnitTests/Services/PullRequestSessionServiceTests.cs index 355c7e74ea..53d60fd5c7 100644 --- a/test/GitHub.InlineReviews.UnitTests/Services/PullRequestSessionServiceTests.cs +++ b/test/GitHub.InlineReviews.UnitTests/Services/PullRequestSessionServiceTests.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Threading.Tasks; +using GitHub.Api; using GitHub.Factories; using GitHub.InlineReviews.Services; using GitHub.InlineReviews.UnitTests.TestDoubles; @@ -46,7 +47,8 @@ Line 2 var result = target.BuildCommentThreads( pullRequest, FilePath, - diff); + diff, + "HEAD_SHA"); var thread = result.Single(); Assert.That(2, Is.EqualTo(thread.LineNumber)); @@ -70,7 +72,8 @@ public async Task IgnoreCommentsWithNoDiffLineContext() var result = target.BuildCommentThreads( pullRequest, FilePath, - diff); + diff, + "HEAD_SHA"); Assert.That(result, Is.Empty); } @@ -105,7 +108,8 @@ Line 2 var result = target.BuildCommentThreads( pullRequest, FilePath, - diff); + diff, + "HEAD_SHA"); var thread = result.Single(); Assert.That(4, Is.EqualTo(thread.LineNumber)); @@ -145,7 +149,8 @@ Line 2 var result = target.BuildCommentThreads( pullRequest, FilePath, - diff); + diff, + "HEAD_SHA"); Assert.That(2, Is.EqualTo(result.Count)); Assert.That(-1, Is.EqualTo(result[1].LineNumber)); @@ -184,7 +189,8 @@ Line 2 var result = target.BuildCommentThreads( pullRequest, winFilePath, - diff); + diff, + "HEAD_SHA"); var thread = result.First(); Assert.That(4, Is.EqualTo(thread.LineNumber)); @@ -226,7 +232,8 @@ Line 2 var threads = target.BuildCommentThreads( pullRequest, FilePath, - diff); + diff, + "HEAD_SHA"); Assert.That(2, Is.EqualTo(threads[0].LineNumber)); @@ -271,7 +278,8 @@ Line 2 var threads = target.BuildCommentThreads( pullRequest, FilePath, - diff); + diff, + "HEAD_SHA"); threads[0].IsStale = true; var changedLines = target.UpdateCommentThreads(threads, diff); @@ -289,6 +297,7 @@ static PullRequestSessionService CreateTarget(IDiffService diffService) Substitute.For(), diffService, Substitute.For(), + Substitute.For(), Substitute.For()); } diff --git a/test/GitHub.InlineReviews.UnitTests/Services/PullRequestSessionTests.cs b/test/GitHub.InlineReviews.UnitTests/Services/PullRequestSessionTests.cs index 82be2cf01e..56caf6cf76 100644 --- a/test/GitHub.InlineReviews.UnitTests/Services/PullRequestSessionTests.cs +++ b/test/GitHub.InlineReviews.UnitTests/Services/PullRequestSessionTests.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using GitHub.Api; using GitHub.Extensions; using GitHub.Factories; using GitHub.InlineReviews.Services; @@ -22,13 +23,156 @@ public class PullRequestSessionTests const string RepoUrl = "https://foo.bar/owner/repo"; const string FilePath = "test.cs"; + public class TheHasPendingReviewProperty + { + [Test] + public void IsFalseWithNoPendingReview() + { + var target = new PullRequestSession( + CreateRealSessionService(), + Substitute.For(), + CreatePullRequest(), + Substitute.For(), + "owner", + true); + + Assert.That(target.HasPendingReview, Is.False); + } + + [Test] + public void IsFalseWithPendingReviewForOtherUser() + { + var currentUser = CreateAccount("grokys"); + var otherUser = CreateAccount("shana"); + var pr = CreatePullRequest(); + var review = CreatePullRequestReview(otherUser, PullRequestReviewState.Pending); + pr.Reviews.Returns(new[] { review }); + + var target = new PullRequestSession( + CreateRealSessionService(), + currentUser, + pr, + Substitute.For(), + "owner", + true); + + Assert.That(target.HasPendingReview, Is.False); + } + + [Test] + public void IsFalseWithNonPendingReviewForCurrentUser() + { + var currentUser = CreateAccount("grokys"); + var pr = CreatePullRequest(); + var review = CreatePullRequestReview(currentUser, PullRequestReviewState.Approved); + pr.Reviews.Returns(new[] { review }); + + var target = new PullRequestSession( + CreateRealSessionService(), + currentUser, + pr, + Substitute.For(), + "owner", + true); + + Assert.That(target.HasPendingReview, Is.False); + } + + [Test] + public void IsTrueWithPendingReviewForCurrentUser() + { + var currentUser = Substitute.For(); + var pr = CreatePullRequest(); + var review = CreatePullRequestReview(currentUser, PullRequestReviewState.Pending); + pr.Reviews.Returns(new[] { review }); + + var target = new PullRequestSession( + CreateRealSessionService(), + currentUser, + pr, + Substitute.For(), + "owner", + true); + + Assert.That(target.HasPendingReview, Is.True); + } + + [Test] + public async Task IsTrueWithUpdatedWithPendingReview() + { + var currentUser = Substitute.For(); + var target = new PullRequestSession( + CreateRealSessionService(), + currentUser, + CreatePullRequest(), + Substitute.For(), + "owner", + true); + + Assert.That(target.HasPendingReview, Is.False); + + var pr = CreatePullRequest(); + var review = CreatePullRequestReview(currentUser, PullRequestReviewState.Pending); + pr.Reviews.Returns(new[] { review }); + await target.Update(pr); + + Assert.That(target.HasPendingReview, Is.True); + } + + [Test] + public async Task IsTrueWhenStartReviewCalled() + { + var currentUser = Substitute.For(); + var service = Substitute.For(); + var review = CreatePullRequestReview(currentUser, PullRequestReviewState.Pending); + service.CreatePendingReview(null, null, null).ReturnsForAnyArgs(review); + + var target = new PullRequestSession( + service, + currentUser, + CreatePullRequest(), + CreateLocalRepository(), + "owner", + true); + + Assert.That(target.HasPendingReview, Is.False); + + await target.StartReview(); + + Assert.That(target.HasPendingReview, Is.True); + } + + [Test] + public async Task IsFalseWhenReviewCancelled() + { + var currentUser = Substitute.For(); + var pr = CreatePullRequest(); + var review = CreatePullRequestReview(currentUser, PullRequestReviewState.Pending); + pr.Reviews.Returns(new[] { review }); + + var target = new PullRequestSession( + Substitute.For(), + currentUser, + pr, + Substitute.For(), + "owner", + true); + + Assert.That(target.HasPendingReview, Is.True); + + await target.CancelReview(); + + Assert.That(target.HasPendingReview, Is.False); + } + } + public class TheGetFileMethod { [Test] public async Task BaseShaIsSet() { var target = new PullRequestSession( - CreateSessionService(), + CreateRealSessionService(), Substitute.For(), CreatePullRequest(), Substitute.For(), @@ -40,10 +184,10 @@ public async Task BaseShaIsSet() } [Test] - public async Task CommitShaIsSet() + public async Task HeadCommitShaIsSet() { var target = new PullRequestSession( - CreateSessionService(), + CreateRealSessionService(), Substitute.For(), CreatePullRequest(), Substitute.For(), @@ -52,13 +196,30 @@ public async Task CommitShaIsSet() var file = await target.GetFile(FilePath); Assert.That("HEAD_SHA", Is.SameAs(file.CommitSha)); + Assert.That(file.IsTrackingHead, Is.True); + } + + [Test] + public async Task PinnedCommitShaIsSet() + { + var target = new PullRequestSession( + CreateRealSessionService(), + Substitute.For(), + CreatePullRequest(), + Substitute.For(), + "owner", + true); + var file = await target.GetFile(FilePath, "123"); + + Assert.That("123", Is.SameAs(file.CommitSha)); + Assert.That(file.IsTrackingHead, Is.False); } [Test] public async Task DiffShaIsSet() { var diff = new List(); - var sessionService = CreateSessionService(); + var sessionService = CreateRealSessionService(); sessionService.Diff( Arg.Any(), @@ -99,7 +260,7 @@ Line 2 using (var diffService = new FakeDiffService()) { var pullRequest = CreatePullRequest(comment); - var service = CreateSessionService(diffService); + var service = CreateRealSessionService(diffService); diffService.AddFile(FilePath, baseContents, "MERGE_BASE"); diffService.AddFile(FilePath, headContents, "HEAD_SHA"); @@ -117,22 +278,251 @@ Line 2 Assert.That(2, Is.EqualTo(thread.LineNumber)); } } + + [Test] + public async Task SameNonHeadCommitShasReturnSameFiles() + { + var target = new PullRequestSession( + CreateRealSessionService(), + Substitute.For(), + CreatePullRequest(), + Substitute.For(), + "owner", + true); + var file1 = await target.GetFile(FilePath, "123"); + var file2 = await target.GetFile(FilePath, "123"); + + Assert.That(file1, Is.SameAs(file2)); + } + + [Test] + public async Task DifferentCommitShasReturnDifferentFiles() + { + var target = new PullRequestSession( + CreateRealSessionService(), + Substitute.For(), + CreatePullRequest(), + Substitute.For(), + "owner", + true); + var file1 = await target.GetFile(FilePath, "123"); + var file2 = await target.GetFile(FilePath, "456"); + + Assert.That(file1, Is.Not.SameAs(file2)); + } } - public class ThePostReviewCommentMethod + public class TheCancelReviewMethod { [Test] - public async Task PostsToCorrectFork() + public void ThrowsWithNoPendingReview() + { + var target = new PullRequestSession( + CreateRealSessionService(), + Substitute.For(), + CreatePullRequest(), + Substitute.For(), + "owner", + true); + + Assert.ThrowsAsync(async () => await target.CancelReview()); + } + + [Test] + public async Task CallsServiceWithNodeId() { var service = Substitute.For(); - var target = CreateTarget(service, "fork", "owner"); + var target = CreateTargetWithPendingReview(service); - await target.PostReviewComment("New Comment", "COMMIT_ID", "file.cs", 1); + await target.CancelReview(); - await service.Received(1).PostReviewComment( + await service.Received(1).CancelPendingReview( Arg.Any(), + "nodeId1"); + } + + [Test] + public async Task RemovesReviewFromModel() + { + var service = Substitute.For(); + var target = CreateTargetWithPendingReview(service); + + await target.CancelReview(); + + Assert.IsEmpty(target.PullRequest.Reviews); + } + + [Test] + public async Task RemovesCommentsFromModel() + { + var service = Substitute.For(); + var target = CreateTargetWithPendingReview(service); + + await target.CancelReview(); + + Assert.IsEmpty(target.PullRequest.ReviewComments); + } + + public static PullRequestSession CreateTargetWithPendingReview( + IPullRequestSessionService service) + { + var currentUser = Substitute.For(); + var pr = CreatePullRequest(); + var review = CreatePullRequestReview(currentUser, PullRequestReviewState.Pending); + var comment = Substitute.For(); + + comment.PullRequestReviewId.Returns(1); + pr.Reviews.Returns(new[] { review }); + pr.ReviewComments.Returns(new[] { comment }); + + return new PullRequestSession( + service, + currentUser, + pr, + Substitute.For(), + "owner", + true); + } + } + + public class ThePostReviewMethod + { + [Test] + public async Task PostsToCorrectForkWithNoPendingReview() + { + var service = Substitute.For(); + var target = CreateTarget(service, "fork", "owner", false); + + await target.PostReview("New Review", Octokit.PullRequestReviewEvent.Approve); + + await service.Received(1).PostReview( + target.LocalRepository, "owner", - Arg.Any(), + target.User, + PullRequestNumber, + "HEAD_SHA", + "New Review", + Octokit.PullRequestReviewEvent.Approve); + } + + [Test] + public async Task PostsToCorrectForkWithPendingReview() + { + var service = Substitute.For(); + var target = CreateTarget(service, "fork", "owner", true); + + await target.PostReview("New Review", Octokit.PullRequestReviewEvent.RequestChanges); + + await service.Received(1).SubmitPendingReview( + target.LocalRepository, + target.User, + "pendingReviewId", + "New Review", + Octokit.PullRequestReviewEvent.RequestChanges); + } + + [Test] + public async Task AddsReviewToModel() + { + var service = Substitute.For(); + var target = CreateTarget(service, "fork", "owner", false); + + var model = await target.PostReview("New Review", Octokit.PullRequestReviewEvent.RequestChanges); + + Assert.That(target.PullRequest.Reviews.Last(), Is.SameAs(model)); + } + + [Test] + public async Task ReplacesPendingReviewWithModel() + { + var service = Substitute.For(); + + var target = CreateTarget(service, "fork", "owner", true); + + Assert.That( + target.PullRequest.Reviews.Where(x => x.State == PullRequestReviewState.Pending).Count(), + Is.EqualTo(1)); + + var submittedReview = CreatePullRequestReview(target.User, PullRequestReviewState.Approved); + submittedReview.NodeId.Returns("pendingReviewId"); + service.SubmitPendingReview(null, null, null, null, Octokit.PullRequestReviewEvent.Approve) + .ReturnsForAnyArgs(submittedReview); + + var model = await target.PostReview("New Review", Octokit.PullRequestReviewEvent.Approve); + + Assert.That( + target.PullRequest.Reviews.Where(x => x.State == PullRequestReviewState.Pending).Count(), + Is.Zero); + } + + [Test] + public async Task MarksAssociatedCommentsAsNonPending() + { + var service = Substitute.For(); + var target = CreateTarget(service, "fork", "owner", true); + + Assert.That(target.PullRequest.ReviewComments[0].IsPending, Is.True); + + var submittedReview = CreatePullRequestReview(target.User, PullRequestReviewState.Approved); + submittedReview.NodeId.Returns("pendingReviewId"); + service.SubmitPendingReview(null, null, null, null, Octokit.PullRequestReviewEvent.Approve) + .ReturnsForAnyArgs(submittedReview); + var model = await target.PostReview("New Review", Octokit.PullRequestReviewEvent.RequestChanges); + + target.PullRequest.ReviewComments[0].Received(1).IsPending = false; + } + + PullRequestSession CreateTarget( + IPullRequestSessionService service, + string localRepositoryOwner, + string remoteRepositoryOwner, + bool hasPendingReview) + { + var repository = Substitute.For(); + + repository.CloneUrl.Returns(new UriString($"https://github.com/{localRepositoryOwner}/reop")); + repository.Owner.Returns(localRepositoryOwner); + repository.Name.Returns("repo"); + + var pr = CreatePullRequest(); + var user = Substitute.For(); + + if (hasPendingReview) + { + var reviewComment = Substitute.For(); + reviewComment.PullRequestReviewId.Returns(1); + reviewComment.IsPending.Returns(true); + pr.ReviewComments.Returns(new[] { reviewComment }); + + var review = CreatePullRequestReview(user, PullRequestReviewState.Pending); + review.NodeId.Returns("pendingReviewId"); + pr.Reviews.Returns(new[] { review }); + } + + return new PullRequestSession( + service, + user, + pr, + repository, + remoteRepositoryOwner, + true); + } + } + + public class ThePostReviewCommentMethod + { + [Test] + public async Task PostsToCorrectForkWithNoPendingReview() + { + var service = Substitute.For(); + var target = CreateTarget(service, "fork", "owner", false); + + await target.PostReviewComment("New Comment", "COMMIT_ID", "file.cs", new DiffChunk[0], 1); + + await service.Received(1).PostStandaloneReviewComment( + target.LocalRepository, + "owner", + target.User, PullRequestNumber, "New Comment", "COMMIT_ID", @@ -141,26 +531,61 @@ await service.Received(1).PostReviewComment( } [Test] - public async Task PostsReplyToCorrectFork() + public async Task PostsReplyToCorrectForkWithNoPendingReview() { var service = Substitute.For(); - var target = CreateTarget(service, "fork", "owner"); + var target = CreateTarget(service, "fork", "owner", false); - await target.PostReviewComment("New Comment", 1); + await target.PostReviewComment("New Comment", 1, "node1"); - await service.Received(1).PostReviewComment( - Arg.Any(), + await service.Received(1).PostStandaloneReviewCommentRepy( + target.LocalRepository, "owner", - Arg.Any(), + target.User, PullRequestNumber, "New Comment", 1); } + [Test] + public async Task PostsToCorrectForkWithPendingReview() + { + var service = Substitute.For(); + var target = CreateTarget(service, "fork", "owner", true); + + await target.PostReviewComment("New Comment", "COMMIT_ID", "file.cs", new DiffChunk[0], 1); + + await service.Received(1).PostPendingReviewComment( + target.LocalRepository, + target.User, + "pendingReviewId", + "New Comment", + "COMMIT_ID", + "file.cs", + 1); + } + + [Test] + public async Task PostsReplyToCorrectForkWithPendingReview() + { + var service = Substitute.For(); + var target = CreateTarget(service, "fork", "owner", true); + + await target.PostReviewComment("New Comment", 1, "node1"); + + await service.Received(1).PostPendingReviewCommentReply( + target.LocalRepository, + target.User, + "pendingReviewId", + "New Comment", + "node1"); + } + PullRequestSession CreateTarget( IPullRequestSessionService service, string localRepositoryOwner, - string remoteRepositoryOwner) + string remoteRepositoryOwner, + bool hasPendingReview) { var repository = Substitute.For(); @@ -168,10 +593,20 @@ PullRequestSession CreateTarget( repository.Owner.Returns(localRepositoryOwner); repository.Name.Returns("repo"); + var pr = CreatePullRequest(); + var user = Substitute.For(); + + if (hasPendingReview) + { + var review = CreatePullRequestReview(user, PullRequestReviewState.Pending); + review.NodeId.Returns("pendingReviewId"); + pr.Reviews.Returns(new[] { review }); + } + return new PullRequestSession( service, - Substitute.For(), - CreatePullRequest(), + user, + pr, repository, remoteRepositoryOwner, true); @@ -184,7 +619,7 @@ public class TheUpdateMethod public async Task UpdatesThePullRequestModel() { var target = new PullRequestSession( - CreateSessionService(), + CreateRealSessionService(), Substitute.For(), CreatePullRequest(), Substitute.For(), @@ -201,7 +636,7 @@ public async Task UpdatesThePullRequestModel() } [Test] - public async Task AddsNewReviewCommentToThread() + public async Task AddsNewReviewCommentToThreadOnHeadFile() { var baseContents = @"Line 1 Line 2 @@ -211,7 +646,6 @@ Line 3 Line 2 Line 3 with comment Line 4"; - var comment1 = CreateComment(@"@@ -1,4 +1,4 @@ Line 1 Line 2 @@ -226,7 +660,7 @@ Line 2 using (var diffService = new FakeDiffService()) { var pullRequest = CreatePullRequest(comment1); - var service = CreateSessionService(diffService); + var service = CreateRealSessionService(diffService); diffService.AddFile(FilePath, baseContents, "MERGE_BASE"); diffService.AddFile(FilePath, headContents, "HEAD_SHA"); @@ -239,14 +673,68 @@ Line 2 "owner", true); - var file = await target.GetFile(FilePath); + var file = await target.GetFile(FilePath, "HEAD"); - Assert.That(1, Is.EqualTo(file.InlineCommentThreads[0].Comments.Count)); + Assert.That(file.InlineCommentThreads[0].Comments, Has.Count.EqualTo(1)); + Assert.That(file.InlineCommentThreads[0].LineNumber, Is.EqualTo(2)); pullRequest = CreatePullRequest(comment1, comment2); await target.Update(pullRequest); - Assert.That(2, Is.EqualTo(file.InlineCommentThreads[0].Comments.Count)); + Assert.That(file.InlineCommentThreads[0].Comments, Has.Count.EqualTo(2)); + Assert.That(file.InlineCommentThreads[0].LineNumber, Is.EqualTo(2)); + } + } + + [Test] + public async Task AddsNewReviewCommentToThreadNonHeadFile() + { + var baseContents = @"Line 1 +Line 2 +Line 3 +Line 4"; + var headContents = @"Line 1 +Line 2 +Line 3 with comment +Line 4"; + + var comment1 = CreateComment(@"@@ -1,4 +1,4 @@ + Line 1 + Line 2 +-Line 3 ++Line 3 with comment", "Comment1"); + var comment2 = CreateComment(@"@@ -1,4 +1,4 @@ + Line 1 + Line 2 +-Line 3 ++Line 3 with comment", "Comment2"); + + using (var diffService = new FakeDiffService()) + { + var pullRequest = CreatePullRequest(comment1); + var service = CreateRealSessionService(diffService); + + diffService.AddFile(FilePath, baseContents, "MERGE_BASE"); + diffService.AddFile(FilePath, headContents, "123"); + + var target = new PullRequestSession( + service, + Substitute.For(), + pullRequest, + Substitute.For(), + "owner", + true); + + var file = await target.GetFile(FilePath, "123"); + + Assert.That(file.InlineCommentThreads[0].Comments, Has.Count.EqualTo(1)); + Assert.That(file.InlineCommentThreads[0].LineNumber, Is.EqualTo(2)); + + pullRequest = CreatePullRequest(comment1, comment2); + await target.Update(pullRequest); + + Assert.That(file.InlineCommentThreads[0].Comments, Has.Count.EqualTo(2)); + Assert.That(file.InlineCommentThreads[0].LineNumber, Is.EqualTo(2)); } } @@ -262,7 +750,7 @@ Line 2 using (var diffService = new FakeDiffService()) { var pullRequest = CreatePullRequest(comment); - var service = CreateSessionService(diffService); + var service = CreateRealSessionService(diffService); var target = new PullRequestSession( service, @@ -284,6 +772,13 @@ Line 2 } } + static IAccount CreateAccount(string login) + { + var result = Substitute.For(); + result.Login.Returns(login); + return result; + } + static IPullRequestReviewCommentModel CreateComment(string diffHunk, string body = "Comment") { var result = Substitute.For(); @@ -322,6 +817,19 @@ static IPullRequestModel CreatePullRequest(params IPullRequestReviewCommentModel return result; } + static IPullRequestReviewModel CreatePullRequestReview( + IAccount author, + PullRequestReviewState state, + long id = 1) + { + var result = Substitute.For(); + result.Id.Returns(id); + result.NodeId.Returns("nodeId" + id); + result.User.Returns(author); + result.State.Returns(state); + return result; + } + static IRepository CreateRepository() { var result = Substitute.For(); @@ -333,13 +841,21 @@ static IRepository CreateRepository() return result; } - static IPullRequestSessionService CreateSessionService(IDiffService diffService = null) + static ILocalRepositoryModel CreateLocalRepository() + { + var result = Substitute.For(); + result.CloneUrl.Returns(new UriString("https://github.com/owner/repo")); + return result; + } + + static IPullRequestSessionService CreateRealSessionService(IDiffService diffService = null) { var result = Substitute.ForPartsOf( Substitute.For(), Substitute.For(), diffService ?? Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For()); result.GetTipSha(Arg.Any()).Returns("BRANCH_TIP"); diff --git a/test/GitHub.InlineReviews.UnitTests/Tags/InlineCommentTaggerTests.cs b/test/GitHub.InlineReviews.UnitTests/Tags/InlineCommentTaggerTests.cs index 617c9bf129..99d16f3528 100644 --- a/test/GitHub.InlineReviews.UnitTests/Tags/InlineCommentTaggerTests.cs +++ b/test/GitHub.InlineReviews.UnitTests/Tags/InlineCommentTaggerTests.cs @@ -140,6 +140,48 @@ public void ShouldRaiseTagsChangedOnFileLinesChanged() Assert.True(raised); } + [Test] + public void ShouldCallSessionGetFileWithCorrectCommitSha() + { + var sessionManager = CreateSessionManager( + CreateSessionFile(), + DiffSide.Right, + "123"); + var session = sessionManager.CurrentSession; + var target = new InlineCommentTagger( + Substitute.For(), + Substitute.For(), + sessionManager); + + // Line 11 has an add diff entry. + var span = CreateSpan(11); + var firstPass = target.GetTags(span); + var result = target.GetTags(span).ToList(); + + session.Received(1).GetFile("file.cs", "123"); + } + + [Test] + public void ShouldAlwaysCallSessionGetFileWithHeadCommitShaForLeftHandSide() + { + var sessionManager = CreateSessionManager( + CreateSessionFile(), + DiffSide.Left, + "123"); + var session = sessionManager.CurrentSession; + var target = new InlineCommentTagger( + Substitute.For(), + Substitute.For(), + sessionManager); + + // Line 11 has an add diff entry. + var span = CreateSpan(11); + var firstPass = target.GetTags(span); + var result = target.GetTags(span).ToList(); + + session.Received(1).GetFile("file.cs", "HEAD"); + } + static IPullRequestSessionFile CreateSessionFile() { var diffChunk = new DiffChunk @@ -182,13 +224,15 @@ static IPullRequestSessionManager CreateSessionManager(DiffSide side) static IPullRequestSessionManager CreateSessionManager( IPullRequestSessionFile file, - DiffSide side) + DiffSide side, + string bufferInfoCommitSha = "HEAD") { var session = Substitute.For(); - session.GetFile("file.cs").Returns(file); + session.GetFile("file.cs", bufferInfoCommitSha).Returns(file); - var info = new PullRequestTextBufferInfo(session, "file.cs", side); + var info = new PullRequestTextBufferInfo(session, "file.cs", bufferInfoCommitSha, side); var result = Substitute.For(); + result.CurrentSession.Returns(session); result.GetTextBufferInfo(null).ReturnsForAnyArgs(info); return result; } diff --git a/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs b/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs index 1f9e259067..bf983d5f5d 100644 --- a/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs +++ b/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs @@ -67,6 +67,34 @@ public async Task ThreadIsCreatedForNewComment() Assert.That(CommentEditState.Editing, Is.EqualTo(target.Thread.Comments[0].EditState)); } + [Test] + public async Task ShouldGetRelativePathFromTextBufferInfoIfPresent() + { + var session = CreateSession(); + var bufferInfo = new PullRequestTextBufferInfo(session, RelativePath, "123", DiffSide.Right); + var sessionManager = CreateSessionManager( + relativePath: "ShouldNotUseThis", + session: session, + textBufferInfo: bufferInfo); + + // There is an existing comment thread at line 10. + var target = new InlineCommentPeekViewModel( + CreatePeekService(lineNumber: 10), + CreatePeekSession(), + sessionManager, + Substitute.For(), + Substitute.For()); + + await target.Initialize(); + + // There should be an existing comment and a reply placeholder. + Assert.That(target.Thread, Is.InstanceOf(typeof(InlineCommentThreadViewModel))); + Assert.That(2, Is.EqualTo(target.Thread.Comments.Count)); + Assert.That("Existing comment", Is.EqualTo(target.Thread.Comments[0].Body)); + Assert.That(string.Empty, Is.EqualTo(target.Thread.Comments[1].Body)); + Assert.That(CommentEditState.Placeholder, Is.EqualTo(target.Thread.Comments[1].EditState)); + } + [Test] public async Task SwitchesFromNewThreadToExistingThreadWhenCommentPosted() { @@ -85,7 +113,12 @@ public async Task SwitchesFromNewThreadToExistingThreadWhenCommentPosted() target.Thread.Comments[0].Body = "New Comment"; sessionManager.CurrentSession - .When(x => x.PostReviewComment(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())) + .When(x => x.PostReviewComment( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any())) .Do(async x => { // Simulate the thread being added to the session. @@ -162,7 +195,7 @@ public async Task RetainsCommentBeingEditedWhenSessionRefreshed() } [Test] - public async Task DoesntRetainSubmittedCommentInPlaceholderAfterPost() + public async Task CommittingEditDoesntRetainSubmittedCommentInPlaceholderAfterPost() { var sessionManager = CreateSessionManager(); var peekSession = CreatePeekSession(); @@ -177,7 +210,7 @@ public async Task DoesntRetainSubmittedCommentInPlaceholderAfterPost() Assert.That(2, Is.EqualTo(target.Thread.Comments.Count)); - sessionManager.CurrentSession.PostReviewComment(null, 0) + sessionManager.CurrentSession.PostReviewComment(null, 0, null) .ReturnsForAnyArgs(async x => { var file = await sessionManager.GetLiveFile( @@ -198,6 +231,43 @@ public async Task DoesntRetainSubmittedCommentInPlaceholderAfterPost() Assert.That(string.Empty, Is.EqualTo(placeholder.Body)); } + [Test] + public async Task StartingReviewDoesntRetainSubmittedCommentInPlaceholderAfterPost() + { + var sessionManager = CreateSessionManager(); + var peekSession = CreatePeekSession(); + var target = new InlineCommentPeekViewModel( + CreatePeekService(lineNumber: 10), + peekSession, + sessionManager, + Substitute.For(), + Substitute.For()); + + await target.Initialize(); + + Assert.That(2, Is.EqualTo(target.Thread.Comments.Count)); + + sessionManager.CurrentSession.StartReview() + .ReturnsForAnyArgs(async x => + { + var file = await sessionManager.GetLiveFile( + RelativePath, + peekSession.TextView, + peekSession.TextView.TextBuffer); + RaiseLinesChanged(file, Tuple.Create(10, DiffSide.Right)); + return Substitute.For(); + }); + + var placeholder = (IPullRequestReviewCommentViewModel)target.Thread.Comments.Last(); + placeholder.BeginEdit.Execute(null); + placeholder.Body = "Comment being edited"; + placeholder.StartReview.Execute(null); + + placeholder = (IPullRequestReviewCommentViewModel)target.Thread.Comments.Last(); + Assert.That(CommentEditState.Placeholder, Is.EqualTo(placeholder.EditState)); + Assert.That(string.Empty, Is.EqualTo(placeholder.Body)); + } + void AddCommentToExistingThread(IPullRequestSessionFile file) { var newThreads = file.InlineCommentThreads.ToList(); @@ -259,7 +329,18 @@ IPeekSession CreatePeekSession() return result; } - IPullRequestSessionManager CreateSessionManager(string commitSha = "COMMIT") + IPullRequestSession CreateSession() + { + var result = Substitute.For(); + result.LocalRepository.CloneUrl.Returns(new UriString("https://foo.bar")); + return result; + } + + IPullRequestSessionManager CreateSessionManager( + string commitSha = "COMMIT", + string relativePath = RelativePath, + IPullRequestSession session = null, + PullRequestTextBufferInfo textBufferInfo = null) { var thread = CreateThread(10, "Existing comment"); @@ -286,13 +367,18 @@ IPullRequestSessionManager CreateSessionManager(string commitSha = "COMMIT") file.InlineCommentThreads.Returns(new[] { thread }); file.LinesChanged.Returns(new Subject>>()); - var session = Substitute.For(); - session.LocalRepository.CloneUrl.Returns(new UriString("https://foo.bar")); + session = session ?? CreateSession(); + + if (textBufferInfo != null) + { + session.GetFile(textBufferInfo.RelativePath, textBufferInfo.CommitSha).Returns(file); + } var result = Substitute.For(); result.CurrentSession.Returns(session); - result.GetLiveFile(RelativePath, Arg.Any(), Arg.Any()).Returns(file); - result.GetRelativePath(Arg.Any()).Returns(RelativePath); + result.GetLiveFile(relativePath, Arg.Any(), Arg.Any()).Returns(file); + result.GetRelativePath(Arg.Any()).Returns(relativePath); + result.GetTextBufferInfo(Arg.Any()).Returns(textBufferInfo); return result; } diff --git a/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentThreadViewModelTests.cs b/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentThreadViewModelTests.cs index bba17ce346..b5e17c7ad9 100644 --- a/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentThreadViewModelTests.cs +++ b/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentThreadViewModelTests.cs @@ -65,7 +65,7 @@ public void PostsCommentInReplyToCorrectComment() target.Comments[2].Body = "New Comment"; target.Comments[2].CommitEdit.Execute(null); - session.Received(1).PostReviewComment("New Comment", 1); + session.Received(1).PostReviewComment("New Comment", 1, "node1"); } IApiClient CreateApiClient() @@ -81,6 +81,7 @@ IPullRequestReviewCommentModel CreateComment(int id, string body) var comment = Substitute.For(); comment.Body.Returns(body); comment.Id.Returns(id); + comment.NodeId.Returns("node" + id); return comment; } diff --git a/test/GitHub.InlineReviews.UnitTests/ViewModels/NewInlineCommentThreadViewModelTests.cs b/test/GitHub.InlineReviews.UnitTests/ViewModels/NewInlineCommentThreadViewModelTests.cs index 6151bdc7d9..333b88bc98 100644 --- a/test/GitHub.InlineReviews.UnitTests/ViewModels/NewInlineCommentThreadViewModelTests.cs +++ b/test/GitHub.InlineReviews.UnitTests/ViewModels/NewInlineCommentThreadViewModelTests.cs @@ -8,6 +8,7 @@ using NSubstitute; using Octokit; using NUnit.Framework; +using System.Collections.Generic; namespace GitHub.InlineReviews.UnitTests.ViewModels { @@ -87,6 +88,7 @@ public void PostsCommentToCorrectAddedLine() "New Comment", "COMMIT_SHA", "file.cs", + Arg.Any>(), 5); } @@ -116,6 +118,7 @@ public void AddsCommentToCorrectDeletedLine() "New Comment", "COMMIT_SHA", "file.cs", + Arg.Any>(), 7); } diff --git a/test/GitHub.InlineReviews.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs b/test/GitHub.InlineReviews.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs new file mode 100644 index 0000000000..f9becd8117 --- /dev/null +++ b/test/GitHub.InlineReviews.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs @@ -0,0 +1,130 @@ +using System; +using System.Reactive.Linq; +using GitHub.InlineReviews.ViewModels; +using GitHub.Models; +using GitHub.Services; +using NSubstitute; +using NUnit.Framework; +using ReactiveUI; + +namespace GitHub.InlineReviews.UnitTests.ViewModels +{ + public class PullRequestReviewCommentViewModelTests + { + public class TheCanStartReviewProperty + { + [Test] + public void IsFalseWhenSessionHasPendingReview() + { + var session = CreateSession(true); + var target = CreateTarget(session); + + Assert.That(target.CanStartReview, Is.False); + } + + [Test] + public void IsTrueWhenSessionHasNoPendingReview() + { + var session = CreateSession(false); + var target = CreateTarget(session); + + Assert.That(target.CanStartReview, Is.True); + } + } + + public class TheCommitCaptionProperty + { + [Test] + public void IsAddReviewCommentWhenSessionHasPendingReview() + { + var session = CreateSession(true); + var target = CreateTarget(session); + + Assert.That(target.CommitCaption, Is.EqualTo("Add review comment")); + } + + [Test] + public void IsAddSingleCommentWhenSessionHasNoPendingReview() + { + var session = CreateSession(false); + var target = CreateTarget(session); + + Assert.That(target.CommitCaption, Is.EqualTo("Add a single comment")); + } + } + + public class TheStartReviewCommand + { + [Test] + public void IsDisabledWhenSessionHasPendingReview() + { + var session = CreateSession(true); + var target = CreateTarget(session); + + Assert.That(target.StartReview.CanExecute(null), Is.False); + } + + [Test] + public void IsDisabledWhenSessionHasNoPendingReview() + { + var session = CreateSession(false); + var target = CreateTarget(session); + + Assert.That(target.StartReview.CanExecute(null), Is.False); + } + + [Test] + public void IsEnabledWhenSessionHasNoPendingReviewAndBodyNotEmpty() + { + var session = CreateSession(false); + var target = CreateTarget(session); + + target.Body = "body"; + + Assert.That(target.StartReview.CanExecute(null), Is.True); + } + + [Test] + public void CallsSessionStartReview() + { + var session = CreateSession(false); + var target = CreateTarget(session); + + target.Body = "body"; + target.StartReview.Execute(null); + + session.Received(1).StartReview(); + } + } + + static PullRequestReviewCommentViewModel CreateTarget( + IPullRequestSession session = null, + ICommentThreadViewModel thread = null) + { + session = session ?? CreateSession(); + thread = thread ?? CreateThread(); + + return new PullRequestReviewCommentViewModel( + session, + thread, + Substitute.For(), + Substitute.For()); + } + + static IPullRequestSession CreateSession( + bool hasPendingReview = false) + { + var result = Substitute.For(); + result.HasPendingReview.Returns(hasPendingReview); + return result; + } + + static ICommentThreadViewModel CreateThread( + bool canPost = true) + { + var result = Substitute.For(); + result.PostComment.Returns(new ReactiveCommand(Observable.Return(canPost), _ => null)); + return result; + } + } +} diff --git a/test/UnitTests/GitHub.Api/SimpleApiClientTests.cs b/test/UnitTests/GitHub.Api/SimpleApiClientTests.cs index 980da768df..34679192a8 100644 --- a/test/UnitTests/GitHub.Api/SimpleApiClientTests.cs +++ b/test/UnitTests/GitHub.Api/SimpleApiClientTests.cs @@ -237,7 +237,7 @@ private static Repository CreateRepository(int id, bool hasWiki) { return new Repository("", "", "", "", "", "", "", id, new User(), "", "", "", "", "", false, false, 0, 0, "", - 0, null, DateTimeOffset.Now, DateTimeOffset.Now, new RepositoryPermissions(), null, null, false, + 0, null, DateTimeOffset.Now, DateTimeOffset.Now, new RepositoryPermissions(), null, null, null, false, hasWiki, false, false, 0, 0, null, null, null); } } diff --git a/test/UnitTests/GitHub.App/Factories/ModelServiceFactoryTests.cs b/test/UnitTests/GitHub.App/Factories/ModelServiceFactoryTests.cs index a42a65c864..a47f5aa797 100644 --- a/test/UnitTests/GitHub.App/Factories/ModelServiceFactoryTests.cs +++ b/test/UnitTests/GitHub.App/Factories/ModelServiceFactoryTests.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using GitHub.Api; using GitHub.Caches; using GitHub.Extensions; using GitHub.Factories; @@ -63,12 +64,14 @@ static ModelServiceFactory CreateTarget( IHostCacheFactory hostCacheFactory = null) { var apiClientFactory = Substitute.For(); + var graphQLClientFactory = Substitute.For(); var avatarProvider = Substitute.For(); hostCacheFactory = hostCacheFactory ?? Substitute.For(); return new ModelServiceFactory( apiClientFactory, + graphQLClientFactory, hostCacheFactory, avatarProvider); } diff --git a/test/UnitTests/GitHub.App/Models/AccountModelTests.cs b/test/UnitTests/GitHub.App/Models/AccountModelTests.cs index 7f1f931843..6b31baa3bb 100644 --- a/test/UnitTests/GitHub.App/Models/AccountModelTests.cs +++ b/test/UnitTests/GitHub.App/Models/AccountModelTests.cs @@ -30,7 +30,7 @@ public void CopyFromDoesNotLoseAvatar() const string login = "foo"; const int initialOwnedPrivateRepositoryCount = 1; - var initialAccount = new Account(login, true, false, initialOwnedPrivateRepositoryCount, 0, initialBitmapImageSubject); + var initialAccount = new Account(login, true, false, initialOwnedPrivateRepositoryCount, 0, null, initialBitmapImageSubject); //Creating the test collection var col = new TrackingCollection(Observable.Empty(), OrderedComparer.OrderByDescending(x => x.Login).Compare); @@ -78,7 +78,7 @@ public void CopyFromDoesNotLoseAvatar() //Creating an account update const int updatedOwnedPrivateRepositoryCount = 2; var updatedBitmapImageSubject = new Subject(); - var updatedAccount = new Account(login, true, false, updatedOwnedPrivateRepositoryCount, 0, updatedBitmapImageSubject); + var updatedAccount = new Account(login, true, false, updatedOwnedPrivateRepositoryCount, 0, null, updatedBitmapImageSubject); //Updating the account in the collection col.AddItem(updatedAccount); @@ -119,13 +119,26 @@ public static bool BitmapSourcesAreEqual(BitmapSource image1, BitmapSource image public static byte[] BitmapSourceToBytes(BitmapSource image) { - var encoder = new BmpBitmapEncoder(); - encoder.Frames.Add(BitmapFrame.Create(image)); - using (MemoryStream ms = new MemoryStream()) + byte[] data = new byte[] { }; + if (image != null) { - encoder.Save(ms); - return ms.ToArray(); + try + { + var encoder = new BmpBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(image)); + using (MemoryStream ms = new MemoryStream()) + { + encoder.Save(ms); + data = ms.ToArray(); + } + return data; + } + catch (Exception ex) + { + } } + + return data; } } } diff --git a/test/UnitTests/GitHub.App/Models/ModelServiceTests.cs b/test/UnitTests/GitHub.App/Models/ModelServiceTests.cs index ed938565c3..844c2f3c95 100644 --- a/test/UnitTests/GitHub.App/Models/ModelServiceTests.cs +++ b/test/UnitTests/GitHub.App/Models/ModelServiceTests.cs @@ -29,10 +29,9 @@ public class TheGetCurrentUserMethod : TestBaseClass [Test] public async Task RetrievesCurrentUser() { - var apiClient = Substitute.For(); var cache = new InMemoryBlobCache(); await cache.InsertObject("user", new AccountCacheItem(CreateOctokitUser("octocat"))); - var modelService = new ModelService(apiClient, cache, Substitute.For()); + var modelService = CreateTarget(hostCache: cache); var user = await modelService.GetCurrentUser(); @@ -45,9 +44,8 @@ public class TheInsertUserMethod : TestBaseClass [Test] public async Task AddsUserToCache() { - var apiClient = Substitute.For(); var cache = new InMemoryBlobCache(); - var modelService = new ModelService(apiClient, cache, Substitute.For()); + var modelService = CreateTarget(hostCache: cache); var user = await modelService.InsertUser(new AccountCacheItem(CreateOctokitUser("octocat"))); @@ -65,7 +63,7 @@ public async Task CanRetrieveAndCacheGitIgnores() var apiClient = Substitute.For(); apiClient.GetGitIgnoreTemplates().Returns(data.ToObservable()); var cache = new InMemoryBlobCache(); - var modelService = new ModelService(apiClient, cache, Substitute.For()); + var modelService = CreateTarget(apiClient: apiClient, hostCache: cache); var fetched = await modelService.GetGitIgnoreTemplates().ToList(); @@ -90,14 +88,14 @@ public async Task CanRetrieveAndCacheLicenses() { var data = new[] { - new LicenseMetadata("mit", "MIT", new Uri("https://github.com/")), - new LicenseMetadata("apache", "Apache", new Uri("https://github.com/")) + new LicenseMetadata("mit", "MIT", "foo", "https://github.com/", false), + new LicenseMetadata("apache", "Apache", "foo", "https://github.com/", false) }; var apiClient = Substitute.For(); apiClient.GetLicenses().Returns(data.ToObservable()); var cache = new InMemoryBlobCache(); - var modelService = new ModelService(apiClient, cache, Substitute.For()); + var modelService = CreateTarget(apiClient: apiClient, hostCache: cache); var fetched = await modelService.GetLicenses().ToList(); @@ -120,8 +118,7 @@ public async Task ReturnsEmptyIfLicenseApiNotFound() var apiClient = Substitute.For(); apiClient.GetLicenses() .Returns(Observable.Throw(new NotFoundException("Not Found", HttpStatusCode.NotFound))); - var cache = new InMemoryBlobCache(); - var modelService = new ModelService(apiClient, cache, Substitute.For()); + var modelService = CreateTarget(apiClient: apiClient); var fetched = await modelService.GetLicenses().ToList(); @@ -135,7 +132,7 @@ public async Task ReturnsEmptyIfCacheReadFails() var cache = Substitute.For(); cache.Get(Args.String) .Returns(Observable.Throw(new InvalidOperationException("Unknown"))); - var modelService = new ModelService(apiClient, cache, Substitute.For()); + var modelService = CreateTarget(apiClient: apiClient, hostCache: cache); var fetched = await modelService.GetLicenses().ToList(); @@ -157,7 +154,7 @@ public async Task CanRetrieveAndCacheUserAndAccounts() apiClient.GetUser().Returns(Observable.Return(CreateOctokitUser("snoopy"))); apiClient.GetOrganizations().Returns(orgs.ToObservable()); var cache = new InMemoryBlobCache(); - var modelService = new ModelService(apiClient, cache, Substitute.For()); + var modelService = CreateTarget(apiClient: apiClient, hostCache: cache); await modelService.InsertUser(new AccountCacheItem { Login = "snoopy" }); var fetched = await modelService.GetAccounts(); @@ -185,7 +182,7 @@ public async Task CanRetrieveUserFromCacheAndAccountsFromApi() var apiClient = Substitute.For(); apiClient.GetOrganizations().Returns(orgs.ToObservable()); var cache = new InMemoryBlobCache(); - var modelService = new ModelService(apiClient, cache, Substitute.For()); + var modelService = CreateTarget(apiClient: apiClient, hostCache: cache); await modelService.InsertUser(new AccountCacheItem(CreateOctokitUser("octocat"))); var fetched = await modelService.GetAccounts(); @@ -214,8 +211,7 @@ public async Task OnlyRetrievesOneUserEvenIfCacheOrApiReturnsMoreThanOne() var apiClient = Substitute.For(); apiClient.GetUser().Returns(users.ToObservable()); apiClient.GetOrganizations().Returns(Observable.Empty()); - var cache = new InMemoryBlobCache(); - var modelService = new ModelService(apiClient, cache, Substitute.For()); + var modelService = CreateTarget(apiClient: apiClient); var fetched = await modelService.GetAccounts(); @@ -263,7 +259,7 @@ public async Task CanRetrieveAndCacheRepositoriesForUserAndOrganizations() apiClient.GetRepositoriesForOrganization("github").Returns(githubRepos.ToObservable()); apiClient.GetRepositoriesForOrganization("octokit").Returns(octokitRepos.ToObservable()); var cache = new InMemoryBlobCache(); - var modelService = new ModelService(apiClient, cache, Substitute.For()); + var modelService = CreateTarget(apiClient: apiClient, hostCache: cache); await modelService.InsertUser(new AccountCacheItem { Login = "opus" }); var fetched = await modelService.GetRepositories().ToList(); @@ -318,7 +314,7 @@ public async Task CanRetrieveAndCacheRepositoriesForUserAndOrganizations() public async Task WhenNotLoggedInReturnsEmptyCollection() { var apiClient = Substitute.For(); - var modelService = new ModelService(apiClient, new InMemoryBlobCache(), Substitute.For()); + var modelService = CreateTarget(apiClient: apiClient); var repos = await modelService.GetRepositories(); @@ -329,7 +325,7 @@ public async Task WhenNotLoggedInReturnsEmptyCollection() public async Task WhenLoggedInDoesNotBlowUpOnUnexpectedNetworkProblems() { var apiClient = Substitute.For(); - var modelService = new ModelService(apiClient, new InMemoryBlobCache(), Substitute.For()); + var modelService = CreateTarget(apiClient: apiClient); apiClient.GetOrganizations() .Returns(Observable.Throw(new NotFoundException("Not Found", HttpStatusCode.NotFound))); @@ -346,7 +342,7 @@ public async Task InvalidatesTheCache() { var apiClient = Substitute.For(); var cache = new InMemoryBlobCache(); - var modelService = new ModelService(apiClient, cache, Substitute.For()); + var modelService = CreateTarget(apiClient: apiClient, hostCache: cache); var user = await modelService.InsertUser(new AccountCacheItem(CreateOctokitUser("octocat"))); //Assert.Single((await cache.GetAllObjects())); @@ -367,7 +363,7 @@ public async Task VaccumsTheCache() received = true; return Observable.Return(Unit.Default); }); - var modelService = new ModelService(apiClient, cache, Substitute.For()); + var modelService = CreateTarget(apiClient: apiClient, hostCache: cache); await modelService.InvalidateAll(); Assert.True(received); @@ -387,7 +383,7 @@ public async Task NonExpiredIndexReturnsCache() var cache = new InMemoryBlobCache(); var apiClient = Substitute.For(); - var modelService = new ModelService(apiClient, cache, Substitute.For()); + var modelService = CreateTarget(apiClient: apiClient, hostCache: cache); var user = CreateOctokitUser(username); apiClient.GetUser().Returns(Observable.Return(user)); apiClient.GetOrganizations().Returns(Observable.Empty()); @@ -438,7 +434,7 @@ public async Task ExpiredIndexReturnsLive() var cache = new InMemoryBlobCache(); var apiClient = Substitute.For(); - var modelService = new ModelService(apiClient, cache, Substitute.For()); + var modelService = CreateTarget(apiClient: apiClient, hostCache: cache); var user = CreateOctokitUser(username); apiClient.GetUser().Returns(Observable.Return(user)); apiClient.GetOrganizations().Returns(Observable.Empty()); @@ -506,7 +502,7 @@ public async Task ExpiredIndexClearsItems() var cache = new InMemoryBlobCache(); var apiClient = Substitute.For(); - var modelService = new ModelService(apiClient, cache, Substitute.For()); + var modelService = CreateTarget(apiClient: apiClient, hostCache: cache); var user = CreateOctokitUser(username); apiClient.GetUser().Returns(Observable.Return(user)); apiClient.GetOrganizations().Returns(Observable.Empty()); @@ -574,4 +570,17 @@ public async Task ExpiredIndexClearsItems() );*/ } } + + static ModelService CreateTarget( + IApiClient apiClient = null, + Octokit.GraphQL.IConnection graphql = null, + IBlobCache hostCache = null, + IAvatarProvider avatarProvider = null) + { + return new ModelService( + apiClient ?? Substitute.For(), + graphql ?? Substitute.For(), + hostCache ?? new InMemoryBlobCache(), + Substitute.For()); + } } diff --git a/test/UnitTests/GitHub.App/Services/PullRequestServiceTests.cs b/test/UnitTests/GitHub.App/Services/PullRequestServiceTests.cs index 63dc45e80c..35678eafa7 100644 --- a/test/UnitTests/GitHub.App/Services/PullRequestServiceTests.cs +++ b/test/UnitTests/GitHub.App/Services/PullRequestServiceTests.cs @@ -579,203 +579,87 @@ static ILocalRepositoryModel CreateLocalRepositoryModel(Repository repo) static Signature Author => new Signature("foo", "foo@bar.com", DateTimeOffset.Now); - public class TheExtractFileMethod + public class TheExtractToTempFileMethod { [Test] - public async Task ExtractHead() + public async Task ExtractsExistingFile() { - var baseFileContent = "baseFileContent"; - var headFileContent = "headFileContent"; - var fileName = "fileName"; - var baseSha = "baseSha"; - var headSha = "headSha"; - var head = true; - - var file = await ExtractFile(baseSha, baseFileContent, headSha, headFileContent, baseSha, baseFileContent, - fileName, head, Encoding.UTF8); - - Assert.That(headFileContent, Is.EqualTo(File.ReadAllText(file))); - } + var gitClient = MockGitClient(); + var target = CreateTarget(gitClient); + var repository = Substitute.For(); + var fileContent = "file content"; + var pr = CreatePullRequest(); - [Test] - public async Task ExtractBase_MergeBaseAvailable_UseMergeBaseSha() - { - var baseFileContent = "baseFileContent"; - var headFileContent = "headFileContent"; - var mergeBaseFileContent = "mergeBaseFileContent"; - var fileName = "fileName"; - var baseSha = "baseSha"; - var headSha = "headSha"; - var mergeBaseSha = "mergeBaseSha"; - var head = false; - - var file = await ExtractFile(baseSha, baseFileContent, headSha, headFileContent, mergeBaseSha, mergeBaseFileContent, - fileName, head, Encoding.UTF8); - - Assert.That(mergeBaseFileContent, Is.EqualTo(File.ReadAllText(file))); - } + gitClient.ExtractFile(Arg.Any(), "123", "filename").Returns(GetFileTask(fileContent)); + var file = await target.ExtractToTempFile(repository, pr, "filename", "123", Encoding.UTF8); - [Test] - public void MergeBaseNotAvailable_ThrowsNotFoundException() - { - var baseFileContent = "baseFileContent"; - var headFileContent = "headFileContent"; - var mergeBaseFileContent = null as string; - var fileName = "fileName"; - var baseSha = "baseSha"; - var headSha = "headSha"; - var mergeBaseSha = null as string; - var head = false; - var mergeBaseException = new NotFoundException(); - - var ex = Assert.ThrowsAsync(() => ExtractFile(baseSha, baseFileContent, headSha, headFileContent, mergeBaseSha, mergeBaseFileContent, - fileName, head, Encoding.UTF8, mergeBaseException: mergeBaseException)); + try + { + Assert.That(File.ReadAllText(file), Is.EqualTo(fileContent)); + } + finally + { + File.Delete(file); + } } [Test] - public async Task FileAdded_BaseFileEmpty() + public async Task CreatesEmptyFileForNonExistentFile() { - var baseFileContent = null as string; - var headFileContent = "headFileContent"; - var fileName = "fileName"; - var baseSha = "baseSha"; - var headSha = "headSha"; - var head = false; - - var file = await ExtractFile(baseSha, baseFileContent, headSha, headFileContent, baseSha, baseFileContent, - fileName, head, Encoding.UTF8); + var gitClient = MockGitClient(); + var target = CreateTarget(gitClient); + var repository = Substitute.For(); + var pr = CreatePullRequest(); - Assert.That(string.Empty, Is.EqualTo(File.ReadAllText(file))); - } + gitClient.ExtractFile(Arg.Any(), "123", "filename").Returns(GetFileTask(null)); + var file = await target.ExtractToTempFile(repository, pr, "filename", "123", Encoding.UTF8); - [Test] - public async Task FileDeleted_HeadFileEmpty() - { - var baseFileContent = "baseFileContent"; - var headFileContent = null as string; - var fileName = "fileName"; - var baseSha = "baseSha"; - var headSha = "headSha"; - var baseRef = new GitReferenceModel("ref", "label", baseSha, "uri"); - var headRef = new GitReferenceModel("ref", "label", headSha, "uri"); - var head = true; - - var file = await ExtractFile(baseSha, baseFileContent, headSha, headFileContent, baseSha, baseFileContent, - fileName, head, Encoding.UTF8); - - Assert.That(string.Empty, Is.EqualTo(File.ReadAllText(file))); + try + { + Assert.That(File.ReadAllText(file), Is.EqualTo(string.Empty)); + } + finally + { + File.Delete(file); + } } // https://github.com/github/VisualStudio/issues/1010 [TestCase("utf-8")] // Unicode (UTF-8) [TestCase("Windows-1252")] // Western European (Windows) - public async Task ChangeEncoding(string encodingName) + public async Task CanChangeEncoding(string encodingName) { var encoding = Encoding.GetEncoding(encodingName); var repoDir = Path.GetTempPath(); - var baseFileContent = "baseFileContent"; - var headFileContent = null as string; var fileName = "fileName.txt"; - var baseSha = "baseSha"; - var headSha = "headSha"; - var baseRef = new GitReferenceModel("ref", "label", baseSha, "uri"); - var head = false; - - var file = await ExtractFile(baseSha, baseFileContent, headSha, headFileContent, - baseSha, baseFileContent, fileName, head, encoding, repoDir); + var fileContent = "file content"; + var gitClient = MockGitClient(); + var target = CreateTarget(gitClient); + var repository = Substitute.For(); + var pr = CreatePullRequest(); var expectedPath = Path.Combine(repoDir, fileName); - var expectedContent = baseFileContent; + var expectedContent = fileContent; File.WriteAllText(expectedPath, expectedContent, encoding); - Assert.That(File.ReadAllText(expectedPath), Is.EqualTo(File.ReadAllText(file))); - Assert.That(File.ReadAllBytes(expectedPath), Is.EqualTo(File.ReadAllBytes(file))); - } - - static bool HasPreamble(string file, Encoding encoding) - { - using (var stream = File.OpenRead(file)) - { - foreach (var b in encoding.GetPreamble()) - { - if (b != stream.ReadByte()) - { - return false; - } - } - } - - return true; - } - - static async Task ExtractFile( - string baseSha, object baseFileContent, string headSha, object headFileContent, string mergeBaseSha, object mergeBaseFileContent, - string fileName, bool head, Encoding encoding, string repoDir = "repoDir", int pullNumber = 666, string baseRef = "baseRef", string headRef = "headRef", - Exception mergeBaseException = null) - { - var repositoryModel = Substitute.For(); - repositoryModel.LocalPath.Returns(repoDir); - - var pullRequest = Substitute.For(); - pullRequest.Number.Returns(1); - - pullRequest.Base.Returns(new GitReferenceModel(baseRef, "label", baseSha, "uri")); - pullRequest.Head.Returns(new GitReferenceModel("ref", "label", headSha, "uri")); - - var serviceProvider = Substitutes.ServiceProvider; - var gitClient = MockGitClient(); - var gitService = serviceProvider.GetGitService(); - var service = new PullRequestService(gitClient, gitService, serviceProvider.GetOperatingSystem(), Substitute.For()); + gitClient.ExtractFile(Arg.Any(), "123", "filename").Returns(GetFileTask(fileContent)); + var file = await target.ExtractToTempFile(repository, pr, "filename", "123", encoding); - if (mergeBaseException == null) - { - gitClient.GetPullRequestMergeBase(Arg.Any(), Arg.Any(), baseSha, headSha, baseRef, pullNumber).ReturnsForAnyArgs(Task.FromResult(mergeBaseSha)); - } - else + try { - gitClient.GetPullRequestMergeBase(Arg.Any(), Arg.Any(), baseSha, headSha, baseRef, pullNumber).ReturnsForAnyArgs(Task.FromException(mergeBaseException)); + Assert.That(File.ReadAllText(expectedPath), Is.EqualTo(File.ReadAllText(file))); + Assert.That(File.ReadAllBytes(expectedPath), Is.EqualTo(File.ReadAllBytes(file))); } - - gitClient.ExtractFile(Arg.Any(), mergeBaseSha, fileName).Returns(GetFileTask(mergeBaseFileContent)); - gitClient.ExtractFile(Arg.Any(), baseSha, fileName).Returns(GetFileTask(baseFileContent)); - gitClient.ExtractFile(Arg.Any(), headSha, fileName).Returns(GetFileTask(headFileContent)); - - return await service.ExtractFile(repositoryModel, pullRequest, fileName, head, encoding); - } - - static IObservable GetFileObservable(object fileOrException) - { - if (fileOrException is string) + finally { - return Observable.Return((string)fileOrException); - } - - if (fileOrException is Exception) - { - return Observable.Throw((Exception)fileOrException); + File.Delete(file); } - - return Observable.Throw(new FileNotFoundException()); } - static Task GetFileTask(object content) + static IPullRequestModel CreatePullRequest() { - if (content is string) - { - return Task.FromResult((string)content); - } - - if (content is Exception) - { - return Task.FromException((Exception)content); - } - - if (content == null) - { - return Task.FromResult(null); - } - - throw new ArgumentException("Unsupported content type: " + content); + var result = Substitute.For(); + return result; } } @@ -1105,6 +989,24 @@ public async Task ShouldRemoveUnusedRemote() } } + static PullRequestService CreateTarget( + IGitClient gitClient = null, + IGitService gitService = null, + IOperatingSystem os = null, + IUsageTracker usageTracker = null) + { + gitClient = gitClient ?? Substitute.For(); + gitService = gitService ?? Substitute.For(); + os = os ?? Substitute.For(); + usageTracker = usageTracker ?? Substitute.For(); + + return new PullRequestService( + gitClient, + gitService, + os, + usageTracker); + } + static BranchCollection MockBranches(params string[] names) { var result = Substitute.For(); @@ -1138,4 +1040,24 @@ static IGitService MockGitService() result.GetRepository(Arg.Any()).Returns(repository); return result; } + + static Task GetFileTask(object content) + { + if (content is string) + { + return Task.FromResult((string)content); + } + + if (content is Exception) + { + return Task.FromException((Exception)content); + } + + if (content == null) + { + return Task.FromResult(null); + } + + throw new ArgumentException("Unsupported content type: " + content); + } } diff --git a/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModelTests.cs b/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModelTests.cs index c9e409c7c1..b3c216944e 100644 --- a/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModelTests.cs +++ b/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModelTests.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.ComponentModel; using System.IO; +using System.Linq; using System.Reactive; using System.Reactive.Linq; using System.Threading.Tasks; @@ -15,7 +17,7 @@ namespace UnitTests.GitHub.App.ViewModels.GitHubPane { - public class PullRequestDetailViewModelTests : TestBaseClass + public class PullRequestDetailViewModelTests { static readonly Uri Uri = new Uri("http://foo"); @@ -26,19 +28,19 @@ public async Task ShouldUsePlaceholderBodyIfNoneExists() { var target = CreateTarget(); - await target.Load(CreatePullRequest(body: string.Empty)); + await target.Load(CreatePullRequestModel(body: string.Empty)); Assert.That("*No description provided.*", Is.EqualTo(target.Body)); } } - public class TheHeadProperty + public class TheHeadProperty : TestBaseClass { [Test] public async Task ShouldAcceptNullHead() { var target = CreateTarget(); - var model = CreatePullRequest(); + var model = CreatePullRequestModel(); // PullRequest.Head can be null for example if a user deletes the repository after creating the PR. model.Head = null; @@ -49,103 +51,112 @@ public async Task ShouldAcceptNullHead() } } - public class TheChangedFilesTreeProperty + public class TheReviewsProperty : TestBaseClass { [Test] - public async Task ShouldCreateChangesTree() + public async Task ShouldShowLatestAcceptedOrChangesRequestedReview() { var target = CreateTarget(); - var pr = CreatePullRequest(); + var model = CreatePullRequestModel( + CreatePullRequestReviewModel(1, "grokys", PullRequestReviewState.ChangesRequested), + CreatePullRequestReviewModel(2, "shana", PullRequestReviewState.ChangesRequested), + CreatePullRequestReviewModel(3, "grokys", PullRequestReviewState.Approved), + CreatePullRequestReviewModel(4, "grokys", PullRequestReviewState.Commented)); - pr.ChangedFiles = new[] - { - new PullRequestFileModel("readme.md", "abc", PullRequestFileStatus.Modified), - new PullRequestFileModel("dir1/f1.cs", "abc", PullRequestFileStatus.Modified), - new PullRequestFileModel("dir1/f2.cs", "abc", PullRequestFileStatus.Modified), - new PullRequestFileModel("dir1/dir1a/f3.cs", "abc", PullRequestFileStatus.Modified), - new PullRequestFileModel("dir2/f4.cs", "abc", PullRequestFileStatus.Modified), - }; - - await target.Load(pr); - - Assert.That(3, Is.EqualTo(target.ChangedFilesTree.Count)); + await target.Load(model); - var dir1 = (PullRequestDirectoryNode)target.ChangedFilesTree[0]; - Assert.That("dir1", Is.EqualTo(dir1.DirectoryName)); - Assert.That(2, Is.EqualTo(dir1.Files.Count)); - Assert.That(1, Is.EqualTo(dir1.Directories.Count)); - Assert.That("f1.cs", Is.EqualTo(dir1.Files[0].FileName)); - Assert.That("f2.cs", Is.EqualTo(dir1.Files[1].FileName)); - Assert.That("dir1", Is.EqualTo(dir1.Files[0].DirectoryPath)); - Assert.That("dir1", Is.EqualTo(dir1.Files[1].DirectoryPath)); + Assert.That(target.Reviews, Has.Count.EqualTo(3)); + Assert.That(target.Reviews[0].User.Login, Is.EqualTo("grokys")); + Assert.That(target.Reviews[1].User.Login, Is.EqualTo("shana")); + Assert.That(target.Reviews[2].User.Login, Is.EqualTo("grokys")); + Assert.That(target.Reviews[0].Id, Is.EqualTo(3)); + Assert.That(target.Reviews[1].Id, Is.EqualTo(2)); + Assert.That(target.Reviews[2].Id, Is.EqualTo(0)); + } - var dir1a = (PullRequestDirectoryNode)dir1.Directories[0]; - Assert.That("dir1a", Is.EqualTo(dir1a.DirectoryName)); - Assert.That(1, Is.EqualTo(dir1a.Files.Count)); - Assert.That(0, Is.EqualTo(dir1a.Directories.Count)); + [Test] + public async Task ShouldShowLatestCommentedReviewIfNothingElsePresent() + { + var target = CreateTarget(); + var model = CreatePullRequestModel( + CreatePullRequestReviewModel(1, "shana", PullRequestReviewState.Commented), + CreatePullRequestReviewModel(2, "shana", PullRequestReviewState.Commented)); - var dir2 = (PullRequestDirectoryNode)target.ChangedFilesTree[1]; - Assert.That("dir2", Is.EqualTo(dir2.DirectoryName)); - Assert.That(1, Is.EqualTo(dir2.Files.Count)); - Assert.That(0, Is.EqualTo(dir2.Directories.Count)); + await target.Load(model); - var readme = (PullRequestFileNode)target.ChangedFilesTree[2]; - Assert.That("readme.md", Is.EqualTo(readme.FileName)); + Assert.That(target.Reviews, Has.Count.EqualTo(2)); + Assert.That(target.Reviews[0].User.Login, Is.EqualTo("shana")); + Assert.That(target.Reviews[1].User.Login, Is.EqualTo("grokys")); + Assert.That(target.Reviews[0].Id, Is.EqualTo(2)); } [Test] - public async Task FileCommentCountShouldTrackSessionInlineComments() + public async Task ShouldNotShowStartNewReviewWhenHasPendingReview() { - var pr = CreatePullRequest(); - var file = Substitute.For(); - var thread1 = CreateThread(5); - var thread2 = CreateThread(6); - var outdatedThread = CreateThread(-1); - var session = Substitute.For(); - var sessionManager = Substitute.For(); + var target = CreateTarget(); + var model = CreatePullRequestModel( + CreatePullRequestReviewModel(1, "grokys", PullRequestReviewState.Pending)); - file.InlineCommentThreads.Returns(new[] { thread1 }); - session.GetFile("readme.md").Returns(Task.FromResult(file)); - sessionManager.GetSession(pr).Returns(Task.FromResult(session)); + await target.Load(model); - var target = CreateTarget(sessionManager: sessionManager); + Assert.That(target.Reviews, Has.Count.EqualTo(1)); + Assert.That(target.Reviews[0].User.Login, Is.EqualTo("grokys")); + Assert.That(target.Reviews[0].Id, Is.EqualTo(1)); + } - pr.ChangedFiles = new[] - { - new PullRequestFileModel("readme.md", "abc", PullRequestFileStatus.Modified), - }; + [Test] + public async Task ShouldShowPendingReviewOverApproved() + { + var target = CreateTarget(); + var model = CreatePullRequestModel( + CreatePullRequestReviewModel(1, "grokys", PullRequestReviewState.Approved), + CreatePullRequestReviewModel(2, "grokys", PullRequestReviewState.Pending)); - await target.Load(pr); - Assert.That(1, Is.EqualTo(((IPullRequestFileNode)target.ChangedFilesTree[0]).CommentCount)); + await target.Load(model); - file.InlineCommentThreads.Returns(new[] { thread1, thread2 }); - RaisePropertyChanged(file, nameof(file.InlineCommentThreads)); - Assert.That(2, Is.EqualTo(((IPullRequestFileNode)target.ChangedFilesTree[0]).CommentCount)); + Assert.That(target.Reviews, Has.Count.EqualTo(1)); + Assert.That(target.Reviews[0].User.Login, Is.EqualTo("grokys")); + Assert.That(target.Reviews[0].Id, Is.EqualTo(2)); + } - // Outdated comment is not included in the count. - file.InlineCommentThreads.Returns(new[] { thread1, thread2, outdatedThread }); - RaisePropertyChanged(file, nameof(file.InlineCommentThreads)); - Assert.That(2, Is.EqualTo(((IPullRequestFileNode)target.ChangedFilesTree[0]).CommentCount)); + [Test] + public async Task ShouldNotShowPendingReviewForOtherUser() + { + var target = CreateTarget(); + var model = CreatePullRequestModel( + CreatePullRequestReviewModel(1, "shana", PullRequestReviewState.Pending)); + + await target.Load(model); - file.Received(1).PropertyChanged += Arg.Any(); + Assert.That(target.Reviews, Has.Count.EqualTo(1)); + Assert.That(target.Reviews[0].User.Login, Is.EqualTo("grokys")); + Assert.That(target.Reviews[0].Id, Is.EqualTo(0)); } - IInlineCommentThreadModel CreateThread(int lineNumber) + static PullRequestModel CreatePullRequestModel( + params IPullRequestReviewModel[] reviews) { - var result = Substitute.For(); - result.LineNumber.Returns(lineNumber); - return result; + return PullRequestDetailViewModelTests.CreatePullRequestModel(reviews: reviews); } - void RaisePropertyChanged(T o, string propertyName) - where T : INotifyPropertyChanged + static PullRequestReviewModel CreatePullRequestReviewModel( + long id, + string login, + PullRequestReviewState state) { - o.PropertyChanged += Raise.Event(new PropertyChangedEventArgs(propertyName)); - } + var account = Substitute.For(); + account.Login.Returns(login); + return new PullRequestReviewModel + { + Id = id, + User = account, + State = state, + }; + } } - public class TheCheckoutCommand + public class TheCheckoutCommand : TestBaseClass { [Test] public async Task CheckedOutAndUpToDate() @@ -154,7 +165,7 @@ public async Task CheckedOutAndUpToDate() currentBranch: "pr/123", existingPrBranch: "pr/123"); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.False(target.Checkout.CanExecute(null)); Assert.That(target.CheckoutState, Is.Null); @@ -167,7 +178,7 @@ public async Task NotCheckedOut() currentBranch: "master", existingPrBranch: "pr/123"); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.True(target.Checkout.CanExecute(null)); Assert.True(target.CheckoutState.IsEnabled); @@ -182,7 +193,7 @@ public async Task NotCheckedOutWithWorkingDirectoryDirty() existingPrBranch: "pr/123", dirty: true); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.False(target.Checkout.CanExecute(null)); Assert.That("Cannot checkout as your working directory has uncommitted changes.", Is.EqualTo(target.CheckoutState.ToolTip)); @@ -195,7 +206,7 @@ public async Task CheckoutExistingLocalBranch() currentBranch: "master", existingPrBranch: "pr/123"); - await target.Load(CreatePullRequest(number: 123)); + await target.Load(CreatePullRequestModel(number: 123)); Assert.True(target.Checkout.CanExecute(null)); Assert.That("Checkout pr/123", Is.EqualTo(target.CheckoutState.Caption)); @@ -207,7 +218,7 @@ public async Task CheckoutNonExistingLocalBranch() var target = CreateTarget( currentBranch: "master"); - await target.Load(CreatePullRequest(number: 123)); + await target.Load(CreatePullRequestModel(number: 123)); Assert.True(target.Checkout.CanExecute(null)); Assert.That("Checkout to pr/123", Is.EqualTo(target.CheckoutState.Caption)); @@ -219,7 +230,7 @@ public async Task UpdatesOperationErrorWithExceptionMessage() var target = CreateTarget( currentBranch: "master", existingPrBranch: "pr/123"); - var pr = CreatePullRequest(); + var pr = CreatePullRequestModel(); pr.Head = new GitReferenceModel("source", null, "sha", (string)null); @@ -236,7 +247,7 @@ public async Task SetsOperationErrorOnCheckoutFailure() currentBranch: "master", existingPrBranch: "pr/123"); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.True(target.Checkout.CanExecute(null)); @@ -252,7 +263,7 @@ public async Task ClearsOperationErrorOnCheckoutSuccess() currentBranch: "master", existingPrBranch: "pr/123"); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.True(target.Checkout.CanExecute(null)); Assert.ThrowsAsync(async () => await target.Checkout.ExecuteAsyncTask()); @@ -269,7 +280,7 @@ public async Task ClearsOperationErrorOnCheckoutRefresh() currentBranch: "master", existingPrBranch: "pr/123"); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.True(target.Checkout.CanExecute(null)); Assert.ThrowsAsync(async () => await target.Checkout.ExecuteAsyncTask()); @@ -280,7 +291,7 @@ public async Task ClearsOperationErrorOnCheckoutRefresh() } } - public class ThePullCommand + public class ThePullCommand : TestBaseClass { [Test] public async Task NotCheckedOut() @@ -289,7 +300,7 @@ public async Task NotCheckedOut() currentBranch: "master", existingPrBranch: "pr/123"); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.False(target.Pull.CanExecute(null)); Assert.That(target.UpdateState, Is.Null); @@ -302,7 +313,7 @@ public async Task CheckedOutAndUpToDate() currentBranch: "pr/123", existingPrBranch: "pr/123"); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.False(target.Pull.CanExecute(null)); Assert.That(0, Is.EqualTo(target.UpdateState.CommitsAhead)); @@ -318,7 +329,7 @@ public async Task CheckedOutAndBehind() existingPrBranch: "pr/123", behindBy: 2); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.True(target.Pull.CanExecute(null)); Assert.That(0, Is.EqualTo(target.UpdateState.CommitsAhead)); @@ -335,7 +346,7 @@ public async Task CheckedOutAndAheadAndBehind() aheadBy: 3, behindBy: 2); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.True(target.Pull.CanExecute(null)); Assert.That(3, Is.EqualTo(target.UpdateState.CommitsAhead)); @@ -352,7 +363,7 @@ public async Task CheckedOutAndBehindFork() prFromFork: true, behindBy: 2); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.True(target.Pull.CanExecute(null)); Assert.That(0, Is.EqualTo(target.UpdateState.CommitsAhead)); @@ -367,14 +378,14 @@ public async Task UpdatesOperationErrorWithExceptionMessage() currentBranch: "master", existingPrBranch: "pr/123"); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.ThrowsAsync(() => target.Pull.ExecuteAsyncTask(null)); Assert.That("Pull threw", Is.EqualTo(target.OperationError)); } } - public class ThePushCommand + public class ThePushCommand : TestBaseClass { [Test] public async Task NotCheckedOut() @@ -383,7 +394,7 @@ public async Task NotCheckedOut() currentBranch: "master", existingPrBranch: "pr/123"); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.False(target.Push.CanExecute(null)); Assert.That(target.UpdateState, Is.Null); @@ -396,7 +407,7 @@ public async Task CheckedOutAndUpToDate() currentBranch: "pr/123", existingPrBranch: "pr/123"); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.False(target.Push.CanExecute(null)); Assert.That(0, Is.EqualTo(target.UpdateState.CommitsAhead)); @@ -412,7 +423,7 @@ public async Task CheckedOutAndAhead() existingPrBranch: "pr/123", aheadBy: 2); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.True(target.Push.CanExecute(null)); Assert.That(2, Is.EqualTo(target.UpdateState.CommitsAhead)); @@ -428,7 +439,7 @@ public async Task CheckedOutAndBehind() existingPrBranch: "pr/123", behindBy: 2); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.False(target.Push.CanExecute(null)); Assert.That(0, Is.EqualTo(target.UpdateState.CommitsAhead)); @@ -445,7 +456,7 @@ public async Task CheckedOutAndAheadAndBehind() aheadBy: 3, behindBy: 2); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.False(target.Push.CanExecute(null)); Assert.That(3, Is.EqualTo(target.UpdateState.CommitsAhead)); @@ -462,7 +473,7 @@ public async Task CheckedOutAndAheadOfFork() prFromFork: true, aheadBy: 2); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.True(target.Push.CanExecute(null)); Assert.That(2, Is.EqualTo(target.UpdateState.CommitsAhead)); @@ -477,7 +488,7 @@ public async Task UpdatesOperationErrorWithExceptionMessage() currentBranch: "master", existingPrBranch: "pr/123"); - await target.Load(CreatePullRequest()); + await target.Load(CreatePullRequestModel()); Assert.ThrowsAsync(() => target.Push.ExecuteAsyncTask(null)); Assert.That("Push threw", Is.EqualTo(target.OperationError)); @@ -550,28 +561,45 @@ static Tuple CreateTargetAndSer pullRequestService.CalculateHistoryDivergence(repository, Arg.Any()) .Returns(Observable.Return(divergence)); + if (sessionManager == null) + { + var currentSession = Substitute.For(); + currentSession.User.Login.Returns("grokys"); + + sessionManager = Substitute.For(); + sessionManager.CurrentSession.Returns(currentSession); + sessionManager.GetSession(null).ReturnsForAnyArgs(currentSession); + } + var vm = new PullRequestDetailViewModel( pullRequestService, - sessionManager ?? Substitute.For(), + sessionManager, Substitute.For(), Substitute.For(), Substitute.For(), - Substitute.For()); + Substitute.For(), + Substitute.For()); vm.InitializeAsync(repository, Substitute.For(), "owner", "repo", 1).Wait(); return Tuple.Create(vm, pullRequestService); } - static PullRequestModel CreatePullRequest(int number = 1, string body = "PR Body") + static PullRequestModel CreatePullRequestModel( + int number = 1, + string body = "PR Body", + IEnumerable reviews = null) { var author = Substitute.For(); + reviews = reviews ?? new IPullRequestReviewModel[0]; + return new PullRequestModel(number, "PR 1", author, DateTimeOffset.Now) { State = PullRequestStateEnum.Open, Body = string.Empty, Head = new GitReferenceModel("source", "foo:baz", "sha", "https://github.com/foo/bar.git"), Base = new GitReferenceModel("dest", "foo:bar", "sha", "https://github.com/foo/bar.git"), + Reviews = reviews.ToList(), }; } diff --git a/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestFilesViewModelTests.cs b/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestFilesViewModelTests.cs new file mode 100644 index 0000000000..67a6cf87e6 --- /dev/null +++ b/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestFilesViewModelTests.cs @@ -0,0 +1,128 @@ +using System; +using System.ComponentModel; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using GitHub.ViewModels.GitHubPane; +using NSubstitute; +using NUnit.Framework; + +namespace UnitTests.GitHub.App.ViewModels.GitHubPane +{ + public class PullRequestFilesViewModelTests + { + static readonly Uri Uri = new Uri("http://foo"); + + [Test] + public async Task ShouldCreateChangesTree() + { + var target = CreateTarget(); + var session = CreateSession(); + + session.PullRequest.ChangedFiles.Returns(new[] + { + new PullRequestFileModel("readme.md", "abc", PullRequestFileStatus.Modified), + new PullRequestFileModel("dir1/f1.cs", "abc", PullRequestFileStatus.Modified), + new PullRequestFileModel("dir1/f2.cs", "abc", PullRequestFileStatus.Modified), + new PullRequestFileModel("dir1/dir1a/f3.cs", "abc", PullRequestFileStatus.Modified), + new PullRequestFileModel("dir2/f4.cs", "abc", PullRequestFileStatus.Modified), + }); + + await target.InitializeAsync(session); + + Assert.That(target.Items.Count, Is.EqualTo(3)); + + var dir1 = (PullRequestDirectoryNode)target.Items[0]; + Assert.That(dir1.DirectoryName, Is.EqualTo("dir1")); + Assert.That(dir1.Files, Has.Exactly(2).Items); + + Assert.That(dir1.Directories, Has.One.Items); + Assert.That(dir1.Files[0].FileName, Is.EqualTo("f1.cs")); + Assert.That(dir1.Files[1].FileName, Is.EqualTo("f2.cs")); + Assert.That(dir1.Files[0].RelativePath, Is.EqualTo("dir1\\f1.cs")); + Assert.That(dir1.Files[1].RelativePath, Is.EqualTo("dir1\\f2.cs")); + + var dir1a = (PullRequestDirectoryNode)dir1.Directories[0]; + Assert.That(dir1a.DirectoryName, Is.EqualTo("dir1a")); + Assert.That(dir1a.Files, Has.One.Items); + Assert.That(dir1a.Directories, Is.Empty); + + var dir2 = (PullRequestDirectoryNode)target.Items[1]; + Assert.That(dir2.DirectoryName, Is.EqualTo("dir2")); + Assert.That(dir2.Files, Has.One.Items); + Assert.That(dir2.Directories, Is.Empty); + + var readme = (PullRequestFileNode)target.Items[2]; + Assert.That(readme.FileName, Is.EqualTo("readme.md")); + } + + [Test] + public async Task FileCommentCountShouldTrackSessionInlineComments() + { + var outdatedThread = CreateThread(-1); + var session = CreateSession(); + + session.PullRequest.ChangedFiles.Returns(new[] + { + new PullRequestFileModel("readme.md", "abc", PullRequestFileStatus.Modified), + }); + + var file = Substitute.For(); + var thread1 = CreateThread(5); + var thread2 = CreateThread(6); + file.InlineCommentThreads.Returns(new[] { thread1 }); + session.GetFile("readme.md").Returns(Task.FromResult(file)); + + var target = CreateTarget(); + + await target.InitializeAsync(session); + Assert.That(((IPullRequestFileNode)target.Items[0]).CommentCount, Is.EqualTo(1)); + + file.InlineCommentThreads.Returns(new[] { thread1, thread2 }); + RaisePropertyChanged(file, nameof(file.InlineCommentThreads)); + Assert.That(((IPullRequestFileNode)target.Items[0]).CommentCount, Is.EqualTo(2)); + + // Outdated comment is not included in the count. + file.InlineCommentThreads.Returns(new[] { thread1, thread2, outdatedThread }); + RaisePropertyChanged(file, nameof(file.InlineCommentThreads)); + Assert.That(((IPullRequestFileNode)target.Items[0]).CommentCount, Is.EqualTo(2)); + + file.Received(1).PropertyChanged += Arg.Any(); + } + + static PullRequestFilesViewModel CreateTarget() + { + var pullRequestService = Substitute.For(); + var editorService = Substitute.For(); + return new PullRequestFilesViewModel(pullRequestService, editorService); + } + + static IPullRequestSession CreateSession() + { + var author = Substitute.For(); + var pr = Substitute.For(); + + var repository = Substitute.For(); + repository.LocalPath.Returns(@"C:\Foo"); + + var result = Substitute.For(); + result.LocalRepository.Returns(repository); + result.PullRequest.Returns(pr); + return result; + } + + IInlineCommentThreadModel CreateThread(int lineNumber) + { + var result = Substitute.For(); + result.LineNumber.Returns(lineNumber); + return result; + } + + void RaisePropertyChanged(T o, string propertyName) + where T : INotifyPropertyChanged + { + o.PropertyChanged += Raise.Event(new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModelTests.cs b/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModelTests.cs index 4d169be19a..3bdf322d62 100644 --- a/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModelTests.cs +++ b/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModelTests.cs @@ -131,9 +131,9 @@ IModelServiceFactory CreateModelServiceFactory() var pullRequest = new PullRequestModel( 1, "PR1", - new Account("foo", true, false, 1, 0, bitmapSource), + new Account("foo", true, false, 1, 0, null, bitmapSource), DateTimeOffset.MinValue); - pullRequest.Assignee = new Account("foo", true, false, 1, 0, bitmapSource); + pullRequest.Assignee = new Account("foo", true, false, 1, 0, null, bitmapSource); var pullRequestCollection = Substitute.For>(); pullRequestCollection[0].Returns(pullRequest); diff --git a/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModelTests.cs b/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModelTests.cs new file mode 100644 index 0000000000..86e9f7efc7 --- /dev/null +++ b/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModelTests.cs @@ -0,0 +1,565 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Subjects; +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 PullRequestReviewAuthoringViewModelTests + { + [Test] + public async Task Creates_New_Pending_Review_Model() + { + var target = CreateTarget(); + + await Initialize(target); + + Assert.That(target.Model.Id, Is.EqualTo(0)); + } + + [Test] + public async Task Uses_Existing_Pending_Review_Model() + { + var review = CreateReview(12, "grokys", state: PullRequestReviewState.Pending); + var model = CreatePullRequest(reviews: review); + + var target = CreateTarget(model); + + await Initialize(target); + + Assert.That(target.Model.Id, Is.EqualTo(12)); + } + + [Test] + public async Task Doesnt_Use_Non_Pending_Review_Model() + { + var review = CreateReview(12, "grokys", state: PullRequestReviewState.Approved); + var model = CreatePullRequest(reviews: review); + + var target = CreateTarget(model); + + await Initialize(target); + + Assert.That(target.Model.Id, Is.EqualTo(0)); + } + + [Test] + public async Task Doesnt_Use_Other_Users_Pending_Review_Model() + { + var review = CreateReview(12, "shana", state: PullRequestReviewState.Pending); + var model = CreatePullRequest(reviews: review); + + var target = CreateTarget(model); + + await Initialize(target); + + Assert.That(target.Model.Id, Is.EqualTo(0)); + } + + [Test] + public async Task Body_Is_Set() + { + var review = CreateReview(body: "Review body"); + var model = CreatePullRequest(reviews: review); + + var target = CreateTarget(model); + + await Initialize(target); + + Assert.That(target.Body, Is.EqualTo("Review body")); + } + + [Test] + public async Task CanApproveRequestChanges_Is_False_When_Is_Own_PullRequest() + { + var review = CreateReview(12, "grokys", state: PullRequestReviewState.Pending); + var model = CreatePullRequest("grokys", review); + + var target = CreateTarget(model); + + await Initialize(target); + + Assert.That(target.CanApproveRequestChanges, Is.False); + } + + [Test] + public async Task CanApproveRequestChanges_Is_True_When_Is_Someone_Elses_PullRequest() + { + var review = CreateReview(12, "grokys", state: PullRequestReviewState.Pending); + var model = CreatePullRequest("shana", review); + + var target = CreateTarget(model); + + await Initialize(target); + + Assert.That(target.CanApproveRequestChanges, Is.True); + } + + [Test] + public async Task Initializes_Files() + { + var session = CreateSession(); + var sessionManager = CreateSessionManager(session); + var target = CreateTarget(sessionManager: sessionManager); + + await Initialize(target); + + await target.Files.Received(1).InitializeAsync(session, Arg.Any>()); + } + + [Test] + public async Task ReInitializes_Files_When_Session_PullRequestChanged() + { + var session = CreateSession(); + var sessionManager = CreateSessionManager(session); + var target = CreateTarget(sessionManager: sessionManager); + + await Initialize(target); + + await target.Files.Received(1).InitializeAsync(session, Arg.Any>()); + + RaisePullRequestChanged(session, CreatePullRequest()); + + await target.Files.Received(2).InitializeAsync(session, Arg.Any>()); + } + + [Test] + public async Task Popuplates_FileComments() + { + var review = CreateReview(id: 12); + var model = CreatePullRequest(reviews: review); + var session = CreateSession( + "grokys", + CreateSessionFile( + CreateInlineCommentThread( + CreateReviewComment(11)), + CreateInlineCommentThread( + CreateReviewComment(12), + CreateReviewComment(12)))); + + var target = CreateTarget(model, session); + + await Initialize(target); + + Assert.That(target.FileComments, Has.Count.EqualTo(2)); + } + + [Test] + public async Task Updates_FileComments_When_Session_PullRequestChanged() + { + var review = CreateReview(id: 12); + var model = CreatePullRequest(reviews: review); + var session = CreateSession( + "grokys", + CreateSessionFile( + CreateInlineCommentThread( + CreateReviewComment(11)), + CreateInlineCommentThread( + CreateReviewComment(12), + CreateReviewComment(12)))); + + var target = CreateTarget(model, session); + + await Initialize(target); + + Assert.That(target.FileComments, Has.Count.EqualTo(2)); + + var newSessionFile = CreateSessionFile( + CreateInlineCommentThread( + CreateReviewComment(11)), + CreateInlineCommentThread( + CreateReviewComment(12))); + session.GetAllFiles().Returns(new[] { newSessionFile }); + RaisePullRequestChanged(session, CreatePullRequest()); + + Assert.That(target.FileComments, Has.Count.EqualTo(1)); + } + + [Test] + public async Task Updates_Model_Id_From_PendingReviewId_When_Session_PullRequestChanged() + { + var model = CreatePullRequest(); + var session = CreateSession( + "grokys", + CreateSessionFile( + CreateInlineCommentThread( + CreateReviewComment(11)), + CreateInlineCommentThread( + CreateReviewComment(12), + CreateReviewComment(12)))); + + var target = CreateTarget(model, session); + + await Initialize(target); + + Assert.That(target.Model.Id, Is.EqualTo(0)); + + session.PendingReviewId.Returns(123); + RaisePullRequestChanged(session, model); + + Assert.That(target.Model.Id, Is.EqualTo(123)); + } + + [Test] + public async Task Approve_Calls_Session_PostReview_And_Closes() + { + var review = CreateReview(12, "grokys", state: PullRequestReviewState.Pending); + var model = CreatePullRequest("shana", review); + var session = CreateSession(); + var closed = false; + + var target = CreateTarget(model, session); + + await Initialize(target); + target.Body = "Post review"; + target.CloseRequested.Subscribe(_ => closed = true); + target.Approve.Execute(null); + + await session.Received(1).PostReview("Post review", Octokit.PullRequestReviewEvent.Approve); + Assert.True(closed); + } + + [Test] + public async Task Comment_Is_Disabled_When_Has_Empty_Body_And_No_File_Comments() + { + var review = CreateReview(12, "grokys", body: "", state: PullRequestReviewState.Pending); + var model = CreatePullRequest("shana", review); + var session = CreateSession(); + + var target = CreateTarget(model, session); + await Initialize(target); + + Assert.IsFalse(target.Comment.CanExecute(null)); + } + + [Test] + public async Task Comment_Is_Enabled_When_Has_Body() + { + var review = CreateReview(12, "grokys", body: "", state: PullRequestReviewState.Pending); + var model = CreatePullRequest("shana", review); + var session = CreateSession(); + + var target = CreateTarget(model, session); + await Initialize(target); + target.Body = "Review body"; + + Assert.IsTrue(target.Comment.CanExecute(null)); + } + + [Test] + public async Task Comment_Is_Enabled_When_Has_File_Comments() + { + var review = CreateReview(12, "grokys", body: "", state: PullRequestReviewState.Pending); + var model = CreatePullRequest("shana", review); + var session = CreateSession( + "grokys", + CreateSessionFile( + CreateInlineCommentThread(CreateReviewComment(12)))); + + var target = CreateTarget(model, session); + await Initialize(target); + + Assert.IsTrue(target.Comment.CanExecute(null)); + } + + [Test] + public async Task Comment_Calls_Session_PostReview_And_Closes() + { + var review = CreateReview(12, "grokys", state: PullRequestReviewState.Pending); + var model = CreatePullRequest("shana", review); + var session = CreateSession(); + var closed = false; + + var target = CreateTarget(model, session); + + await Initialize(target); + target.Body = "Post review"; + target.CloseRequested.Subscribe(_ => closed = true); + target.Comment.Execute(null); + + await session.Received(1).PostReview("Post review", Octokit.PullRequestReviewEvent.Comment); + Assert.True(closed); + } + + [Test] + public async Task RequestChanges_Is_Disabled_When_Has_Empty_Body_And_No_File_RequestChangess() + { + var review = CreateReview(12, "grokys", body: "", state: PullRequestReviewState.Pending); + var model = CreatePullRequest("shana", review); + var session = CreateSession(); + + var target = CreateTarget(model, session); + await Initialize(target); + + Assert.IsFalse(target.RequestChanges.CanExecute(null)); + } + + [Test] + public async Task RequestChanges_Is_Enabled_When_Has_Body() + { + var review = CreateReview(12, "grokys", body: "", state: PullRequestReviewState.Pending); + var model = CreatePullRequest("shana", review); + var session = CreateSession(); + + var target = CreateTarget(model, session); + await Initialize(target); + target.Body = "Review body"; + + Assert.IsTrue(target.RequestChanges.CanExecute(null)); + } + + [Test] + public async Task RequestChanges_Is_Enabled_When_Has_File_Comments() + { + var review = CreateReview(12, "grokys", body: "", state: PullRequestReviewState.Pending); + var model = CreatePullRequest("shana", review); + var session = CreateSession( + "grokys", + CreateSessionFile( + CreateInlineCommentThread(CreateReviewComment(12)))); + + var target = CreateTarget(model, session); + await Initialize(target); + + Assert.IsTrue(target.RequestChanges.CanExecute(null)); + } + + [Test] + public async Task RequestChanges_Calls_Session_PostReview_And_Closes() + { + var review = CreateReview(12, "grokys", state: PullRequestReviewState.Pending); + var model = CreatePullRequest("shana", review); + var session = CreateSession(); + var closed = false; + + var target = CreateTarget(model, session); + + await Initialize(target); + target.Body = "Post review"; + target.CloseRequested.Subscribe(_ => closed = true); + target.RequestChanges.Execute(null); + + await session.Received(1).PostReview("Post review", Octokit.PullRequestReviewEvent.RequestChanges); + Assert.True(closed); + } + + [Test] + public async Task Cancel_Calls_Session_CancelReview_And_Closes_When_Has_Pending_Review() + { + var review = CreateReview(12, "grokys", state: PullRequestReviewState.Pending); + var model = CreatePullRequest("shana", review); + var session = CreateSession(); + var closed = false; + + var target = CreateTarget(model, session); + await Initialize(target); + + target.CloseRequested.Subscribe(_ => closed = true); + target.Cancel.Execute(null); + + await session.Received(1).CancelReview(); + Assert.True(closed); + } + + [Test] + public async Task Cancel_Just_Closes_When_Has_No_Pending_Review() + { + var model = CreatePullRequest("shana"); + var session = CreateSession(); + var closed = false; + + var target = CreateTarget(model, session); + await Initialize(target); + + target.CloseRequested.Subscribe(_ => closed = true); + target.Cancel.Execute(null); + + await session.Received(0).CancelReview(); + Assert.True(closed); + } + + static PullRequestReviewAuthoringViewModel CreateTarget( + IPullRequestModel model, + IPullRequestSession session = null) + { + return CreateTarget( + sessionManager: CreateSessionManager(session), + modelServiceFactory: CreateModelServiceFactory(CreateModelService(model))); + } + + static PullRequestReviewAuthoringViewModel CreateTarget( + IPullRequestEditorService editorService = null, + IPullRequestSessionManager sessionManager = null, + IModelServiceFactory modelServiceFactory = null, + IPullRequestFilesViewModel files = null) + { + editorService = editorService ?? Substitute.For(); + sessionManager = sessionManager ?? CreateSessionManager(); + modelServiceFactory = modelServiceFactory ?? CreateModelServiceFactory(); + files = files ?? Substitute.For(); + + return new PullRequestReviewAuthoringViewModel( + editorService, + sessionManager, + modelServiceFactory, + files); + } + + static PullRequestReviewModel CreateReview( + int id = 5, + string login = "grokys", + string body = "Review body", + PullRequestReviewState state = PullRequestReviewState.Pending) + { + var user = Substitute.For(); + user.Login.Returns(login); + + return new PullRequestReviewModel + { + Id = id, + State = state, + User = user, + Body = body, + }; + } + + static PullRequestReviewCommentModel CreateReviewComment( + int pullRequestReviewId) + { + return new PullRequestReviewCommentModel + { + PullRequestReviewId = pullRequestReviewId, + }; + } + + static PullRequestModel CreatePullRequest( + string authorLogin = "grokys", + params IPullRequestReviewModel[] reviews) + { + var author = Substitute.For(); + author.Login.Returns(authorLogin); + + var result = new PullRequestModel( + 5, + "Pull Request", + author, + DateTimeOffset.Now); + result.Reviews = reviews.ToList(); + return result; + } + + static PullRequestModel CreatePullRequest( + string authorLogin = "grokys", + IEnumerable reviews = null, + IEnumerable reviewComments = null) + { + reviews = reviews ?? new IPullRequestReviewModel[0]; + reviewComments = reviewComments ?? new IPullRequestReviewCommentModel[0]; + + var author = Substitute.For(); + author.Login.Returns(authorLogin); + + var result = new PullRequestModel( + 5, + "Pull Request", + author, + DateTimeOffset.Now); + result.Reviews = reviews.ToList(); + result.ReviewComments = reviewComments.ToList(); + return result; + } + + static IPullRequestSession CreateSession( + string userLogin = "grokys", + params IPullRequestSessionFile[] files) + { + var user = Substitute.For(); + user.Login.Returns(userLogin); + + var result = Substitute.For(); + result.User.Returns(user); + result.GetAllFiles().Returns(files); + result.PullRequestChanged.Returns(new Subject()); + return result; + } + + static IPullRequestSessionFile CreateSessionFile( + params IInlineCommentThreadModel[] threads) + { + var result = Substitute.For(); + result.InlineCommentThreads.Returns(threads); + return result; + } + + static IInlineCommentThreadModel CreateInlineCommentThread( + params IPullRequestReviewCommentModel[] comments) + { + var result = Substitute.For(); + result.Comments.Returns(comments); + return result; + } + + static IPullRequestSessionManager CreateSessionManager( + IPullRequestSession session = null) + { + session = session ?? CreateSession(); + + var result = Substitute.For(); + result.GetSession(null).ReturnsForAnyArgs(session); + return result; + } + + static IModelService CreateModelService(IPullRequestModel pullRequest = null) + { + pullRequest = pullRequest ?? CreatePullRequest(); + + var result = Substitute.For(); + result.GetPullRequest(null, null, 0).ReturnsForAnyArgs(Observable.Return(pullRequest)); + return result; + } + + static IModelServiceFactory CreateModelServiceFactory(IModelService service = null) + { + service = service ?? CreateModelService(); + + var result = Substitute.For(); + result.CreateAsync(null).ReturnsForAnyArgs(service); + return result; + } + + static ILocalRepositoryModel CreateLocalRepositoryModel() + { + var result = Substitute.For(); + result.Owner.Returns("owner"); + result.Name.Returns("repo"); + return result; + } + + static async Task Initialize( + IPullRequestReviewAuthoringViewModel target, + ILocalRepositoryModel localRepository = null) + { + localRepository = localRepository ?? CreateLocalRepositoryModel(); + + await target.InitializeAsync( + localRepository, + Substitute.For(), + "owner", + "repo", + 5); + } + + static void RaisePullRequestChanged(IPullRequestSession session, PullRequestModel newPullRequest) + { + ((ISubject)session.PullRequestChanged).OnNext(newPullRequest); + } + } +} 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/GitHub.Exports.Reactive/Services/PullRequestEditorServiceTests.cs b/test/UnitTests/GitHub.Exports.Reactive/Services/PullRequestEditorServiceTests.cs index acbc202559..51838e4f11 100644 --- a/test/UnitTests/GitHub.Exports.Reactive/Services/PullRequestEditorServiceTests.cs +++ b/test/UnitTests/GitHub.Exports.Reactive/Services/PullRequestEditorServiceTests.cs @@ -2,6 +2,7 @@ using GitHub.Services; using NUnit.Framework; using NSubstitute; +using Microsoft.VisualStudio.Editor; public class PullRequestEditorServiceTests { @@ -48,6 +49,15 @@ public void FindNearestMatchingLine(IList fromLines, IList toLin static PullRequestEditorService CreateNavigationService() { var sp = Substitute.For(); - return new PullRequestEditorService(sp); + var pullRequestService = Substitute.For(); + var vsEditorAdaptersFactory = Substitute.For(); + var statusBar = Substitute.For(); + var usageTracker = Substitute.For(); + return new PullRequestEditorService( + sp, + pullRequestService, + vsEditorAdaptersFactory, + statusBar, + usageTracker); } } diff --git a/test/UnitTests/Helpers/TestBaseClass.cs b/test/UnitTests/Helpers/TestBaseClass.cs index 8de707592b..406bfed43c 100644 --- a/test/UnitTests/Helpers/TestBaseClass.cs +++ b/test/UnitTests/Helpers/TestBaseClass.cs @@ -27,7 +27,7 @@ public virtual void OnExit() protected static User CreateOctokitUser(string login = "login", string url = "https://url") { return new User("https://url", "bio", "blog", 1, "GitHub", - DateTimeOffset.UtcNow, 0, "email", 100, 100, true, url, + DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 0, "email", 100, 100, true, url, 10, 42, "location", login, "name", 1, new Plan(), 1, 1, 1, "https://url", new RepositoryPermissions(true, true, true), false, null, null); @@ -46,7 +46,7 @@ protected static Repository CreateRepository(string owner, string name, string d id, CreateOctokitUser(owner), name, "fullname", "description", notCloneUrl, "c#", false, parent != null, 0, 0, "master", 0, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, - new RepositoryPermissions(), parent, null, true, false, false, false, 0, 0, null, null, null); + new RepositoryPermissions(), parent, null, null, true, false, false, false, 0, 0, null, null, null); } protected static PullRequest CreatePullRequest(User user, int id, ItemState state, string title, @@ -58,18 +58,18 @@ protected static PullRequest CreatePullRequest(User user, int id, ItemState stat 1, user, "Repo", "Repo", string.Empty, string.Empty, string.Empty, false, false, 0, 0, "master", 0, null, createdAt, updatedAt, - null, null, null, + null, null, null, null, false, false, false, false, 0, 0, null, null, null); - return new PullRequest(0, uri, uri, uri, uri, uri, uri, + return new PullRequest(0, uris, uris, uris, uris, uris, uris, id, state, title, "", createdAt, updatedAt, null, null, new GitReference(uri.ToString(), "foo:bar", "bar", "123", user, repo), new GitReference(uri.ToString(), "foo:baz", "baz", "123", user, repo), - user, null, null, false, null, - commentCount, reviewCommentCount, 0, 0, 0, 0, - null, false); + user, null, null, false, null, null, null, + commentCount, 0, 0, 0, 0, + null, false, null); } protected class TempDirectory : IDisposable diff --git a/test/UnitTests/UnitTests.csproj b/test/UnitTests/UnitTests.csproj index 3711a1ec52..10382e15a0 100644 --- a/test/UnitTests/UnitTests.csproj +++ b/test/UnitTests/UnitTests.csproj @@ -82,6 +82,10 @@ ..\..\packages\Microsoft.VisualStudio.CoreUtility.14.3.25407\lib\net45\Microsoft.VisualStudio.CoreUtility.dll True + + ..\..\packages\Microsoft.VisualStudio.Editor.14.3.25407\lib\net45\Microsoft.VisualStudio.Editor.dll + True + ..\..\packages\Microsoft.VisualStudio.Language.Intellisense.14.3.25407\lib\net45\Microsoft.VisualStudio.Language.Intellisense.dll True @@ -162,6 +166,10 @@ ..\..\packages\Microsoft.VisualStudio.TextManager.Interop.8.0.8.0.50727\lib\Microsoft.VisualStudio.TextManager.Interop.8.0.dll True + + ..\..\packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll + True + ..\..\packages\NSubstitute.2.0.3\lib\net45\NSubstitute.dll True @@ -169,6 +177,14 @@ ..\..\packages\NUnit.3.9.0\lib\net45\nunit.framework.dll + + ..\..\packages\Octokit.GraphQL.0.0.2-alpha\lib\netstandard1.1\Octokit.GraphQL.dll + True + + + ..\..\packages\Octokit.GraphQL.0.0.2-alpha\lib\netstandard1.1\Octokit.GraphQL.Core.dll + True + @@ -241,6 +257,10 @@ + + + + @@ -374,6 +394,9 @@ Designer + + PreserveNewest + diff --git a/test/UnitTests/UnitTests.dll.config b/test/UnitTests/UnitTests.dll.config new file mode 100644 index 0000000000..5fbc6df21b --- /dev/null +++ b/test/UnitTests/UnitTests.dll.config @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/UnitTests/packages.config b/test/UnitTests/packages.config index a80e0e8c78..87bdee5b8d 100644 --- a/test/UnitTests/packages.config +++ b/test/UnitTests/packages.config @@ -6,6 +6,7 @@ + @@ -25,10 +26,12 @@ + +