Merge branch 'master' into feature/inline-reviews

Conflicts:
	src/GitHub.App/ViewModels/PullRequestDetailViewModel.cs
	src/GitHub.VisualStudio/Properties/AssemblyInfo.cs
	src/GitHub.VisualStudio/source.extension.vsixmanifest
	src/MsiInstaller/Version.wxi
	src/common/SolutionInfo.cs
This commit is contained in:
Steven Kirk 2017-06-27 16:55:34 +02:00
Родитель d84ccd448a 1cccfe2077
Коммит 3097190685
87 изменённых файлов: 1882 добавлений и 704 удалений

Просмотреть файл

@ -1,5 +1,6 @@
<ProjectConfiguration>
<Settings>
<PreventSigningOfAssembly>True</PreventSigningOfAssembly>
<PreviouslyBuiltSuccessfully>True</PreviouslyBuiltSuccessfully>
</Settings>
</ProjectConfiguration>

Просмотреть файл

@ -1,5 +1,6 @@
<ProjectConfiguration>
<Settings>
<PreventSigningOfAssembly>True</PreventSigningOfAssembly>
<PreviouslyBuiltSuccessfully>True</PreviouslyBuiltSuccessfully>
</Settings>
</ProjectConfiguration>

Просмотреть файл

@ -1,5 +1,6 @@
<ProjectConfiguration>
<Settings>
<PreventSigningOfAssembly>True</PreventSigningOfAssembly>
<PreviouslyBuiltSuccessfully>True</PreviouslyBuiltSuccessfully>
</Settings>
</ProjectConfiguration>

Просмотреть файл

@ -1,5 +1,6 @@
<ProjectConfiguration>
<Settings>
<PreloadReferencedAssemblies>False</PreloadReferencedAssemblies>
<PreventSigningOfAssembly>True</PreventSigningOfAssembly>
<PreviouslyBuiltSuccessfully>True</PreviouslyBuiltSuccessfully>
</Settings>

Просмотреть файл

@ -41,7 +41,7 @@ Open the `GitHubVS.sln` solution with Visual Studio 2015.
To be able to use the GitHub API, you'll need to:
- [Register a new developer application](https://github.com/settings/developers) in your profile.
- Open [src/GitHub.App/Api/ApiClientConfiguration.cs](src/GitHub.App/Api/ApiClientConfiguration.cs) and fill out the clientId/clientSecret fields for your application.
- Open [src/GitHub.Api/ApiClientConfiguration_User.cs](src/GitHub.Api/ApiClientConfiguration_User.cs) and fill out the clientId/clientSecret fields for your application. **Note this has recently changed location, so you may need to re-do this**
Build using Visual Studio 2015 or:

Просмотреть файл

@ -14,7 +14,7 @@
<NuGetPackageImportStamp>
</NuGetPackageImportStamp>
<TargetFrameworkProfile />
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
<OutputPath>bin\$(Configuration)\</OutputPath>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">

Просмотреть файл

@ -0,0 +1,119 @@
using System;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using GitHub.Primitives;
namespace GitHub.Api
{
/// <summary>
/// Holds the configuration for API clients.
/// </summary>
public static partial class ApiClientConfiguration
{
/// <summary>
/// Initializes static members of the <see cref="ApiClientConfiguration"/> class.
/// </summary>
static ApiClientConfiguration()
{
Configure();
}
/// <summary>
/// Gets the application's OAUTH client ID.
/// </summary>
public static string ClientId { get; private set; }
/// <summary>
/// Gets the application's OAUTH client secret.
/// </summary>
public static string ClientSecret { get; private set; }
/// <summary>
/// Gets a note that will be stored with the OAUTH token.
/// </summary>
public static string AuthorizationNote
{
get { return Info.ApplicationInfo.ApplicationDescription + " on " + GetMachineNameSafe(); }
}
/// <summary>
/// Gets the machine fingerprint that will be registered with the OAUTH token, allowing
/// multiple authorizations to be created for a single user.
/// </summary>
public static string MachineFingerprint
{
get
{
return GetSha256Hash(Info.ApplicationInfo.ApplicationDescription + ":" + GetMachineIdentifier());
}
}
static partial void Configure();
static string GetMachineIdentifier()
{
try
{
// adapted from http://stackoverflow.com/a/1561067
var fastedValidNetworkInterface = NetworkInterface.GetAllNetworkInterfaces()
.OrderBy(nic => nic.Speed)
.Where(nic => nic.OperationalStatus == OperationalStatus.Up)
.Select(nic => nic.GetPhysicalAddress().ToString())
.FirstOrDefault(address => address.Length > 12);
return fastedValidNetworkInterface ?? GetMachineNameSafe();
}
catch (Exception)
{
//log.Info("Could not retrieve MAC address. Fallback to using machine name.", e);
return GetMachineNameSafe();
}
}
static string GetMachineNameSafe()
{
try
{
return Dns.GetHostName();
}
catch (Exception)
{
//log.Info("Failed to retrieve host name using `DNS.GetHostName`.", e);
try
{
return Environment.MachineName;
}
catch (Exception)
{
//log.Info("Failed to retrieve host name using `Environment.MachineName`.", ex);
return "(unknown)";
}
}
}
static string GetSha256Hash(string 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)
{
//log.Error("IMPOSSIBLE! Generating Sha256 hash caused an exception.", e);
return null;
}
}
}
}

Просмотреть файл

@ -1,11 +1,13 @@
using System;
namespace GitHub.Api
{
public partial class ApiClient : IApiClient
static partial class ApiClientConfiguration
{
const string clientId = "YOUR CLIENT ID HERE";
const string clientSecret = "YOUR CLIENT SECRET HERE";
partial void Configure()
static partial void Configure()
{
ClientId = clientId;
ClientSecret = clientSecret;

Просмотреть файл

@ -11,7 +11,7 @@
<AssemblyName>GitHub.Api</AssemblyName>
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
<OutputPath>bin\$(Configuration)\</OutputPath>
<TargetFrameworkProfile />
</PropertyGroup>
@ -55,7 +55,17 @@
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="ApiClientConfiguration.cs"/>
<Compile Include="..\..\script\src\ApiClientConfiguration_User.cs" Condition="$(Buildtype) == 'Internal'">
<Link>ApiClientConfiguration_User.cs</Link>
</Compile>
<Compile Include="ApiClientConfiguration_User.cs" Condition="$(Buildtype) != 'Internal'"/>
<Compile Include="ILoginCache.cs" />
<Compile Include="ILoginManager.cs" />
<Compile Include="ITwoFactorChallengeHandler.cs" />
<Compile Include="LoginManager.cs" />
<Compile Include="SimpleCredentialStore.cs" />
<Compile Include="WindowsLoginCache.cs" />
<None Include="..\..\script\Key.snk" Condition="$(Buildtype) == 'Internal'">
<Link>Key.snk</Link>
</None>

Просмотреть файл

@ -0,0 +1,37 @@
using System;
using System.Threading.Tasks;
using GitHub.Primitives;
namespace GitHub.Api
{
/// <summary>
/// Stores login details.
/// </summary>
public interface ILoginCache
{
/// <summary>
/// Gets the login details for the specified host address.
/// </summary>
/// <param name="hostAddress">The host address.</param>
/// <returns>
/// A task returning a tuple containing the retrieved username and password.
/// </returns>
Task<Tuple<string, string>> GetLogin(HostAddress hostAddress);
/// <summary>
/// Saves the login details for the specified host address.
/// </summary>
/// <param name="userName">The username.</param>
/// <param name="password">The password.</param>
/// <param name="hostAddress">The host address.</param>
/// <returns>A task tracking the operation.</returns>
Task SaveLogin(string userName, string password, HostAddress hostAddress);
/// <summary>
/// Removes the login details for the specified host address.
/// </summary>
/// <param name="hostAddress"></param>
/// <returns>A task tracking the operation.</returns>
Task EraseLogin(HostAddress hostAddress);
}
}

Просмотреть файл

@ -0,0 +1,45 @@
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using GitHub.Primitives;
using GitHub.VisualStudio;
using Octokit;
namespace GitHub.Api
{
/// <summary>
/// Provides services for logging into a GitHub server.
/// </summary>
[Guid(Guids.LoginManagerId)]
public interface ILoginManager
{
/// <summary>
/// Attempts to log into a GitHub server.
/// </summary>
/// <param name="hostAddress">The address of the server.</param>
/// <param name="client">An octokit client configured to access the server.</param>
/// <param name="userName">The username.</param>
/// <param name="password">The password.</param>
/// <returns>The logged in user.</returns>
/// <exception cref="AuthorizationException">
/// The login authorization failed.
/// </exception>
Task<User> Login(HostAddress hostAddress, IGitHubClient client, string userName, string password);
/// <summary>
/// Attempts to log into a GitHub server using existing credentials.
/// </summary>
/// <param name="hostAddress">The address of the server.</param>
/// <param name="client">An octokit client configured to access the server.</param>
/// <returns>The logged in user.</returns>
/// <exception cref="AuthorizationException">
/// The login authorization failed.
/// </exception>
Task<User> LoginFromCache(HostAddress hostAddress, IGitHubClient client);
/// <summary>
/// Logs out of GitHub server.
/// </summary>
/// <param name="hostAddress">The address of the server.</param>
Task Logout(HostAddress hostAddress, IGitHubClient client);
}
}

Просмотреть файл

@ -0,0 +1,32 @@
using System;
using System.Threading.Tasks;
using Octokit;
namespace GitHub.Api
{
/// <summary>
/// Interface for handling Two Factor Authentication challenges in a
/// <see cref="LoginManager"/> operation.
/// </summary>
public interface ITwoFactorChallengeHandler
{
/// <summary>
/// Called when the GitHub API responds to a login request with a 2FA challenge.
/// </summary>
/// <param name="exception">The 2FA exception that initiated the challenge.</param>
/// <returns>A task returning a <see cref="TwoFactorChallengeResult"/>.</returns>
Task<TwoFactorChallengeResult> HandleTwoFactorException(TwoFactorAuthorizationException exception);
/// <summary>
/// Called when an error occurs sending the 2FA challenge response.
/// </summary>
/// <param name="e">The exception that occurred.</param>
/// <remarks>
/// This method is called when, on sending the challenge response returned by
/// <see cref="HandleTwoFactorException(TwoFactorAuthorizationException)"/>, an exception of
/// a type other than <see cref="TwoFactorAuthorizationException"/> is thrown. This
/// indicates that the login attempt is over and any 2FA dialog being shown should close.
/// </remarks>
Task ChallengeFailed(Exception e);
}
}

Просмотреть файл

@ -0,0 +1,262 @@
using System;
using System.Net;
using System.Threading.Tasks;
using GitHub.Extensions;
using GitHub.Primitives;
using Octokit;
namespace GitHub.Api
{
/// <summary>
/// Provides services for logging into a GitHub server.
/// </summary>
public class LoginManager : ILoginManager
{
readonly string[] scopes = { "user", "repo", "gist", "write:public_key" };
readonly ILoginCache loginCache;
readonly ITwoFactorChallengeHandler twoFactorChallengeHandler;
readonly string clientId;
readonly string clientSecret;
readonly string authorizationNote;
readonly string fingerprint;
/// <summary>
/// Initializes a new instance of the <see cref="LoginManager"/> class.
/// </summary>
/// <param name="loginCache">The cache in which to store login details.</param>
/// <param name="twoFactorChallengeHandler">The handler for 2FA challenges.</param>
/// <param name="clientId">The application's client API ID.</param>
/// <param name="clientSecret">The application's client API secret.</param>
/// <param name="authorizationNote">An note to store with the authorization.</param>
/// <param name="fingerprint">The machine fingerprint.</param>
public LoginManager(
ILoginCache loginCache,
ITwoFactorChallengeHandler twoFactorChallengeHandler,
string clientId,
string clientSecret,
string authorizationNote = null,
string fingerprint = null)
{
Guard.ArgumentNotNull(loginCache, nameof(loginCache));
Guard.ArgumentNotNull(twoFactorChallengeHandler, nameof(twoFactorChallengeHandler));
Guard.ArgumentNotEmptyString(clientId, nameof(clientId));
Guard.ArgumentNotEmptyString(clientSecret, nameof(clientSecret));
this.loginCache = loginCache;
this.twoFactorChallengeHandler = twoFactorChallengeHandler;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.authorizationNote = authorizationNote;
this.fingerprint = fingerprint;
}
/// <inheritdoc/>
public async Task<User> Login(
HostAddress hostAddress,
IGitHubClient client,
string userName,
string password)
{
Guard.ArgumentNotNull(hostAddress, nameof(hostAddress));
Guard.ArgumentNotNull(client, nameof(client));
Guard.ArgumentNotEmptyString(userName, nameof(userName));
Guard.ArgumentNotEmptyString(password, nameof(password));
// Start by saving the username and password, these will be used by the `IGitHubClient`
// until an authorization token has been created and acquired:
await loginCache.SaveLogin(userName, password, hostAddress).ConfigureAwait(false);
var newAuth = new NewAuthorization
{
Scopes = scopes,
Note = authorizationNote,
Fingerprint = fingerprint,
};
ApplicationAuthorization auth = null;
do
{
try
{
auth = await CreateAndDeleteExistingApplicationAuthorization(client, newAuth, null)
.ConfigureAwait(false);
EnsureNonNullAuthorization(auth);
}
catch (TwoFactorAuthorizationException e)
{
auth = await HandleTwoFactorAuthorization(hostAddress, client, newAuth, e)
.ConfigureAwait(false);
}
catch (Exception e)
{
// Some enterpise instances don't support OAUTH, so fall back to using the
// supplied password - on intances that don't support OAUTH the user should
// be using a personal access token as the password.
if (EnterpriseWorkaround(hostAddress, e))
{
auth = new ApplicationAuthorization(password);
}
else
{
await loginCache.EraseLogin(hostAddress).ConfigureAwait(false);
throw;
}
}
} while (auth == null);
await loginCache.SaveLogin(userName, auth.Token, hostAddress).ConfigureAwait(false);
var retry = 0;
while (true)
{
try
{
return await client.User.Current().ConfigureAwait(false);
}
catch (AuthorizationException)
{
if (retry++ == 3) throw;
}
// It seems that attempting to use a token immediately sometimes fails, retry a few
// times with a delay of of 1s to allow the token to propagate.
await Task.Delay(1000);
}
}
/// <inheritdoc/>
public Task<User> LoginFromCache(HostAddress hostAddress, IGitHubClient client)
{
Guard.ArgumentNotNull(hostAddress, nameof(hostAddress));
Guard.ArgumentNotNull(client, nameof(client));
return client.User.Current();
}
/// <inheritdoc/>
public async Task Logout(HostAddress hostAddress, IGitHubClient client)
{
Guard.ArgumentNotNull(hostAddress, nameof(hostAddress));
Guard.ArgumentNotNull(client, nameof(client));
await loginCache.EraseLogin(hostAddress);
}
async Task<ApplicationAuthorization> CreateAndDeleteExistingApplicationAuthorization(
IGitHubClient client,
NewAuthorization newAuth,
string twoFactorAuthenticationCode)
{
ApplicationAuthorization result;
var retry = 0;
do
{
if (twoFactorAuthenticationCode == null)
{
result = await client.Authorization.GetOrCreateApplicationAuthentication(
clientId,
clientSecret,
newAuth).ConfigureAwait(false);
}
else
{
result = await client.Authorization.GetOrCreateApplicationAuthentication(
clientId,
clientSecret,
newAuth,
twoFactorAuthenticationCode).ConfigureAwait(false);
}
if (result.Token == string.Empty)
{
if (twoFactorAuthenticationCode == null)
{
await client.Authorization.Delete(result.Id);
}
else
{
await client.Authorization.Delete(result.Id, twoFactorAuthenticationCode);
}
}
} while (result.Token == string.Empty && retry++ == 0);
return result;
}
async Task<ApplicationAuthorization> HandleTwoFactorAuthorization(
HostAddress hostAddress,
IGitHubClient client,
NewAuthorization newAuth,
TwoFactorAuthorizationException exception)
{
for (;;)
{
var challengeResult = await twoFactorChallengeHandler.HandleTwoFactorException(exception);
if (challengeResult == null)
{
throw new InvalidOperationException(
"ITwoFactorChallengeHandler.HandleTwoFactorException returned null.");
}
if (!challengeResult.ResendCodeRequested)
{
try
{
var auth = await CreateAndDeleteExistingApplicationAuthorization(
client,
newAuth,
challengeResult.AuthenticationCode).ConfigureAwait(false);
return EnsureNonNullAuthorization(auth);
}
catch (TwoFactorAuthorizationException e)
{
exception = e;
}
catch (Exception e)
{
await twoFactorChallengeHandler.ChallengeFailed(e);
await loginCache.EraseLogin(hostAddress).ConfigureAwait(false);
throw;
}
}
else
{
return null;
}
}
}
ApplicationAuthorization EnsureNonNullAuthorization(ApplicationAuthorization auth)
{
// If a mock IGitHubClient is not set up correctly, it can return null from
// IGutHubClient.Authorization.Create - this will cause an infinite loop in Login()
// so prevent that.
if (auth == null)
{
throw new InvalidOperationException("IGutHubClient.Authorization.Create returned null.");
}
return auth;
}
bool EnterpriseWorkaround(HostAddress hostAddress, Exception e)
{
// Older Enterprise hosts either don't have the API end-point to PUT an authorization, or they
// return 422 because they haven't white-listed our client ID. In that case, we just ignore
// the failure, using basic authentication (with username and password) instead of trying
// to get an authorization token.
// Since enterprise 2.1 and https://github.com/github/github/pull/36669 the API returns 403
// instead of 404 to signal that it's not allowed. In the name of backwards compatibility we
// test for both 404 (NotFoundException) and 403 (ForbiddenException) here.
var apiException = e as ApiException;
return !hostAddress.IsGitHubDotCom() &&
(e is NotFoundException ||
e is ForbiddenException ||
apiException?.StatusCode == (HttpStatusCode)422);
}
}
}

Просмотреть файл

@ -6,6 +6,7 @@ using GitHub.Primitives;
using GitHub.Services;
using Octokit;
using System.Collections.Concurrent;
using System.Threading.Tasks;
namespace GitHub.Api
{
@ -27,12 +28,13 @@ namespace GitHub.Api
lazyWikiProbe = wikiProbe;
}
public ISimpleApiClient Create(UriString repositoryUrl)
public Task<ISimpleApiClient> Create(UriString repositoryUrl)
{
var hostAddress = HostAddress.Create(repositoryUrl);
return cache.GetOrAdd(repositoryUrl, new SimpleApiClient(repositoryUrl,
var result = cache.GetOrAdd(repositoryUrl, new SimpleApiClient(repositoryUrl,
new GitHubClient(productHeader, new SimpleCredentialStore(hostAddress), hostAddress.ApiUri),
lazyEnterpriseProbe, lazyWikiProbe));
return Task.FromResult(result);
}
public void ClearFromCache(ISimpleApiClient client)

Просмотреть файл

@ -0,0 +1,86 @@
using System;
using System.ComponentModel.Composition;
using System.Threading.Tasks;
using GitHub.Authentication.CredentialManagement;
using GitHub.Extensions;
using GitHub.Primitives;
namespace GitHub.Api
{
/// <summary>
/// A login cache that stores logins in the windows credential cache.
/// </summary>
[Export(typeof(ILoginCache))]
[PartCreationPolicy(CreationPolicy.Shared)]
public class WindowsLoginCache : ILoginCache
{
/// <inheritdoc/>
public Task<Tuple<string, string>> GetLogin(HostAddress hostAddress)
{
Guard.ArgumentNotNull(hostAddress, nameof(hostAddress));
var keyHost = GetKeyHost(hostAddress.CredentialCacheKeyHost);
using (var credential = new Credential())
{
credential.Target = keyHost;
credential.Type = CredentialType.Generic;
if (credential.Load())
return Task.FromResult(Tuple.Create(credential.Username, credential.Password));
}
return Task.FromResult(Tuple.Create<string, string>(null, null));
}
/// <inheritdoc/>
public Task SaveLogin(string userName, string password, HostAddress hostAddress)
{
Guard.ArgumentNotEmptyString(userName, nameof(userName));
Guard.ArgumentNotEmptyString(password, nameof(password));
Guard.ArgumentNotNull(hostAddress, nameof(hostAddress));
var keyHost = GetKeyHost(hostAddress.CredentialCacheKeyHost);
using (var credential = new Credential(userName, password, keyHost))
{
credential.Save();
}
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task EraseLogin(HostAddress hostAddress)
{
Guard.ArgumentNotNull(hostAddress, nameof(hostAddress));
var keyHost = GetKeyHost(hostAddress.CredentialCacheKeyHost);
using (var credential = new Credential())
{
credential.Target = keyHost;
credential.Type = CredentialType.Generic;
credential.Delete();
}
return Task.CompletedTask;
}
static string GetKeyHost(string key)
{
key = FormatKey(key);
if (key.StartsWith("git:", StringComparison.Ordinal))
key = key.Substring("git:".Length);
if (!key.EndsWith("/", StringComparison.Ordinal))
key += '/';
return key;
}
static string FormatKey(string key)
{
if (key.StartsWith("login:", StringComparison.Ordinal))
key = key.Substring("login:".Length);
return key;
}
}
}

Просмотреть файл

@ -41,15 +41,18 @@ namespace GitHub.Api
string ClientId { get; set; }
string ClientSecret { get; set; }
public ApiClient(HostAddress hostAddress, IObservableGitHubClient gitHubClient)
public ApiClient([AllowNull] HostAddress hostAddress, [AllowNull] IObservableGitHubClient gitHubClient)
{
Configure();
ClientId = ApiClientConfiguration.ClientId;
ClientSecret = ApiClientConfiguration.ClientSecret;
HostAddress = hostAddress;
this.gitHubClient = gitHubClient;
}
partial void Configure();
public IGitHubClient GitHubClient => new GitHubClient(gitHubClient.Connection);
public IObservable<Repository> CreateRepository(NewRepository repository, string login, bool isUser)
{
Guard.ArgumentNotEmptyString(login, nameof(login));

Просмотреть файл

@ -5,12 +5,16 @@ using GitHub.ViewModels;
using Octokit;
using ReactiveUI;
using NullGuard;
using System.Threading.Tasks;
using GitHub.Api;
using GitHub.Helpers;
namespace GitHub.Authentication
{
[Export(typeof(ITwoFactorChallengeHandler))]
[Export(typeof(IDelegatingTwoFactorChallengeHandler))]
[PartCreationPolicy(CreationPolicy.Shared)]
public class TwoFactorChallengeHandler : ReactiveObject, ITwoFactorChallengeHandler
public class TwoFactorChallengeHandler : ReactiveObject, IDelegatingTwoFactorChallengeHandler
{
ITwoFactorDialogViewModel twoFactorDialog;
[AllowNull]
@ -26,15 +30,27 @@ namespace GitHub.Authentication
CurrentViewModel = vm;
}
public IObservable<TwoFactorChallengeResult> HandleTwoFactorException(TwoFactorAuthorizationException exception)
public async Task<TwoFactorChallengeResult> HandleTwoFactorException(TwoFactorAuthorizationException exception)
{
await ThreadingHelper.SwitchToMainThreadAsync();
var userError = new TwoFactorRequiredUserError(exception);
return twoFactorDialog.Show(userError)
.ObserveOn(RxApp.MainThreadScheduler)
.SelectMany(x =>
x == RecoveryOptionResult.RetryOperation
? Observable.Return(userError.ChallengeResult)
: Observable.Throw<TwoFactorChallengeResult>(exception));
var result = await twoFactorDialog.Show(userError);
if (result != null)
{
return result;
}
else
{
throw exception;
}
}
public async Task ChallengeFailed(Exception exception)
{
await ThreadingHelper.SwitchToMainThreadAsync();
await twoFactorDialog.Cancel.ExecuteAsync(null);
}
}
}

Просмотреть файл

@ -18,14 +18,6 @@ namespace GitHub.Authentication
public TwoFactorType TwoFactorType { get; private set; }
[AllowNull]
public TwoFactorChallengeResult ChallengeResult
{
[return: AllowNull]
get;
set;
}
public IObservable<RecoveryOptionResult> Throw()
{
return Throw(this);

Просмотреть файл

@ -8,6 +8,8 @@ using Octokit;
using Octokit.Reactive;
using ApiClient = GitHub.Api.ApiClient;
using GitHub.Infrastructure;
using System.Threading.Tasks;
using ILoginCache = GitHub.Caches.ILoginCache;
namespace GitHub.Factories
{
@ -25,13 +27,19 @@ namespace GitHub.Factories
config.Configure();
}
public IApiClient Create(HostAddress hostAddress)
public Task<IGitHubClient> CreateGitHubClient(HostAddress hostAddress)
{
var apiBaseUri = hostAddress.ApiUri;
return Task.FromResult<IGitHubClient>(new GitHubClient(
productHeader,
new GitHubCredentialStore(hostAddress, LoginCache),
hostAddress.ApiUri));
}
public async Task<IApiClient> Create(HostAddress hostAddress)
{
return new ApiClient(
hostAddress,
new ObservableGitHubClient(new GitHubClient(productHeader, new GitHubCredentialStore(hostAddress, LoginCache), apiBaseUri)));
new ObservableGitHubClient(await CreateGitHubClient(hostAddress)));
}
protected ILoginCache LoginCache { get; private set; }

Просмотреть файл

@ -6,6 +6,9 @@ using GitHub.Models;
using GitHub.Primitives;
using GitHub.Services;
using System.Reactive.Disposables;
using System.Threading.Tasks;
using GitHub.Api;
using ILoginCache = GitHub.Caches.ILoginCache;
namespace GitHub.Factories
{
@ -15,9 +18,9 @@ namespace GitHub.Factories
{
readonly IApiClientFactory apiClientFactory;
readonly IHostCacheFactory hostCacheFactory;
readonly ILoginManager loginManager;
readonly ILoginCache loginCache;
readonly IAvatarProvider avatarProvider;
readonly ITwoFactorChallengeHandler twoFactorChallengeHandler;
readonly CompositeDisposable hosts = new CompositeDisposable();
readonly IUsageTracker usage;
@ -25,25 +28,25 @@ namespace GitHub.Factories
public RepositoryHostFactory(
IApiClientFactory apiClientFactory,
IHostCacheFactory hostCacheFactory,
ILoginManager loginManager,
ILoginCache loginCache,
IAvatarProvider avatarProvider,
ITwoFactorChallengeHandler twoFactorChallengeHandler,
IUsageTracker usage)
{
this.apiClientFactory = apiClientFactory;
this.hostCacheFactory = hostCacheFactory;
this.loginManager = loginManager;
this.loginCache = loginCache;
this.avatarProvider = avatarProvider;
this.twoFactorChallengeHandler = twoFactorChallengeHandler;
this.usage = usage;
}
public IRepositoryHost Create(HostAddress hostAddress)
public async Task<IRepositoryHost> Create(HostAddress hostAddress)
{
var apiClient = apiClientFactory.Create(hostAddress);
var apiClient = await apiClientFactory.Create(hostAddress);
var hostCache = hostCacheFactory.Create(hostAddress);
var modelService = new ModelService(apiClient, hostCache, avatarProvider);
var host = new RepositoryHost(apiClient, modelService, loginCache, twoFactorChallengeHandler, usage);
var host = new RepositoryHost(apiClient, modelService, loginManager, loginCache, usage);
hosts.Add(host);
return host;
}

Просмотреть файл

@ -19,7 +19,7 @@
<RunCodeAnalysis>true</RunCodeAnalysis>
<CodeAnalysisRuleSet>..\common\GitHubVS.ruleset</CodeAnalysisRuleSet>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
<OutputPath>bin\$(Configuration)\</OutputPath>
<TargetFrameworkProfile />
</PropertyGroup>
@ -177,10 +177,6 @@
<Compile Include="..\common\SolutionInfo.cs">
<Link>Properties\SolutionInfo.cs</Link>
</Compile>
<Compile Include="..\..\script\src\ApiClientConfiguration.cs" Condition="$(Buildtype) == 'Internal'">
<Link>Api\ApiClientConfiguration.cs</Link>
</Compile>
<Compile Include="Api\ApiClientConfiguration.cs" Condition="$(Buildtype) != 'Internal'" />
</ItemGroup>
<ItemGroup>
<None Include="AkavacheSqliteLinkerOverride.cs" />
@ -286,6 +282,10 @@
<Project>{41a47c5b-c606-45b4-b83c-22b9239e4da0}</Project>
<Name>CredentialManagement</Name>
</ProjectReference>
<ProjectReference Include="..\GitHub.Api\GitHub.Api.csproj">
<Project>{B389ADAF-62CC-486E-85B4-2D8B078DF763}</Project>
<Name>GitHub.Api</Name>
</ProjectReference>
<ProjectReference Include="..\GitHub.Exports.Reactive\GitHub.Exports.Reactive.csproj">
<Project>{e4ed0537-d1d9-44b6-9212-3096d7c3f7a1}</Project>
<Name>GitHub.Exports.Reactive</Name>

Просмотреть файл

@ -18,6 +18,8 @@ using System.Linq;
using System.Reactive.Threading.Tasks;
using System.Collections.Generic;
using GitHub.Extensions;
using ILoginCache = GitHub.Caches.ILoginCache;
using System.Threading.Tasks;
namespace GitHub.Models
{
@ -27,31 +29,29 @@ namespace GitHub.Models
static readonly Logger log = LogManager.GetCurrentClassLogger();
static readonly UserAndScopes unverifiedUser = new UserAndScopes(null, null);
readonly ITwoFactorChallengeHandler twoFactorChallengeHandler;
readonly ILoginManager loginManager;
readonly HostAddress hostAddress;
readonly ILoginCache loginCache;
readonly IUsageTracker usage;
bool isLoggedIn;
readonly bool isEnterprise;
public RepositoryHost(
IApiClient apiClient,
IModelService modelService,
ILoginManager loginManager,
ILoginCache loginCache,
ITwoFactorChallengeHandler twoFactorChallengeHandler,
IUsageTracker usage)
{
ApiClient = apiClient;
ModelService = modelService;
this.loginManager = loginManager;
this.loginCache = loginCache;
this.twoFactorChallengeHandler = twoFactorChallengeHandler;
this.usage = usage;
Debug.Assert(apiClient.HostAddress != null, "HostAddress of an api client shouldn't be null");
Address = apiClient.HostAddress;
hostAddress = apiClient.HostAddress;
isEnterprise = !hostAddress.IsGitHubDotCom();
Title = apiClient.HostAddress.Title;
}
@ -72,18 +72,32 @@ namespace GitHub.Models
[SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope")]
public IObservable<AuthenticationResult> LogInFromCache()
{
return GetUserFromApi()
.ObserveOn(RxApp.MainThreadScheduler)
.Catch<UserAndScopes, Exception>(ex =>
Func<Task<AuthenticationResult>> f = async () =>
{
try
{
if (ex is AuthorizationException)
var user = await loginManager.LoginFromCache(Address, ApiClient.GitHubClient);
var accountCacheItem = new AccountCacheItem(user);
usage.IncrementLoginCount().Forget();
await ModelService.InsertUser(accountCacheItem);
if (user != unverifiedUser.User)
{
log.Warn("Got an authorization exception", ex);
IsLoggedIn = true;
return AuthenticationResult.Success;
}
return Observable.Return<UserAndScopes>(null);
})
.SelectMany(LoginWithApiUser)
.PublishAsync();
else
{
return AuthenticationResult.VerificationFailure;
}
}
catch (AuthorizationException)
{
return AuthenticationResult.CredentialFailure;
}
};
return f().ToObservable();
}
public IObservable<AuthenticationResult> LogIn(string usernameOrEmail, string password)
@ -91,114 +105,23 @@ namespace GitHub.Models
Guard.ArgumentNotEmptyString(usernameOrEmail, nameof(usernameOrEmail));
Guard.ArgumentNotEmptyString(password, nameof(password));
// If we need to retry on fallback, we'll store the 2FA token
// from the first request to re-use:
string authenticationCode = null;
// We need to intercept the 2FA handler to get the token:
var interceptingTwoFactorChallengeHandler =
new Func<TwoFactorAuthorizationException, IObservable<TwoFactorChallengeResult>>(ex =>
twoFactorChallengeHandler.HandleTwoFactorException(ex)
.Do(twoFactorChallengeResult =>
authenticationCode = twoFactorChallengeResult.AuthenticationCode));
// Keep the function to save the authorization token here because it's used
// in multiple places in the chain below:
var saveAuthorizationToken = new Func<ApplicationAuthorization, IObservable<Unit>>(authorization =>
return Observable.Defer(async () =>
{
var token = authorization?.Token;
if (string.IsNullOrWhiteSpace(token))
return Observable.Return(Unit.Default);
var user = await loginManager.Login(Address, ApiClient.GitHubClient, usernameOrEmail, password);
var accountCacheItem = new AccountCacheItem(user);
usage.IncrementLoginCount().Forget();
await ModelService.InsertUser(accountCacheItem);
return loginCache.SaveLogin(usernameOrEmail, token, Address)
.ObserveOn(RxApp.MainThreadScheduler);
if (user != unverifiedUser.User)
{
IsLoggedIn = true;
return Observable.Return(AuthenticationResult.Success);
}
else
{
return Observable.Return(AuthenticationResult.VerificationFailure);
}
});
// Start be saving the username and password, as they will be used for older versions of Enterprise
// that don't support authorization tokens, and for the API client to use until an authorization
// token has been created and acquired:
return loginCache.SaveLogin(usernameOrEmail, password, Address)
.ObserveOn(RxApp.MainThreadScheduler)
// Try to get an authorization token, save it, then get the user to log in:
.SelectMany(fingerprint => ApiClient.GetOrCreateApplicationAuthenticationCode(interceptingTwoFactorChallengeHandler))
.SelectMany(saveAuthorizationToken)
.SelectMany(_ => GetUserFromApi())
.Catch<UserAndScopes, ApiException>(firstTryEx =>
{
var exception = firstTryEx as AuthorizationException;
if (isEnterprise
&& exception != null
&& exception.Message == "Bad credentials")
{
return Observable.Throw<UserAndScopes>(exception);
}
// If the Enterprise host doesn't support the write:public_key scope, it'll return a 422.
// EXCEPT, there's a bug where it doesn't, and instead creates a bad token, and in
// that case we'd get a 401 here from the GetUser invocation. So to be safe (and consistent
// with the Mac app), we'll just retry after any API error for Enterprise hosts:
if (isEnterprise && !(firstTryEx is TwoFactorChallengeFailedException))
{
// Because we potentially have a bad authorization token due to the Enterprise bug,
// we need to reset to using username and password authentication:
return loginCache.SaveLogin(usernameOrEmail, password, Address)
.ObserveOn(RxApp.MainThreadScheduler)
.SelectMany(_ =>
{
// Retry with the old scopes. If we have a stashed 2FA token, we use it:
if (authenticationCode != null)
{
return ApiClient.GetOrCreateApplicationAuthenticationCode(
interceptingTwoFactorChallengeHandler,
authenticationCode,
useOldScopes: true,
useFingerprint: false);
}
// Otherwise, we use the default handler:
return ApiClient.GetOrCreateApplicationAuthenticationCode(
interceptingTwoFactorChallengeHandler,
useOldScopes: true,
useFingerprint: false);
})
// Then save the authorization token (if there is one) and get the user:
.SelectMany(saveAuthorizationToken)
.SelectMany(_ => GetUserFromApi());
}
return Observable.Throw<UserAndScopes>(firstTryEx);
})
.Catch<UserAndScopes, ApiException>(retryEx =>
{
// Older Enterprise hosts either don't have the API end-point to PUT an authorization, or they
// return 422 because they haven't white-listed our client ID. In that case, we just ignore
// the failure, using basic authentication (with username and password) instead of trying
// to get an authorization token.
// Since enterprise 2.1 and https://github.com/github/github/pull/36669 the API returns 403
// instead of 404 to signal that it's not allowed. In the name of backwards compatibility we
// test for both 404 (NotFoundException) and 403 (ForbiddenException) here.
if (isEnterprise && (retryEx is NotFoundException || retryEx is ForbiddenException || retryEx.StatusCode == (HttpStatusCode)422))
return GetUserFromApi();
// Other errors are "real" so we pass them along:
return Observable.Throw<UserAndScopes>(retryEx);
})
.ObserveOn(RxApp.MainThreadScheduler)
.Catch<UserAndScopes, Exception>(ex =>
{
// If we get here, we have an actual login failure:
if (ex is TwoFactorChallengeFailedException)
{
return Observable.Return(unverifiedUser);
}
if (ex is AuthorizationException)
{
return Observable.Return(default(UserAndScopes));
}
return Observable.Throw<UserAndScopes>(ex);
})
.SelectMany(LoginWithApiUser)
.PublishAsync();
}
public IObservable<Unit> LogOut()

Просмотреть файл

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.Composition;
using System.Globalization;
using System.Linq;
@ -10,9 +9,9 @@ using System.Reactive.Subjects;
using Akavache;
using GitHub.Authentication;
using GitHub.Caches;
using GitHub.Extensions.Reactive;
using GitHub.Factories;
using GitHub.Primitives;
using GitHub.Services;
using NullGuard;
using ReactiveUI;
@ -81,11 +80,11 @@ namespace GitHub.Models
disposables.Add(
connectionManager.Connections.CreateDerivedCollection(x => x)
.ItemsRemoved
.Select(x =>
.SelectMany(async x =>
{
var host = LookupHost(x.HostAddress);
if (host.Address != x.HostAddress)
host = RepositoryHostFactory.Create(x.HostAddress);
host = await RepositoryHostFactory.Create(x.HostAddress);
return host;
})
.Select(h => LogOut(h))
@ -128,36 +127,42 @@ namespace GitHub.Models
string password)
{
var isDotCom = HostAddress.GitHubDotComHostAddress == address;
var host = RepositoryHostFactory.Create(address);
return host.LogIn(usernameOrEmail, password)
.Catch<AuthenticationResult, Exception>(Observable.Throw<AuthenticationResult>)
.Do(result =>
{
bool successful = result.IsSuccess();
log.Info(CultureInfo.InvariantCulture, "Log in to {3} host '{0}' with username '{1}' {2}",
address.ApiUri,
usernameOrEmail,
successful ? "SUCCEEDED" : "FAILED",
isDotCom ? "GitHub.com" : address.WebUri.Host
);
if (successful)
return Observable.Defer(async () =>
{
var host = await RepositoryHostFactory.Create(address);
return host.LogIn(usernameOrEmail, password)
.Catch<AuthenticationResult, Exception>(Observable.Throw<AuthenticationResult>)
.ObserveOn(RxApp.MainThreadScheduler)
.Do(result =>
{
// Make sure that GitHubHost/EnterpriseHost are set when the connections
// changed event is raised and likewise that the connection is added when
// the property changed notification is sent.
if (isDotCom)
githubHost = host;
else
enterpriseHost = host;
bool successful = result.IsSuccess();
log.Info(CultureInfo.InvariantCulture, "Log in to {3} host '{0}' with username '{1}' {2}",
address.ApiUri,
usernameOrEmail,
successful ? "SUCCEEDED" : "FAILED",
isDotCom ? "GitHub.com" : address.WebUri.Host
);
if (successful)
{
// Make sure that GitHubHost/EnterpriseHost are set when the connections
// changed event is raised and likewise that the connection is added when
// the property changed notification is sent.
if (isDotCom)
githubHost = host;
else
enterpriseHost = host;
connectionManager.AddConnection(address, usernameOrEmail);
connectionManager.AddConnection(address, usernameOrEmail);
if (isDotCom)
this.RaisePropertyChanged(nameof(GitHubHost));
else
this.RaisePropertyChanged(nameof(EnterpriseHost));
}
});
if (isDotCom)
this.RaisePropertyChanged(nameof(GitHubHost));
else
this.RaisePropertyChanged(nameof(EnterpriseHost));
}
});
});
}
/// <summary>
@ -169,20 +174,25 @@ namespace GitHub.Models
public IObservable<AuthenticationResult> LogInFromCache(HostAddress address)
{
var isDotCom = HostAddress.GitHubDotComHostAddress == address;
var host = RepositoryHostFactory.Create(address);
return host.LogInFromCache()
.Catch<AuthenticationResult, Exception>(Observable.Throw<AuthenticationResult>)
.Do(result =>
{
bool successful = result.IsSuccess();
if (successful)
return Observable.Defer(async () =>
{
var host = await RepositoryHostFactory.Create(address);
return host.LogInFromCache()
.Catch<AuthenticationResult, Exception>(Observable.Throw<AuthenticationResult>)
.ObserveOn(RxApp.MainThreadScheduler)
.Do(result =>
{
if (isDotCom)
GitHubHost = host;
else
EnterpriseHost = host;
}
});
bool successful = result.IsSuccess();
if (successful)
{
if (isDotCom)
GitHubHost = host;
else
EnterpriseHost = host;
}
});
});
}
public IObservable<Unit> LogOut(IRepositoryHost host)
@ -190,6 +200,7 @@ namespace GitHub.Models
var address = host.Address;
var isDotCom = HostAddress.GitHubDotComHostAddress == address;
return host.LogOut()
.ObserveOn(RxApp.MainThreadScheduler)
.Do(result =>
{
// reset the logged out host property to null

Просмотреть файл

@ -47,13 +47,14 @@ namespace GitHub.ViewModels
if (ex.IsCriticalException()) return;
log.Info(string.Format(CultureInfo.InvariantCulture, "Error logging into '{0}' as '{1}'", BaseUri, UsernameOrEmail), ex);
if (ex is Octokit.ForbiddenException)
{
UserError.Throw(new UserError(Resources.LoginFailedForbiddenMessage));
Error = new UserError(Resources.LoginFailedForbiddenMessage, ex.Message);
}
else
{
UserError.Throw(new UserError(ex.Message));
Error = new UserError(ex.Message);
}
});
@ -127,6 +128,15 @@ namespace GitHub.ViewModels
get { return canLogin.Value; }
}
UserError error;
[AllowNull]
public UserError Error
{
[return: AllowNull]
get { return error; }
set { this.RaiseAndSetIfChanged(ref error, value); }
}
protected abstract IObservable<AuthenticationResult> LogIn(object args);
protected IObservable<AuthenticationResult> LogInToHost(HostAddress hostAddress)
@ -142,15 +152,15 @@ namespace GitHub.ViewModels
switch (authResult)
{
case AuthenticationResult.CredentialFailure:
UserError.Throw(new UserError(
Error = new UserError(
Resources.LoginFailedText,
Resources.LoginFailedMessage,
new[] { NavigateForgotPassword }));
new[] { NavigateForgotPassword });
break;
case AuthenticationResult.VerificationFailure:
break;
case AuthenticationResult.EnterpriseServerNotFound:
UserError.Throw(new UserError(Resources.CouldNotConnectToGitHub));
Error = new UserError(Resources.CouldNotConnectToGitHub);
break;
}
})

Просмотреть файл

@ -312,6 +312,7 @@ namespace GitHub.ViewModels
/// <param name="files">The pull request's changed files.</param>
public async Task Load(IPullRequestModel pullRequest)
{
var firstLoad = (Model == null);
Model = pullRequest;
Session = await sessionManager.GetSession(pullRequest);
Title = Resources.PullRequestNavigationItemText + " #" + pullRequest.Number;
@ -391,6 +392,11 @@ namespace GitHub.ViewModels
IsLoading = IsBusy = false;
if (firstLoad)
{
usageTracker.IncrementPullRequestOpened().Forget();
}
if (!isInCheckout)
{
pullRequestsService.RemoveUnusedRemotes(Repository).Subscribe(_ => { });

Просмотреть файл

@ -31,7 +31,7 @@ namespace GitHub.ViewModels
[ImportingConstructor]
public TwoFactorDialogViewModel(
IVisualStudioBrowser browser,
ITwoFactorChallengeHandler twoFactorChallengeHandler)
IDelegatingTwoFactorChallengeHandler twoFactorChallengeHandler)
{
Title = Resources.TwoFactorTitle;
twoFactorChallengeHandler.SetViewModel(this);
@ -42,6 +42,7 @@ namespace GitHub.ViewModels
(code, busy) => !string.IsNullOrEmpty(code.Value) && code.Value.Length == 6 && !busy.Value);
OkCommand = ReactiveCommand.Create(canVerify);
Cancel.Subscribe(_ => TwoFactorType = TwoFactorType.None);
NavigateLearnMore = ReactiveCommand.Create();
NavigateLearnMore.Subscribe(x => browser.OpenUrl(GitHubUrls.TwoFactorLearnMore));
//TODO: ShowHelpCommand.Subscribe(x => browser.OpenUrl(twoFactorHelpUri));
@ -80,28 +81,25 @@ namespace GitHub.ViewModels
.ToProperty(this, x => x.IsSms);
}
public IObservable<RecoveryOptionResult> Show(UserError userError)
public IObservable<TwoFactorChallengeResult> Show(UserError userError)
{
IsBusy = false;
var error = userError as TwoFactorRequiredUserError;
Debug.Assert(error != null,
String.Format(CultureInfo.InvariantCulture, "The user error is '{0}' not a TwoFactorRequiredUserError", userError));
InvalidAuthenticationCode = error.RetryFailed;
IsAuthenticationCodeSent = false;
TwoFactorType = error.TwoFactorType;
var ok = OkCommand
.Do(_ => IsBusy = true)
.Select(_ => AuthenticationCode == null
? RecoveryOptionResult.CancelOperation
: RecoveryOptionResult.RetryOperation)
.Do(_ => error.ChallengeResult = AuthenticationCode != null
? new TwoFactorChallengeResult(AuthenticationCode)
: null);
? null
: new TwoFactorChallengeResult(AuthenticationCode));
var resend = ResendCodeCommand.Select(_ => RecoveryOptionResult.RetryOperation)
.Do(_ => error.ChallengeResult = TwoFactorChallengeResult.RequestResendCode);
var cancel = Cancel.Select(_ => RecoveryOptionResult.CancelOperation);
return Observable.Merge(ok, cancel, resend)
.Take(1)
.Do(_ => IsAuthenticationCodeSent = error.ChallengeResult == TwoFactorChallengeResult.RequestResendCode);
.Select(_ => TwoFactorChallengeResult.RequestResendCode)
.Do(_ => IsAuthenticationCodeSent = true);
var cancel = Cancel.Select(_ => default(TwoFactorChallengeResult));
return Observable.Merge(ok, cancel, resend).Take(1);
}
public TwoFactorType TwoFactorType

Просмотреть файл

@ -9,6 +9,10 @@ namespace GitHub.Api
public interface IApiClient
{
HostAddress HostAddress { get; }
// HACK: This is temporary. Should be removed in login refactor timeframe.
IGitHubClient GitHubClient { get; }
IObservable<Repository> CreateRepository(NewRepository repository, string login, bool isUser);
IObservable<Gist> CreateGist(NewGist newGist);
IObservable<UserAndScopes> GetUser();

Просмотреть файл

@ -1,13 +1,13 @@
using System;
using Octokit;
using GitHub.ViewModels;
using GitHub.Api;
namespace GitHub.Authentication
{
public interface ITwoFactorChallengeHandler
public interface IDelegatingTwoFactorChallengeHandler : ITwoFactorChallengeHandler
{
void SetViewModel(IViewModel vm);
IViewModel CurrentViewModel { get; }
IObservable<TwoFactorChallengeResult> HandleTwoFactorException(TwoFactorAuthorizationException exception);
}
}

Просмотреть файл

@ -1,9 +1,9 @@
using System.Reactive.Linq;
using GitHub.Models;
using System;
using ReactiveUI;
using GitHub.Primitives;
using System;
using System.Linq;
using System.Reactive.Linq;
using GitHub.Models;
using GitHub.Primitives;
using GitHub.Services;
namespace GitHub.Extensions
{

Просмотреть файл

@ -1,11 +1,15 @@
using GitHub.Api;
using GitHub.Primitives;
using System;
using System.Threading.Tasks;
using Octokit;
namespace GitHub.Factories
{
public interface IApiClientFactory
{
IApiClient Create(HostAddress hostAddress);
Task<IGitHubClient> CreateGitHubClient(HostAddress hostAddress);
Task<IApiClient> Create(HostAddress hostAddress);
}
}

Просмотреть файл

@ -1,4 +1,5 @@
using System;
using System.Threading.Tasks;
using GitHub.Models;
using GitHub.Primitives;
@ -6,7 +7,7 @@ namespace GitHub.Factories
{
public interface IRepositoryHostFactory : IDisposable
{
IRepositoryHost Create(HostAddress hostAddress);
Task<IRepositoryHost> Create(HostAddress hostAddress);
void Remove(IRepositoryHost host);
}
}

Просмотреть файл

@ -12,7 +12,7 @@
<AssemblyName>GitHub.Exports.Reactive</AssemblyName>
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
<NuGetPackageImportStamp>
</NuGetPackageImportStamp>
<OutputPath>bin\$(Configuration)\</OutputPath>
@ -155,7 +155,7 @@
</ItemGroup>
<ItemGroup>
<Compile Include="Factories\IApiClientFactory.cs" />
<Compile Include="Authentication\ITwoFactorChallengeHandler.cs" />
<Compile Include="Authentication\IDelegatingTwoFactorChallengeHandler.cs" />
<Compile Include="Factories\IRepositoryHostFactory.cs" />
<Compile Include="Models\IRepositoryHost.cs" />
<Compile Include="Collections\ISelectable.cs" />
@ -188,6 +188,10 @@
<Project>{252ce1c2-027a-4445-a3c2-e4d6c80a935a}</Project>
<Name>Splat-Net45</Name>
</ProjectReference>
<ProjectReference Include="..\GitHub.Api\GitHub.Api.csproj">
<Project>{B389ADAF-62CC-486E-85B4-2D8B078DF763}</Project>
<Name>GitHub.Api</Name>
</ProjectReference>
<ProjectReference Include="..\GitHub.Exports\GitHub.Exports.csproj">
<Project>{9aea02db-02b5-409c-b0ca-115d05331a6b}</Project>
<Name>GitHub.Exports</Name>

Просмотреть файл

@ -64,5 +64,10 @@ namespace GitHub.ViewModels
/// a GitHub.com lost password flow.
/// </summary>
IRecoveryCommand NavigateForgotPassword { get; }
/// <summary>
/// Gets an error to display to the user.
/// </summary>
UserError Error { get; }
}
}

Просмотреть файл

@ -1,5 +1,6 @@
using System;
using GitHub.Validation;
using Octokit;
using ReactiveUI;
namespace GitHub.ViewModels
@ -10,7 +11,7 @@ namespace GitHub.ViewModels
ReactiveCommand<object> NavigateLearnMore { get; }
ReactiveCommand<object> ResendCodeCommand { get; }
IObservable<RecoveryOptionResult> Show(UserError error);
IObservable<TwoFactorChallengeResult> Show(UserError error);
bool IsSms { get; }
bool IsAuthenticationCodeSent { get; }

Просмотреть файл

@ -1,10 +1,11 @@
using GitHub.Primitives;
using System.Threading.Tasks;
using GitHub.Primitives;
namespace GitHub.Api
{
public interface ISimpleApiClientFactory
{
ISimpleApiClient Create(UriString repositoryUrl);
Task<ISimpleApiClient> Create(UriString repositoryUrl);
void ClearFromCache(ISimpleApiClient client);
}
}

Просмотреть файл

@ -12,7 +12,7 @@
<AssemblyName>GitHub.Exports</AssemblyName>
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
<NuGetPackageImportStamp>
</NuGetPackageImportStamp>
<OutputPath>bin\$(Configuration)\</OutputPath>
@ -162,11 +162,13 @@
<Compile Include="Helpers\ThreadingHelper.cs" />
<Compile Include="Info\ApplicationInfo.cs" />
<Compile Include="Models\BranchModel.cs" />
<Compile Include="Models\ConnectionDetails.cs" />
<Compile Include="Models\CloneDialogResult.cs" />
<Compile Include="Models\GitReferenceModel.cs" />
<Compile Include="Models\IAccount.cs" />
<Compile Include="Models\IBranch.cs" />
<Compile Include="Models\IConnectionManager.cs" />
<Compile Include="Services\IConnectionCache.cs" />
<Compile Include="Services\IConnectionManager.cs" />
<Compile Include="Models\IExportFactoryProvider.cs" />
<Compile Include="Models\IPullRequestFileModel.cs" />
<Compile Include="Models\IRepositoryModel.cs" />

Просмотреть файл

@ -0,0 +1,75 @@
using System;
using GitHub.Primitives;
namespace GitHub.Models
{
/// <summary>
/// Represents details about a connection stored in an <see cref="IConnectionCache"/>.
/// </summary>
public struct ConnectionDetails : IEquatable<ConnectionDetails>
{
/// <summary>
/// Initializes a new instance of the <see cref="ConnectionDetails"/> struct.
/// </summary>
/// <param name="hostAddress">The address of the host.</param>
/// <param name="userName">The username for the host.</param>
public ConnectionDetails(string hostAddress, string userName)
{
HostAddress = HostAddress.Create(hostAddress);
UserName = userName;
}
/// <summary>
/// Initializes a new instance of the <see cref="ConnectionDetails"/> struct.
/// </summary>
/// <param name="hostAddress">The address of the host.</param>
/// <param name="userName">The username for the host.</param>
public ConnectionDetails(HostAddress hostAddress, string userName)
{
HostAddress = hostAddress;
UserName = userName;
}
/// <summary>
/// Gets the address of the host.
/// </summary>
public HostAddress HostAddress { get; }
/// <summary>
/// Gets the username for the host.
/// </summary>
public string UserName { get; }
public bool Equals(ConnectionDetails other)
{
if (ReferenceEquals(this, other))
return true;
return HostAddress.Equals(other.HostAddress) &&
string.Equals(UserName, other.UserName, StringComparison.OrdinalIgnoreCase);
}
public override bool Equals(object obj)
{
return obj is ConnectionDetails && Equals((ConnectionDetails)obj);
}
public override int GetHashCode()
{
unchecked
{
return (HostAddress.GetHashCode()*397) ^ StringComparer.InvariantCultureIgnoreCase.GetHashCode(UserName);
}
}
public static bool operator ==(ConnectionDetails left, ConnectionDetails right)
{
return Equals(left, right);
}
public static bool operator !=(ConnectionDetails left, ConnectionDetails right)
{
return !Equals(left, right);
}
}
}

Просмотреть файл

@ -57,13 +57,13 @@ namespace GitHub.Primitives
}
/// <summary>
/// The Base URL to the host. For example, "https://github.com" or "https://ghe.io"
/// The Base URL to the host. For example, "https://github.com" or "https://github-enterprise.com"
/// </summary>
public Uri WebUri { get; set; }
/// <summary>
/// The Base Url to the host's API endpoint. For example, "https://api.github.com" or
/// "https://ghe.io/api/v3"
/// "https://github-enterprise.com/api/v3"
/// </summary>
public Uri ApiUri { get; set; }

Просмотреть файл

@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using GitHub.Models;
namespace GitHub.Services
{
/// <summary>
/// Loads the configured connections from a cache.
/// </summary>
public interface IConnectionCache
{
/// <summary>
/// Loads the configured connections.
/// </summary>
/// <returns>A task returning the collection of configured collections.</returns>
Task<IEnumerable<ConnectionDetails>> Load();
/// <summary>
/// Saves the configured connections.
/// </summary>
/// <param name="connections">The collection of configured collections to save.</param>
/// <returns>A task tracking the operation.</returns>
Task Save(IEnumerable<ConnectionDetails> connections);
}
}

Просмотреть файл

@ -1,10 +1,11 @@
using System;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using GitHub.Primitives;
using System.Threading.Tasks;
using GitHub.Models;
using GitHub.Primitives;
namespace GitHub.Models
namespace GitHub.Services
{
public interface IConnectionManager
{

Просмотреть файл

@ -19,6 +19,7 @@ namespace GitHub.Services
Task IncrementPullRequestCheckOutCount(bool fork);
Task IncrementPullRequestPullCount(bool fork);
Task IncrementPullRequestPushCount(bool fork);
Task IncrementPullRequestOpened();
Task IncrementWelcomeDocsClicks();
Task IncrementWelcomeTrainingClicks();
Task IncrementGitHubPaneHelpClicks();

Просмотреть файл

@ -14,6 +14,7 @@ namespace GitHub.VisualStudio
public const string CodeContainerProviderId = "6CE146CB-EF57-4F2C-A93F-5BA685317660";
public const string InlineReviewsPackageId = "248325BE-4A2D-4111-B122-E7D59BF73A35";
public const string TeamExplorerWelcomeMessage = "C529627F-8AA6-4FDB-82EB-4BFB7DB753C3";
public const string LoginManagerId = "7BA2071A-790A-4F95-BE4A-0EEAA5928AAF";
// VisualStudio IDs
public const string GitSccProviderId = "11B8E6D7-C08B-4385-B321-321078CDD1F8";

Просмотреть файл

@ -17,7 +17,7 @@
<RunCodeAnalysis>true</RunCodeAnalysis>
<CodeAnalysisRuleSet>..\common\GitHubVS.ruleset</CodeAnalysisRuleSet>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
<OutputPath>bin\$(Configuration)\</OutputPath>
<TargetFrameworkProfile />
</PropertyGroup>

Просмотреть файл

@ -17,7 +17,7 @@
<RunCodeAnalysis>true</RunCodeAnalysis>
<CodeAnalysisRuleSet>..\common\GitHubVS.ruleset</CodeAnalysisRuleSet>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
<OutputPath>bin\$(Configuration)\</OutputPath>
<TargetFrameworkProfile />
</PropertyGroup>

Просмотреть файл

@ -57,7 +57,7 @@ namespace GitHub.InlineReviews.Commands
var session = await sessionManager.GetSession(pullRequest);
var address = HostAddress.Create(session.Repository.CloneUrl);
var apiClient = apiClientFactory.Create(address);
var apiClient = await apiClientFactory.Create(address);
await window.Initialize(session, apiClient);
var windowFrame = (IVsWindowFrame)window.Frame;

Просмотреть файл

@ -1,5 +1,6 @@
using System;
using System.ComponentModel.Composition;
using System.Threading.Tasks;
using GitHub.Api;
using GitHub.Extensions;
using GitHub.Factories;
@ -127,12 +128,6 @@ namespace GitHub.InlineReviews.Services
return trackingPoint;
}
IApiClient CreateApiClient(ILocalRepositoryModel repository)
{
var hostAddress = HostAddress.Create(repository.CloneUrl.Host);
return apiClientFactory.Create(hostAddress);
}
void ExpandCollapsedRegions(ITextView textView, SnapshotSpan span)
{
var outlining = outliningService.GetOutliningManager(textView);

Просмотреть файл

@ -138,7 +138,7 @@ namespace GitHub.InlineReviews.ViewModels
var thread = file.InlineCommentThreads.FirstOrDefault(x =>
x.LineNumber == lineNumber &&
(!leftBuffer || x.DiffLineType == DiffChangeType.Delete));
var apiClient = CreateApiClient(session.Repository);
var apiClient = await CreateApiClient(session.Repository);
if (thread != null)
{
@ -180,7 +180,7 @@ namespace GitHub.InlineReviews.ViewModels
fileSubscription = file.WhenAnyValue(x => x.InlineCommentThreads).Subscribe(_ => UpdateThread().Forget());
}
IApiClient CreateApiClient(ILocalRepositoryModel repository)
Task<IApiClient> CreateApiClient(ILocalRepositoryModel repository)
{
var hostAddress = HostAddress.Create(repository.CloneUrl.Host);
return apiClientFactory.Create(hostAddress);

Просмотреть файл

@ -8,7 +8,7 @@
<VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
<UseCodeBase>true</UseCodeBase>
<TargetFrameworkProfile />
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
<NuGetPackageImportStamp>
</NuGetPackageImportStamp>
</PropertyGroup>

Просмотреть файл

@ -259,7 +259,8 @@ namespace GitHub.VisualStudio.TeamExplorer.Connect
try
{
// TODO: Cache the icon state.
var repo = await ApiFactory.Create(newrepo.CloneUrl).GetRepository();
var api = await ApiFactory.Create(newrepo.CloneUrl);
var repo = await api.GetRepository();
newrepo.SetIcon(repo.Private, repo.Fork);
}
catch
@ -283,7 +284,8 @@ namespace GitHub.VisualStudio.TeamExplorer.Connect
try
{
// TODO: Cache the icon state.
var repo = await ApiFactory.Create(r.CloneUrl).GetRepository();
var api = await ApiFactory.Create(r.CloneUrl);
var repo = await api.GetRepository();
r.SetIcon(repo.Private, repo.Fork);
}
catch

Просмотреть файл

@ -15,7 +15,7 @@
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<OutputPath>..\..\build\$(Configuration)\</OutputPath>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
<NuGetPackageImportStamp>
</NuGetPackageImportStamp>
<TargetFrameworkProfile />

Просмотреть файл

@ -26,6 +26,7 @@ namespace GitHub.VisualStudio.TeamExplorer.Home
{
public const string GitHubHomeSectionId = "72008232-2104-4FA0-A189-61B0C6F91198";
const string TrainingUrl = "https://services.github.com/on-demand/windows/visual-studio";
readonly static Guid welcomeMessageGuid = new Guid(Guids.TeamExplorerWelcomeMessage);
readonly IVisualStudioBrowser visualStudioBrowser;
readonly ITeamExplorerServices teamExplorerServices;
@ -53,25 +54,6 @@ namespace GitHub.VisualStudio.TeamExplorer.Home
var openOnGitHub = ReactiveCommand.Create();
openOnGitHub.Subscribe(_ => DoOpenOnGitHub());
OpenOnGitHub = openOnGitHub;
// We want to display a welcome message but only if Team Explorer isn't
// already displaying the "Install 3rd Party Tools" message. To do this
// we need to set a timer and check in the tick as at this point the message
// won't be initialized.
if (!settings.HideTeamExplorerWelcomeMessage)
{
var timer = new DispatcherTimer();
timer.Interval = new TimeSpan(10);
timer.Tick += (s, e) =>
{
timer.Stop();
if (!IsGitToolsMessageVisible())
{
ShowWelcomeMessage();
}
};
timer.Start();
}
}
bool IsGitToolsMessageVisible()
@ -93,11 +75,23 @@ namespace GitHub.VisualStudio.TeamExplorer.Home
RepoName = ActiveRepoName;
RepoUrl = ActiveRepoUri.ToString();
Icon = GetIcon(false, true, false);
// We want to display a welcome message but only if Team Explorer isn't
// already displaying the "Install 3rd Party Tools" message and the current repo is hosted on GitHub.
if (!settings.HideTeamExplorerWelcomeMessage && !IsGitToolsMessageVisible())
{
ShowWelcomeMessage();
}
Debug.Assert(SimpleApiClient != null,
"If we're in this block, simpleApiClient cannot be null. It was created by UpdateStatus");
var repo = await SimpleApiClient.GetRepository();
Icon = GetIcon(repo.Private, true, repo.Fork);
IsLoggedIn = IsUserAuthenticated();
IsLoggedIn = await IsUserAuthenticated();
}
else
{
teamExplorerServices.HideNotification(welcomeMessageGuid);
}
}
@ -106,8 +100,9 @@ namespace GitHub.VisualStudio.TeamExplorer.Home
IsVisible = await IsAGitHubRepo();
if (IsVisible)
{
IsLoggedIn = IsUserAuthenticated();
IsLoggedIn = await IsUserAuthenticated();
}
base.Refresh();
}
@ -149,7 +144,6 @@ namespace GitHub.VisualStudio.TeamExplorer.Home
void ShowWelcomeMessage()
{
var welcomeMessageGuid = new Guid(Guids.TeamExplorerWelcomeMessage);
teamExplorerServices.ShowMessage(
Resources.TeamExplorerWelcomeMessage,
new RelayCommand(o =>

Просмотреть файл

@ -15,7 +15,7 @@
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<OutputPath>..\..\build\$(Configuration)\</OutputPath>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
<NuGetPackageImportStamp>
</NuGetPackageImportStamp>
<TargetFrameworkProfile />

Просмотреть файл

@ -32,18 +32,6 @@ namespace GitHub.UI
{
DataContext = result;
});
Unloaded += (o, e) =>
{
if (whenAnyShowingMessage != null)
{
whenAnyShowingMessage.Dispose();
}
if (whenAnyDataContext != null)
{
whenAnyDataContext.Dispose();
}
};
}
public static readonly DependencyProperty IconMarginProperty = DependencyProperty.Register("IconMargin", typeof(Thickness), typeof(UserErrorMessages), new PropertyMetadata(new Thickness(0,0,8,0)));

Просмотреть файл

@ -42,6 +42,13 @@ namespace GitHub.UI
typeof(ViewBase),
new FrameworkPropertyMetadata());
static readonly DependencyProperty ShowBusyStateProperty =
DependencyProperty.Register(
nameof(ShowBusyState),
typeof(bool),
typeof(ViewBase),
new FrameworkPropertyMetadata(true));
public static readonly DependencyProperty HasBusyStateProperty = HasBusyStatePropertyKey.DependencyProperty;
public static readonly DependencyProperty IsBusyProperty = IsBusyPropertyKey.DependencyProperty;
public static readonly DependencyProperty IsLoadingProperty = IsLoadingPropertyKey.DependencyProperty;
@ -83,6 +90,15 @@ namespace GitHub.UI
protected set { SetValue(IsLoadingPropertyKey, value); }
}
/// <summary>
/// Gets or sets a value indicating whether to display the view model's busy state.
/// </summary>
public bool ShowBusyState
{
get { return (bool)GetValue(ShowBusyStateProperty); }
set { SetValue(ShowBusyStateProperty, value); }
}
internal ViewBase()
{
}

Просмотреть файл

@ -18,7 +18,7 @@
<RunCodeAnalysis>true</RunCodeAnalysis>
<CodeAnalysisRuleSet>..\common\GitHubVS.ruleset</CodeAnalysisRuleSet>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
<OutputPath>bin\$(Configuration)\</OutputPath>
<TargetFrameworkProfile />
</PropertyGroup>

Просмотреть файл

@ -32,16 +32,28 @@
<ui:GitHubProgressBar Foreground="{DynamicResource GitHubAccentBrush}"
IsIndeterminate="True"
Style="{DynamicResource GitHubProgressBar}"
Visibility="{TemplateBinding IsBusy, Converter={ui:BooleanToHiddenVisibilityConverter}}"/>
Style="{DynamicResource GitHubProgressBar}">
<ui:GitHubProgressBar.Visibility>
<MultiBinding Converter="{ui:MultiBooleanToVisibilityConverter}">
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="IsBusy"/>
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="ShowBusyState"/>
</MultiBinding>
</ui:GitHubProgressBar.Visibility>
</ui:GitHubProgressBar>
<c:Spinner Name="spinner"
Grid.Row="1"
Width="48"
Height="48"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="{TemplateBinding IsLoading, Converter={ui:BooleanToVisibilityConverter}}"/>
VerticalAlignment="Center">
<c:Spinner.Visibility>
<MultiBinding Converter="{ui:MultiBooleanToVisibilityConverter}">
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="IsLoading"/>
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="ShowBusyState"/>
</MultiBinding>
</c:Spinner.Visibility>
</c:Spinner>
<ContentPresenter Grid.Row="1"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"

Просмотреть файл

@ -0,0 +1,32 @@
using System;
using System.Globalization;
using System.Linq;
using System.Windows;
using NullGuard;
namespace GitHub.UI
{
[Localizability(LocalizationCategory.NeverLocalize)]
public sealed class MultiBooleanToVisibilityConverter : MultiValueConverterMarkupExtension<MultiBooleanToVisibilityConverter>
{
readonly System.Windows.Controls.BooleanToVisibilityConverter converter = new System.Windows.Controls.BooleanToVisibilityConverter();
public override object Convert(
[AllowNull]object[] value,
[AllowNull]Type targetType,
[AllowNull]object parameter,
[AllowNull]CultureInfo culture)
{
return value.OfType<bool>().All(x => x) ? Visibility.Visible : Visibility.Collapsed;
}
public override object[] ConvertBack(
[AllowNull]object value,
[AllowNull]Type[] targetType,
[AllowNull]object parameter,
[AllowNull]CultureInfo culture)
{
return null;
}
}
}

Просмотреть файл

@ -14,7 +14,7 @@
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
<NuGetPackageImportStamp>
</NuGetPackageImportStamp>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
<OutputPath>bin\$(Configuration)\</OutputPath>
<TargetFrameworkProfile />
</PropertyGroup>
@ -91,6 +91,7 @@
<DependentUpon>Spinner.xaml</DependentUpon>
</Compile>
<Compile Include="Converters\AllCapsConverter.cs" />
<Compile Include="Converters\MultiBooleanToVisibilityConverter.cs" />
<Compile Include="Converters\NullToVisibilityConverter.cs" />
<Compile Include="Converters\BranchNameConverter.cs" />
<Compile Include="Converters\CountToVisibilityConverter.cs" />

Просмотреть файл

@ -112,7 +112,7 @@ namespace GitHub.VisualStudio.Base
return RepositoryOrigin.Other;
Debug.Assert(apiFactory != null, "apiFactory cannot be null. Did you call the right constructor?");
SimpleApiClient = apiFactory.Create(uri);
SimpleApiClient = await apiFactory.Create(uri);
var isdotcom = HostAddress.IsGitHubDotComUri(uri.ToRepositoryUrl());
@ -139,7 +139,7 @@ namespace GitHub.VisualStudio.Base
return origin == RepositoryOrigin.DotCom || origin == RepositoryOrigin.Enterprise;
}
protected bool IsUserAuthenticated()
protected async Task<bool> IsUserAuthenticated()
{
if (SimpleApiClient == null)
{
@ -150,7 +150,7 @@ namespace GitHub.VisualStudio.Base
if (uri == null)
return false;
SimpleApiClient = apiFactory.Create(uri);
SimpleApiClient = await apiFactory.Create(uri);
}
return SimpleApiClient?.IsAuthenticated() ?? false;

Просмотреть файл

@ -12,7 +12,7 @@
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<OutputPath>..\..\build\$(Configuration)\</OutputPath>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
<WarningLevel>4</WarningLevel>
<RunCodeAnalysis>true</RunCodeAnalysis>
<CodeAnalysisRuleSet>..\common\GitHubVS.ruleset</CodeAnalysisRuleSet>

Просмотреть файл

@ -19,8 +19,11 @@
<NuGetPackageImportStamp>
</NuGetPackageImportStamp>
<TargetFrameworkProfile />
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
<ApplicationVersion>2.2.0.7</ApplicationVersion>
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
<ApplicationVersion>2.2.1.100</ApplicationVersion>
<ApplicationVersion>2.3.0.0</ApplicationVersion>
<OutputPath>..\..\build\$(Configuration)\</OutputPath>
<VsixType>v3</VsixType>
<IsProductComponent>false</IsProductComponent>
@ -311,8 +314,10 @@
<Compile Include="Menus\MenuProvider.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Helpers\Browser.cs" />
<Compile Include="Services\JsonConnectionCache.cs" />
<Compile Include="Services\UIProvider.cs" />
<Compile Include="Services\UsageTracker.cs" />
<Compile Include="Services\LoginManagerDispatcher.cs" />
<Compile Include="Services\UsageTrackerDispatcher.cs" />
<Compile Include="Settings\Constants.cs" />
<Compile Include="Services\ConnectionManager.cs" />
@ -744,4 +749,4 @@
<Target Name="AfterBuild">
</Target>
-->
</Project>
</Project>

Просмотреть файл

@ -17,6 +17,7 @@ using Task = System.Threading.Tasks.Task;
using GitHub.VisualStudio.Menus;
using System.ComponentModel.Design;
using GitHub.ViewModels;
using GitHub.Api;
namespace GitHub.VisualStudio
{
@ -89,6 +90,7 @@ namespace GitHub.VisualStudio
[NullGuard.NullGuard(NullGuard.ValidationFlags.None)]
[PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)]
[ProvideService(typeof(ILoginManager), IsAsyncQueryable = true)]
[ProvideService(typeof(IMenuProvider), IsAsyncQueryable = true)]
[ProvideService(typeof(IGitHubServiceProvider), IsAsyncQueryable = true)]
[ProvideService(typeof(IUsageTracker), IsAsyncQueryable = true)]
@ -130,6 +132,7 @@ namespace GitHub.VisualStudio
{
AddService(typeof(IGitHubServiceProvider), CreateService, true);
AddService(typeof(IUsageTracker), CreateService, true);
AddService(typeof(ILoginManager), CreateService, true);
AddService(typeof(IMenuProvider), CreateService, true);
AddService(typeof(IUIProvider), CreateService, true);
AddService(typeof(IGitHubToolWindowManager), CreateService, true);
@ -188,6 +191,20 @@ namespace GitHub.VisualStudio
await result.Initialize();
return result;
}
else if (serviceType == typeof(ILoginManager))
{
var serviceProvider = await GetServiceAsync(typeof(IGitHubServiceProvider)) as IGitHubServiceProvider;
var loginCache = serviceProvider.GetService<ILoginCache>();
var twoFaHandler = serviceProvider.GetService<ITwoFactorChallengeHandler>();
return new LoginManager(
loginCache,
twoFaHandler,
ApiClientConfiguration.ClientId,
ApiClientConfiguration.ClientSecret,
ApiClientConfiguration.AuthorizationNote,
ApiClientConfiguration.MachineFingerprint);
}
else if (serviceType == typeof(IMenuProvider))
{
var sp = await GetServiceAsync(typeof(IGitHubServiceProvider)) as IGitHubServiceProvider;

Просмотреть файл

@ -104,7 +104,7 @@ namespace GitHub.VisualStudio
if (uri == null)
return false;
SimpleApiClient = ApiFactory.Create(uri);
SimpleApiClient = await ApiFactory.Create(uri);
var isdotcom = HostAddress.IsGitHubDotComUri(uri.ToRepositoryUrl());
if (!isdotcom)

Просмотреть файл

@ -1,4 +1,5 @@
using System.Reflection;
using System;
using System.Reflection;
using System.Runtime.InteropServices;
using Microsoft.VisualStudio.Shell;
@ -6,14 +7,15 @@ using Microsoft.VisualStudio.Shell;
[assembly: AssemblyDescription("GitHub for Visual Studio VSPackage")]
[assembly: Guid("fad77eaa-3fe1-4c4b-88dc-3753b6263cd7")]
// NOTE: If you provide a codebase for an assembly, codebases must also be provided for all of that
// assembly's references. That is unless they're in the GAC or one of Visual Studio's probing
// directories (IDE, PublicAssemblies, PrivateAssemblies, etc).
[assembly: ProvideBindingRedirection(AssemblyName = "GitHub.UI", CodeBase = @"$PackageFolder$\GitHub.UI.dll",
OldVersionLowerBound = "0.0.0.0", OldVersionUpperBound = AssemblyVersionInformation.Version)]
[assembly: ProvideBindingRedirection(AssemblyName = "GitHub.VisualStudio.UI", CodeBase = @"$PackageFolder$\GitHub.VisualStudio.UI.dll",
OldVersionLowerBound = "0.0.0.0", OldVersionUpperBound = AssemblyVersionInformation.Version)]
[assembly: ProvideBindingRedirection(AssemblyName = "GitHub.Exports", CodeBase = @"$PackageFolder$\GitHub.Exports.dll",
OldVersionLowerBound = "0.0.0.0", OldVersionUpperBound = AssemblyVersionInformation.Version)]
[assembly: ProvideBindingRedirection(AssemblyName = "GitHub.Extensions", CodeBase = @"$PackageFolder$\GitHub.Extensions.dll",
OldVersionLowerBound = "0.0.0.0", OldVersionUpperBound = AssemblyVersionInformation.Version)]
[assembly: ProvideCodeBase(AssemblyName = "GitHub.UI", CodeBase = @"$PackageFolder$\GitHub.UI.dll")]
[assembly: ProvideCodeBase(AssemblyName = "GitHub.VisualStudio.UI", CodeBase = @"$PackageFolder$\GitHub.VisualStudio.UI.dll")]
[assembly: ProvideCodeBase(AssemblyName = "GitHub.Exports", CodeBase = @"$PackageFolder$\GitHub.Exports.dll")]
[assembly: ProvideCodeBase(AssemblyName = "GitHub.Extensions", CodeBase = @"$PackageFolder$\GitHub.Extensions.dll")]
[assembly: ProvideCodeBase(AssemblyName = "Octokit", CodeBase = @"$PackageFolder$\Octokit.dll")]
[assembly: ProvideCodeBase(AssemblyName = "LibGit2Sharp", CodeBase = @"$PackageFolder$\LibGit2Sharp.dll")]
[assembly: ProvideCodeBase(AssemblyName = "Splat", CodeBase = @"$PackageFolder$\Splat.dll")]

Просмотреть файл

@ -1,86 +1,44 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel.Composition;
using System.Diagnostics;
using System.Linq;
using System.Text;
using GitHub.Models;
using GitHub.Services;
using GitHub.Primitives;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Api;
using GitHub.Extensions;
using GitHub.Factories;
using GitHub.Models;
using GitHub.Primitives;
using GitHub.Services;
namespace GitHub.VisualStudio
{
class CacheData
{
public IEnumerable<ConnectionCacheItem> connections;
}
class ConnectionCacheItem
{
public Uri HostUrl { get; set; }
public string UserName { get; set; }
}
[Export(typeof(IConnectionManager))]
[PartCreationPolicy(CreationPolicy.Shared)]
public class ConnectionManager : IConnectionManager
{
readonly string cachePath;
readonly IVSGitServices vsGitServices;
const string cacheFile = "ghfvs.connections";
readonly IConnectionCache cache;
readonly ILoginManager loginManager;
readonly IApiClientFactory apiClientFactory;
public event Func<IConnection, IObservable<IConnection>> DoLogin;
Func<string, bool> fileExists;
Func<string, Encoding, string> readAllText;
Action<string, string> writeAllText;
Action<string> fileDelete;
Func<string, bool> dirExists;
Action<string> dirCreate;
[ImportingConstructor]
public ConnectionManager(IProgram program, IVSGitServices vsGitServices)
public ConnectionManager(
IProgram program,
IVSGitServices vsGitServices,
IConnectionCache cache,
ILoginManager loginManager,
IApiClientFactory apiClientFactory)
{
this.vsGitServices = vsGitServices;
fileExists = (path) => System.IO.File.Exists(path);
readAllText = (path, encoding) => System.IO.File.ReadAllText(path, encoding);
writeAllText = (path, content) => System.IO.File.WriteAllText(path, content);
fileDelete = (path) => System.IO.File.Delete(path);
dirExists = (path) => System.IO.Directory.Exists(path);
dirCreate = (path) => System.IO.Directory.CreateDirectory(path);
this.cache = cache;
this.loginManager = loginManager;
this.apiClientFactory = apiClientFactory;
Connections = new ObservableCollection<IConnection>();
cachePath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
program.ApplicationName,
cacheFile);
LoadConnectionsFromCache();
Connections.CollectionChanged += RefreshConnections;
}
public ConnectionManager(IProgram program, Rothko.IOperatingSystem os, IVSGitServices vsGitServices)
{
this.vsGitServices = vsGitServices;
fileExists = (path) => os.File.Exists(path);
readAllText = (path, encoding) => os.File.ReadAllText(path, encoding);
writeAllText = (path, content) => os.File.WriteAllText(path, content);
fileDelete = (path) => os.File.Delete(path);
dirExists = (path) => os.Directory.Exists(path);
dirCreate = (path) => os.Directory.CreateDirectory(path);
Connections = new ObservableCollection<IConnection>();
cachePath = System.IO.Path.Combine(
os.Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
program.ApplicationName,
cacheFile);
LoadConnectionsFromCache();
Connections.CollectionChanged += RefreshConnections;
LoadConnectionsFromCache().Forget();
}
public IConnection CreateConnection(HostAddress address, string username)
@ -158,71 +116,43 @@ namespace GitHub.VisualStudio
c.Dispose();
}
}
SaveConnectionsToCache();
SaveConnectionsToCache().Forget();
}
void LoadConnectionsFromCache()
async Task LoadConnectionsFromCache()
{
EnsureCachePath();
if (!fileExists(cachePath))
return;
string data = readAllText(cachePath, Encoding.UTF8);
CacheData cacheData;
try
foreach (var c in await cache.Load())
{
cacheData = SimpleJson.DeserializeObject<CacheData>(data);
}
catch
{
cacheData = null;
}
var client = await apiClientFactory.CreateGitHubClient(c.HostAddress);
var addConnection = true;
if (cacheData == null || cacheData.connections == null)
{
// cache is corrupt, remove
fileDelete(cachePath);
return;
}
cacheData.connections.ForEach(c =>
{
if (c.HostUrl != null)
AddConnection(c.HostUrl, c.UserName);
});
}
void SaveConnectionsToCache()
{
EnsureCachePath();
var cache = new CacheData();
cache.connections = Connections.Select(conn =>
new ConnectionCacheItem
try
{
HostUrl = conn.HostAddress.WebUri,
UserName = conn.Username,
});
try
{
string data = SimpleJson.SerializeObject(cache);
writeAllText(cachePath, data);
}
catch (Exception ex)
{
Debug.Fail(ex.ToString());
await loginManager.LoginFromCache(c.HostAddress, client);
}
catch (Octokit.ApiException e)
{
addConnection = false;
VsOutputLogger.WriteLine("Cached credentials for connection {0} were invalid: {1}", c.HostAddress, e);
}
catch (Exception)
{
// Add the connection in this case - could be that there's no internet connection.
}
if (addConnection)
{
AddConnection(c.HostAddress, c.UserName);
}
}
Connections.CollectionChanged += RefreshConnections;
}
void EnsureCachePath()
async Task SaveConnectionsToCache()
{
if (fileExists(cachePath))
return;
var di = System.IO.Path.GetDirectoryName(cachePath);
if (!dirExists(di))
dirCreate(di);
await cache.Save(Connections.Select(x => new ConnectionDetails(x.HostAddress, x.Username)));
}
public ObservableCollection<IConnection> Connections { get; private set; }

Просмотреть файл

@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.Composition;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using GitHub.Models;
using GitHub.Primitives;
using GitHub.Services;
using Rothko;
namespace GitHub.VisualStudio
{
/// <summary>
/// Loads and saves the configured connections from/to a .json file.
/// </summary>
[Export(typeof(IConnectionCache))]
public class JsonConnectionCache : IConnectionCache
{
const string DefaultCacheFile = "ghfvs.connections";
readonly string cachePath;
readonly IOperatingSystem os;
[ImportingConstructor]
public JsonConnectionCache(IProgram program, IOperatingSystem os)
: this(program, os, DefaultCacheFile)
{
}
public JsonConnectionCache(IProgram program, IOperatingSystem os, string cacheFile)
{
this.os = os;
cachePath = Path.Combine(
os.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),
program.ApplicationName,
cacheFile);
}
public Task<IEnumerable<ConnectionDetails>> Load()
{
if (os.File.Exists(cachePath))
{
try
{
// TODO: Need a ReadAllTextAsync method here.
var data = os.File.ReadAllText(cachePath, Encoding.UTF8);
var result = SimpleJson.DeserializeObject<CacheData>(data);
return Task.FromResult(result.connections.Select(FromCache));
}
catch (Exception e)
{
try
{
os.File.Delete(cachePath);
}
catch { }
VsOutputLogger.WriteLine("Failed to read connection cache from {0}: {1}", cachePath, e);
}
}
return Task.FromResult(Enumerable.Empty<ConnectionDetails>());
}
public Task Save(IEnumerable<ConnectionDetails> connections)
{
var data = SimpleJson.SerializeObject(new CacheData
{
connections = connections.Select(ToCache).ToList(),
});
try
{
os.File.WriteAllText(cachePath, data);
}
catch (Exception e)
{
VsOutputLogger.WriteLine("Failed to write connection cache to {0}: {1}", cachePath, e);
}
return Task.CompletedTask;
}
static ConnectionDetails FromCache(ConnectionCacheItem c)
{
return new ConnectionDetails(HostAddress.Create(c.HostUrl), c.UserName);
}
static ConnectionCacheItem ToCache(ConnectionDetails c)
{
return new ConnectionCacheItem
{
HostUrl = c.HostAddress.WebUri,
UserName = c.UserName,
};
}
class CacheData
{
public IEnumerable<ConnectionCacheItem> connections { get; set; }
}
class ConnectionCacheItem
{
public Uri HostUrl { get; set; }
public string UserName { get; set; }
}
}
}

Просмотреть файл

@ -0,0 +1,39 @@
using System;
using System.ComponentModel.Composition;
using System.Threading.Tasks;
using GitHub.Api;
using GitHub.Primitives;
using Microsoft.VisualStudio.Shell;
using Octokit;
using Task = System.Threading.Tasks.Task;
namespace GitHub.Services
{
[Export(typeof(ILoginManager))]
[PartCreationPolicy(CreationPolicy.NonShared)]
public class LoginManagerDispatcher : ILoginManager
{
readonly ILoginManager inner;
[ImportingConstructor]
public LoginManagerDispatcher([Import(typeof(SVsServiceProvider))] IServiceProvider serviceProvider)
{
inner = serviceProvider.GetService(typeof(ILoginManager)) as ILoginManager;
}
public Task<User> Login(HostAddress hostAddress, IGitHubClient client, string userName, string password)
{
return inner.Login(hostAddress, client, userName, password);
}
public Task<User> LoginFromCache(HostAddress hostAddress, IGitHubClient client)
{
return inner.LoginFromCache(hostAddress, client);
}
public Task Logout(HostAddress hostAddress, IGitHubClient client)
{
return inner.Logout(hostAddress, client);
}
}
}

Просмотреть файл

@ -11,21 +11,21 @@ using Task = System.Threading.Tasks.Task;
using GitHub.Extensions;
using System.Threading.Tasks;
using GitHub.Helpers;
using System.Threading;
namespace GitHub.Services
{
public class UsageTracker : IUsageTracker
public sealed class UsageTracker : IUsageTracker, IDisposable
{
const string StoreFileName = "ghfvs.usage";
static readonly Calendar cal = CultureInfo.InvariantCulture.Calendar;
readonly IGitHubServiceProvider gitHubServiceProvider;
readonly DispatcherTimer timer;
IMetricsService client;
IConnectionManager connectionManager;
IPackageSettings userSettings;
IVSServices vsservices;
Timer timer;
string storePath;
bool firstRun = true;
@ -61,13 +61,16 @@ namespace GitHub.Services
};
dirCreate = (path) => System.IO.Directory.CreateDirectory(path);
this.timer = new DispatcherTimer(
TimeSpan.FromMinutes(3),
DispatcherPriority.Background,
this.timer = new Timer(
TimerTick,
ThreadingHelper.MainThreadDispatcher);
null,
TimeSpan.FromMinutes(3),
TimeSpan.FromHours(8));
}
RunTimer();
public void Dispose()
{
timer?.Dispose();
}
public async Task IncrementLaunchCount()
@ -192,6 +195,13 @@ namespace GitHub.Services
SaveUsage(usage);
}
public async Task IncrementPullRequestOpened()
{
var usage = await LoadUsage();
++usage.Model.NumberOfPullRequestsOpened;
SaveUsage(usage);
}
async Task Initialize()
{
// The services needed by the usage tracker are loaded when they are first needed to
@ -244,14 +254,7 @@ namespace GitHub.Services
writeAllText(storePath, json, Encoding.UTF8);
}
void RunTimer()
{
// The timer first ticks after 3 minutes to allow things to settle down after startup.
// This will be changed to 8 hours after the first tick by the TimerTick method.
timer.Start();
}
void TimerTick(object sender, EventArgs e)
void TimerTick(object state)
{
TimerTick()
.Catch(ex =>
@ -268,13 +271,13 @@ namespace GitHub.Services
if (firstRun)
{
await IncrementLaunchCount();
timer.Interval = TimeSpan.FromHours(8);
firstRun = false;
}
if (client == null || !userSettings.CollectMetrics)
{
timer.Stop();
timer.Dispose();
timer = null;
return;
}
@ -350,6 +353,9 @@ namespace GitHub.Services
usage.NumberOfForkPullRequestsCheckedOut = 0;
usage.NumberOfForkPullRequestPulls = 0;
usage.NumberOfForkPullRequestPushes = 0;
usage.NumberOfGitHubPaneHelpClicks = 0;
usage.NumberOfWelcomeTrainingClicks = 0;
usage.NumberOfWelcomeDocsClicks = 0;
if (weekly)
usage.NumberOfStartupsWeek = 0;

Просмотреть файл

@ -26,6 +26,7 @@ namespace GitHub.Services
public Task IncrementOpenInGitHubCount() => inner.IncrementOpenInGitHubCount();
public Task IncrementPublishCount() => inner.IncrementPublishCount();
public Task IncrementUpstreamPullRequestCount() => inner.IncrementUpstreamPullRequestCount();
public Task IncrementPullRequestOpened() => inner.IncrementPullRequestOpened();
public Task IncrementPullRequestCheckOutCount(bool fork) => inner.IncrementPullRequestCheckOutCount(fork);
public Task IncrementPullRequestPullCount(bool fork) => inner.IncrementPullRequestPullCount(fork);
public Task IncrementPullRequestPushCount(bool fork) => inner.IncrementPullRequestPushCount(fork);

Просмотреть файл

@ -55,7 +55,6 @@
x:Name="hostTabControl"
Margin="30,0"
Style="{StaticResource LightModalViewTabControl}"
SelectionChanged="hostTabControl_SelectionChanged"
FocusManager.IsFocusScope="True"
FocusVisualStyle="{x:Null}"
helpers:AccessKeysManagerScoping.IsEnabled="True"

Просмотреть файл

@ -22,8 +22,6 @@ namespace GitHub.VisualStudio.UI.Views.Controls
[PartCreationPolicy(CreationPolicy.NonShared)]
public partial class LoginControl : GenericLoginControl
{
IDisposable errorHandler;
public LoginControl()
{
InitializeComponent();
@ -33,8 +31,6 @@ namespace GitHub.VisualStudio.UI.Views.Controls
SetupDotComBindings(d);
SetupEnterpriseBindings(d);
SetupSelectedAndVisibleTabBindings(d);
d(Disposable.Create(() => errorHandler.Dispose()));
});
IsVisibleChanged += (s, e) =>
@ -57,6 +53,7 @@ namespace GitHub.VisualStudio.UI.Views.Controls
d(this.OneWayBind(ViewModel, vm => vm.GitHubLogin.Login, v => v.dotComLogInButton.Command));
d(this.OneWayBind(ViewModel, vm => vm.GitHubLogin.IsLoggingIn, v => v.dotComLogInButton.ShowSpinner));
d(this.OneWayBind(ViewModel, vm => vm.GitHubLogin.NavigatePricing, v => v.pricingLink.Command));
d(this.OneWayBind(ViewModel, vm => vm.GitHubLogin.Error, v => v.dotComErrorMessage.UserError));
}
void SetupEnterpriseBindings(Action<IDisposable> d)
@ -75,6 +72,7 @@ namespace GitHub.VisualStudio.UI.Views.Controls
d(this.OneWayBind(ViewModel, vm => vm.EnterpriseLogin.Login, v => v.enterpriseLogInButton.Command));
d(this.OneWayBind(ViewModel, vm => vm.EnterpriseLogin.IsLoggingIn, v => v.enterpriseLogInButton.ShowSpinner));
d(this.OneWayBind(ViewModel, vm => vm.EnterpriseLogin.NavigateLearnMore, v => v.learnMoreLink.Command));
d(this.OneWayBind(ViewModel, vm => vm.EnterpriseLogin.Error, v => v.enterpriseErrorMessage.UserError));
}
void SetupSelectedAndVisibleTabBindings(Action<IDisposable> d)
@ -97,25 +95,5 @@ namespace GitHub.VisualStudio.UI.Views.Controls
.Where(x => x == true)
.BindTo(this, v => v.enterpriseTab.IsSelected));
}
void hostTabControl_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
{
// This is a bit ugly but it's the simplest way I could think of dealing with it: there can only
// be one UserErrorMessages control active at any time and we need one for each tab. Register/unregister
// them here when the tab is changed.
var clearErrorWhen = Observable.Return(false);
errorHandler?.Dispose();
switch (hostTabControl.SelectedIndex)
{
case 0:
errorHandler = dotComErrorMessage.RegisterHandler<UserError>(clearErrorWhen);
break;
case 1:
errorHandler = enterpriseErrorMessage.RegisterHandler<UserError>(clearErrorWhen);
break;
}
}
}
}

Просмотреть файл

@ -155,15 +155,6 @@
IsEnabled="{Binding FilterTextIsEnabled, Mode=OneWay}"
AutomationProperties.AutomationId="{x:Static automation:AutomationIDs.SearchRepositoryTextBox}" />
<ui:GitHubProgressBar x:Name="loadingProgressBar"
Margin="0"
HorizontalContentAlignment="Stretch"
DockPanel.Dock="Top"
Foreground="{DynamicResource GitHubAccentBrush}"
IsIndeterminate="True"
Style="{DynamicResource GitHubProgressBar}"
Visibility="{Binding IsBusy, Mode=OneWay, Converter={ui:BooleanToVisibilityConverter}}" />
<StackPanel x:Name="loadingFailedPanel"
Margin="0,4,0,4"
HorizontalAlignment="Center"

Просмотреть файл

@ -15,6 +15,7 @@
d:DesignWidth="414"
d:DesignHeight="440"
Style="{DynamicResource DialogUserControl}"
ShowBusyState="False"
AutomationProperties.AutomationId="{x:Static automation:AutomationIDs.TwoFactorAuthenticationCustom}" >
<Control.Resources>

Просмотреть файл

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011" xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011">
<Metadata>
<Identity Id="c3d3dc68-c977-411f-b3e8-03b0dccf7dfc" Version="2.2.1.100" Language="en-US" Publisher="GitHub, Inc" />
<Identity Id="c3d3dc68-c977-411f-b3e8-03b0dccf7dfc" Version="2.3.0.0" Language="en-US" Publisher="GitHub, Inc" />
<DisplayName>GitHub Extension for Visual Studio</DisplayName>
<Description xml:space="preserve">A Visual Studio Extension that brings the GitHub Flow into Visual Studio.</Description>
<PackageId>GitHub.VisualStudio</PackageId>

Просмотреть файл

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<Include>
<?define VersionNumber="2.2.1.100" ?>
<?define VersionNumber="2.3.0.0" ?>
</Include>

Просмотреть файл

@ -0,0 +1,272 @@
using System;
using System.Net;
using System.Threading.Tasks;
using GitHub.Api;
using GitHub.Primitives;
using NSubstitute;
using Octokit;
using Xunit;
public class LoginManagerTests
{
static readonly HostAddress host = HostAddress.GitHubDotComHostAddress;
static readonly HostAddress enterprise = HostAddress.Create("https://enterprise.hub");
public class TheLoginMethod
{
[Fact]
public async Task LoginTokenIsSavedToCache()
{
var client = Substitute.For<IGitHubClient>();
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
.Returns(new ApplicationAuthorization("123abc"));
var loginCache = Substitute.For<ILoginCache>();
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
var target = new LoginManager(loginCache, tfa, "id", "secret");
await target.Login(host, client, "foo", "bar");
await loginCache.Received().SaveLogin("foo", "123abc", host);
}
[Fact]
public async Task LoggedInUserIsReturned()
{
var client = Substitute.For<IGitHubClient>();
var user = new User();
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
.Returns(new ApplicationAuthorization("123abc"));
client.User.Current().Returns(user);
var loginCache = Substitute.For<ILoginCache>();
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
var target = new LoginManager(loginCache, tfa, "id", "secret");
var result = await target.Login(host, client, "foo", "bar");
Assert.Same(user, result);
}
[Fact]
public async Task DeletesExistingAuthenticationIfNullTokenReturned()
{
// If GetOrCreateApplicationAuthentication is called and a matching token already exists,
// the returned token will be null because it is assumed that the token will be stored
// locally. In this case, the existing token should be first deleted.
var client = Substitute.For<IGitHubClient>();
var user = new User();
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
.Returns(
new ApplicationAuthorization(string.Empty),
new ApplicationAuthorization("123abc"));
client.User.Current().Returns(user);
var loginCache = Substitute.For<ILoginCache>();
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
var target = new LoginManager(loginCache, tfa, "id", "secret");
var result = await target.Login(host, client, "foo", "bar");
await client.Authorization.Received(2).GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>());
await client.Authorization.Received(1).Delete(0);
await loginCache.Received().SaveLogin("foo", "123abc", host);
}
[Fact]
public async Task TwoFactorExceptionIsPassedToHandler()
{
var client = Substitute.For<IGitHubClient>();
var exception = new TwoFactorChallengeFailedException();
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
.Returns<ApplicationAuthorization>(_ => { throw exception; });
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>(), "123456")
.Returns(new ApplicationAuthorization("123abc"));
var loginCache = Substitute.For<ILoginCache>();
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
tfa.HandleTwoFactorException(exception).Returns(new TwoFactorChallengeResult("123456"));
var target = new LoginManager(loginCache, tfa, "id", "secret");
await target.Login(host, client, "foo", "bar");
await client.Authorization.Received().GetOrCreateApplicationAuthentication(
"id",
"secret",
Arg.Any<NewAuthorization>(),
"123456");
}
[Fact]
public async Task Failed2FACodeResultsInRetry()
{
var client = Substitute.For<IGitHubClient>();
var exception = new TwoFactorChallengeFailedException();
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
.Returns<ApplicationAuthorization>(_ => { throw exception; });
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>(), "111111")
.Returns<ApplicationAuthorization>(_ => { throw exception; });
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>(), "123456")
.Returns(new ApplicationAuthorization("123abc"));
var loginCache = Substitute.For<ILoginCache>();
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
tfa.HandleTwoFactorException(exception).Returns(
new TwoFactorChallengeResult("111111"),
new TwoFactorChallengeResult("123456"));
var target = new LoginManager(loginCache, tfa, "id", "secret");
await target.Login(host, client, "foo", "bar");
await client.Authorization.Received(1).GetOrCreateApplicationAuthentication(
"id",
"secret",
Arg.Any<NewAuthorization>(),
"111111");
await client.Authorization.Received(1).GetOrCreateApplicationAuthentication(
"id",
"secret",
Arg.Any<NewAuthorization>(),
"123456");
}
[Fact]
public async Task HandlerNotifiedOfExceptionIn2FAChallengeResponse()
{
var client = Substitute.For<IGitHubClient>();
var twoFaException = new TwoFactorChallengeFailedException();
var forbiddenResponse = Substitute.For<IResponse>();
forbiddenResponse.StatusCode.Returns(HttpStatusCode.Forbidden);
var loginAttemptsException = new LoginAttemptsExceededException(forbiddenResponse);
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
.Returns<ApplicationAuthorization>(_ => { throw twoFaException; });
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>(), "111111")
.Returns<ApplicationAuthorization>(_ => { throw loginAttemptsException; });
var loginCache = Substitute.For<ILoginCache>();
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
tfa.HandleTwoFactorException(twoFaException).Returns(
new TwoFactorChallengeResult("111111"),
new TwoFactorChallengeResult("123456"));
var target = new LoginManager(loginCache, tfa, "id", "secret");
Assert.ThrowsAsync<LoginAttemptsExceededException>(async () => await target.Login(host, client, "foo", "bar"));
await client.Authorization.Received(1).GetOrCreateApplicationAuthentication(
"id",
"secret",
Arg.Any<NewAuthorization>(),
"111111");
tfa.Received(1).ChallengeFailed(loginAttemptsException);
}
[Fact]
public async Task RequestResendCodeResultsInRetryingLogin()
{
var client = Substitute.For<IGitHubClient>();
var exception = new TwoFactorChallengeFailedException();
var user = new User();
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
.Returns<ApplicationAuthorization>(_ => { throw exception; });
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>(), "123456")
.Returns(new ApplicationAuthorization("456def"));
client.User.Current().Returns(user);
var loginCache = Substitute.For<ILoginCache>();
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
tfa.HandleTwoFactorException(exception).Returns(
TwoFactorChallengeResult.RequestResendCode,
new TwoFactorChallengeResult("123456"));
var target = new LoginManager(loginCache, tfa, "id", "secret");
await target.Login(host, client, "foo", "bar");
await client.Authorization.Received(2).GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>());
}
[Fact]
public async Task UsesUsernameAndPasswordInsteadOfAuthorizationTokenWhenEnterpriseAndAPIReturns404()
{
var client = Substitute.For<IGitHubClient>();
var user = new User();
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
.Returns<ApplicationAuthorization>(_ =>
{
throw new NotFoundException("Not there", HttpStatusCode.NotFound);
});
client.User.Current().Returns(user);
var loginCache = Substitute.For<ILoginCache>();
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
var target = new LoginManager(loginCache, tfa, "id", "secret");
await target.Login(enterprise, client, "foo", "bar");
await loginCache.Received().SaveLogin("foo", "bar", enterprise);
}
[Fact]
public async Task ErasesLoginWhenUnauthorized()
{
var client = Substitute.For<IGitHubClient>();
var user = new User();
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
.Returns<ApplicationAuthorization>(_ => { throw new AuthorizationException(); });
var loginCache = Substitute.For<ILoginCache>();
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
var target = new LoginManager(loginCache, tfa, "id", "secret");
await Assert.ThrowsAsync<AuthorizationException>(async () => await target.Login(enterprise, client, "foo", "bar"));
await loginCache.Received().EraseLogin(enterprise);
}
[Fact]
public async Task ErasesLoginWhenNonOctokitExceptionThrown()
{
var client = Substitute.For<IGitHubClient>();
var user = new User();
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
.Returns<ApplicationAuthorization>(_ => { throw new InvalidOperationException(); });
var loginCache = Substitute.For<ILoginCache>();
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
var target = new LoginManager(loginCache, tfa, "id", "secret");
await Assert.ThrowsAsync<InvalidOperationException>(async () => await target.Login(host, client, "foo", "bar"));
await loginCache.Received().EraseLogin(host);
}
[Fact]
public async Task ErasesLoginWhenNonOctokitExceptionThrownIn2FA()
{
var client = Substitute.For<IGitHubClient>();
var user = new User();
var exception = new TwoFactorChallengeFailedException();
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
.Returns<ApplicationAuthorization>(_ => { throw exception; });
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>(), "123456")
.Returns<ApplicationAuthorization>(_ => { throw new InvalidOperationException(); });
client.User.Current().Returns(user);
var loginCache = Substitute.For<ILoginCache>();
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
tfa.HandleTwoFactorException(exception).Returns(new TwoFactorChallengeResult("123456"));
var target = new LoginManager(loginCache, tfa, "id", "secret");
await Assert.ThrowsAsync<InvalidOperationException>(async () => await target.Login(host, client, "foo", "bar"));
await loginCache.Received().EraseLogin(host);
}
}
}

Просмотреть файл

@ -1,4 +1,5 @@
using System;
using System.Threading.Tasks;
using GitHub.Api;
using GitHub.Primitives;
using GitHub.Services;
@ -11,7 +12,7 @@ public class SimpleApiClientFactoryTests
public class TheCreateMethod
{
[Fact]
public void CreatesNewInstanceOfSimpleApiClient()
public async Task CreatesNewInstanceOfSimpleApiClient()
{
const string url = "https://github.com/github/CreatesNewInstanceOfSimpleApiClient";
var program = new Program();
@ -22,18 +23,18 @@ public class SimpleApiClientFactoryTests
new Lazy<IEnterpriseProbeTask>(() => enterpriseProbe),
new Lazy<IWikiProbe>(() => wikiProbe));
var client = factory.Create(url);
var client = await factory.Create(url);
Assert.Equal(url, client.OriginalUrl);
Assert.Equal(HostAddress.GitHubDotComHostAddress, client.HostAddress);
Assert.Same(client, factory.Create(url)); // Tests caching.
Assert.Same(client, await factory.Create(url)); // Tests caching.
}
}
public class TheClearFromCacheMethod
{
[Fact]
public void RemovesClientFromCache()
public async Task RemovesClientFromCache()
{
const string url = "https://github.com/github/RemovesClientFromCache";
var program = new Program();
@ -44,7 +45,7 @@ public class SimpleApiClientFactoryTests
new Lazy<IEnterpriseProbeTask>(() => enterpriseProbe),
new Lazy<IWikiProbe>(() => wikiProbe));
var client = factory.Create(url);
var client = await factory.Create(url);
factory.ClearFromCache(client);
Assert.NotSame(client, factory.Create(url));

Просмотреть файл

@ -30,9 +30,11 @@ public class RepositoryHostTests
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("baymax")));
var hostCache = new InMemoryBlobCache();
var modelService = new ModelService(apiClient, hostCache, Substitute.For<IAvatarProvider>());
var loginManager = Substitute.For<ILoginManager>();
loginManager.Login(HostAddress.GitHubDotComHostAddress, Arg.Any<IGitHubClient>(), "baymax", "aPassword").Returns(CreateUserAndScopes("baymax").User);
var loginCache = new TestLoginCache();
var usage = Substitute.For<IUsageTracker>();
var host = new RepositoryHost(apiClient, modelService, loginCache, Substitute.For<ITwoFactorChallengeHandler>(), usage);
var host = new RepositoryHost(apiClient, modelService, loginManager, loginCache, usage);
var result = await host.LogIn("baymax", "aPassword");
@ -40,10 +42,28 @@ public class RepositoryHostTests
var user = await hostCache.GetObject<AccountCacheItem>("user");
Assert.NotNull(user);
Assert.Equal("baymax", user.Login);
var loginInfo = await loginCache.GetLoginAsync(HostAddress.GitHubDotComHostAddress);
Assert.Equal("baymax", loginInfo.UserName);
Assert.Equal("S3CR3TS", loginInfo.Password);
Assert.True(host.IsLoggedIn);
}
[Fact]
public async Task IncrementsLoginCount()
{
var apiClient = Substitute.For<IApiClient>();
apiClient.HostAddress.Returns(HostAddress.GitHubDotComHostAddress);
apiClient.GetOrCreateApplicationAuthenticationCode(
Args.TwoFactorChallengCallback, Args.String, Args.Boolean)
.Returns(Observable.Return(new ApplicationAuthorization("S3CR3TS")));
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("baymax")));
var hostCache = new InMemoryBlobCache();
var modelService = Substitute.For<IModelService>();
var loginManager = Substitute.For<ILoginManager>();
loginManager.Login(HostAddress.GitHubDotComHostAddress, Arg.Any<IGitHubClient>(), "baymax", "aPassword").Returns(CreateUserAndScopes("baymax").User);
var loginCache = new TestLoginCache();
var usage = Substitute.For<IUsageTracker>();
var host = new RepositoryHost(apiClient, modelService, loginManager, loginCache, usage);
var result = await host.LogIn("baymax", "aPassword");
await usage.Received().IncrementLoginCount();
}
[Fact]
@ -51,178 +71,61 @@ public class RepositoryHostTests
{
var apiClient = Substitute.For<IApiClient>();
apiClient.HostAddress.Returns(HostAddress.GitHubDotComHostAddress);
apiClient.GetOrCreateApplicationAuthenticationCode(
Args.TwoFactorChallengCallback, Args.String, Args.Boolean)
.Returns(Observable.Throw<ApplicationAuthorization>(new NotFoundException("", HttpStatusCode.BadGateway)));
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("jiminy")));
var hostCache = new InMemoryBlobCache();
var modelService = new ModelService(apiClient, hostCache, Substitute.For<IAvatarProvider>());
var loginManager = Substitute.For<ILoginManager>();
loginManager.Login(HostAddress.GitHubDotComHostAddress, Arg.Any<IGitHubClient>(), "jiminy", "cricket")
.Returns<User>(_ => { throw new NotFoundException("", HttpStatusCode.BadGateway); });
var loginCache = new TestLoginCache();
var usage = Substitute.For<IUsageTracker>();
var host = new RepositoryHost(apiClient, modelService, loginCache, Substitute.For<ITwoFactorChallengeHandler>(), usage);
var host = new RepositoryHost(apiClient, modelService, loginManager, loginCache, usage);
await Assert.ThrowsAsync<NotFoundException>(async () => await host.LogIn("jiminy", "cricket"));
await Assert.ThrowsAsync<KeyNotFoundException>(
async () => await hostCache.GetObject<AccountCacheItem>("user"));
var loginInfo = await loginCache.GetLoginAsync(HostAddress.GitHubDotComHostAddress);
Assert.Equal("jiminy", loginInfo.UserName);
Assert.Equal("cricket", loginInfo.Password);
Assert.False(host.IsLoggedIn);
}
}
public class TheLoginFromCacheMethod : TestBaseClass
{
[Fact]
public async Task UsesUsernameAndPasswordInsteadOfAuthorizationTokenWhenEnterpriseAndAPIReturns404()
{
var enterpriseHostAddress = HostAddress.Create("https://enterprise.example.com/");
var apiClient = Substitute.For<IApiClient>();
apiClient.HostAddress.Returns(enterpriseHostAddress);
// Throw a 404 on the first try with the new scopes
apiClient.GetOrCreateApplicationAuthenticationCode(Args.TwoFactorChallengCallback, null, false, true)
.Returns(Observable.Throw<ApplicationAuthorization>(new NotFoundException("Not there", HttpStatusCode.NotFound)));
// Throw a 404 on the retry with the old scopes:
apiClient.GetOrCreateApplicationAuthenticationCode(Args.TwoFactorChallengCallback, null, true, false)
.Returns(Observable.Throw<ApplicationAuthorization>(new NotFoundException("Also not there", HttpStatusCode.NotFound)));
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("Cthulu")));
var hostCache = new InMemoryBlobCache();
var modelService = new ModelService(apiClient, hostCache, Substitute.For<IAvatarProvider>());
var loginCache = new TestLoginCache();
var usage = Substitute.For<IUsageTracker>();
var host = new RepositoryHost(apiClient, modelService, loginCache, Substitute.For<ITwoFactorChallengeHandler>(), usage);
var result = await host.LogIn("Cthulu", "aPassword");
Assert.Equal(AuthenticationResult.Success, result);
// Only username and password were saved, never an authorization token:
var loginInfo = await loginCache.GetLoginAsync(enterpriseHostAddress);
Assert.Equal("Cthulu", loginInfo.UserName);
Assert.Equal("aPassword", loginInfo.Password);
Assert.True(host.IsLoggedIn);
}
// Since we're now catching a 401 to detect a bad authoriation token, we need a test to make sure
// real 2FA failures (like putting in a bad code) still make it through (they're also a 401, but
// shouldn't be caught):
[Fact]
public async Task DoesNotFallBackToOldScopesWhenGitHubAndTwoFactorAuthFailsAndErasesLogin()
public async Task LogsTheUserInSuccessfullyAndCachesRelevantInfo()
{
var apiClient = Substitute.For<IApiClient>();
apiClient.HostAddress.Returns(HostAddress.GitHubDotComHostAddress);
bool received1 = false, received2 = false;
apiClient.GetOrCreateApplicationAuthenticationCode(Args.TwoFactorChallengCallback, null, false, true)
.Returns(_ =>
{
received1 = true;
return Observable.Throw<ApplicationAuthorization>(new TwoFactorChallengeFailedException());
});
apiClient.GetOrCreateApplicationAuthenticationCode(Args.TwoFactorChallengCallback,
Args.String,
true,
Args.Boolean)
.Returns(_ =>
{
received2 = true;
return Observable.Throw<ApplicationAuthorization>(new TwoFactorChallengeFailedException());
});
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("jiminy")));
var hostCache = new InMemoryBlobCache();
var modelService = new ModelService(apiClient, hostCache, Substitute.For<IAvatarProvider>());
var loginManager = Substitute.For<ILoginManager>();
loginManager.LoginFromCache(HostAddress.GitHubDotComHostAddress, Arg.Any<IGitHubClient>()).Returns(CreateUserAndScopes("baymax").User);
var loginCache = new TestLoginCache();
var usage = Substitute.For<IUsageTracker>();
var host = new RepositoryHost(apiClient, modelService, loginCache, Substitute.For<ITwoFactorChallengeHandler>(), usage);
var host = new RepositoryHost(apiClient, modelService, loginManager, loginCache, usage);
await host.LogIn("aUsername", "aPassowrd");
var result = await host.LogInFromCache();
Assert.True(received1);
Assert.False(received2);
Assert.False(host.IsLoggedIn);
var loginInfo = await loginCache.GetLoginAsync(HostAddress.GitHubDotComHostAddress);
Assert.Equal("", loginInfo.UserName);
Assert.Equal("", loginInfo.Password);
Assert.Equal(AuthenticationResult.Success, result);
var user = await hostCache.GetObject<AccountCacheItem>("user");
Assert.NotNull(user);
Assert.Equal("baymax", user.Login);
}
[Fact]
public async Task RetriesUsingOldScopeWhenAuthenticationFailsAndIsEnterprise()
{
var enterpriseHostAddress = HostAddress.Create("https://enterprise.example.com/");
var apiClient = Substitute.For<IApiClient>();
apiClient.HostAddress.Returns(enterpriseHostAddress);
apiClient.GetOrCreateApplicationAuthenticationCode(Args.TwoFactorChallengCallback, null, false, true)
.Returns(Observable.Throw<ApplicationAuthorization>(new ApiException("Bad scopes", (HttpStatusCode)422)));
apiClient.GetOrCreateApplicationAuthenticationCode(Args.TwoFactorChallengCallback, null, true, false)
.Returns(Observable.Return(new ApplicationAuthorization("T0k3n")));
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("jiminy")));
var hostCache = new InMemoryBlobCache();
var modelService = new ModelService(apiClient, hostCache, Substitute.For<IAvatarProvider>());
var loginCache = new TestLoginCache();
var usage = Substitute.For<IUsageTracker>();
var host = new RepositoryHost(apiClient, modelService, loginCache, Substitute.For<ITwoFactorChallengeHandler>(), usage);
await host.LogIn("jiminy", "aPassowrd");
Assert.True(host.IsLoggedIn);
var loginInfo = await loginCache.GetLoginAsync(enterpriseHostAddress);
Assert.Equal("jiminy", loginInfo.UserName);
Assert.Equal("T0k3n", loginInfo.Password);
}
[Fact]
public async Task SupportsGistIsTrueWhenGistScopeIsPresent()
public async Task IncrementsLoginCount()
{
var apiClient = Substitute.For<IApiClient>();
apiClient.HostAddress.Returns(HostAddress.GitHubDotComHostAddress);
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("baymax", new[] { "gist" })));
var hostCache = new InMemoryBlobCache();
var modelService = new ModelService(apiClient, hostCache, Substitute.For<IAvatarProvider>());
var modelService = Substitute.For<IModelService>();
var loginManager = Substitute.For<ILoginManager>();
loginManager.LoginFromCache(HostAddress.GitHubDotComHostAddress, Arg.Any<IGitHubClient>()).Returns(CreateUserAndScopes("baymax").User);
var loginCache = new TestLoginCache();
var usage = Substitute.For<IUsageTracker>();
var host = new RepositoryHost(apiClient, modelService, loginCache, Substitute.For<ITwoFactorChallengeHandler>(), usage);
var host = new RepositoryHost(apiClient, modelService, loginManager, loginCache, usage);
var result = await host.LogIn("baymax", "aPassword");
var result = await host.LogInFromCache();
Assert.Equal(AuthenticationResult.Success, result);
Assert.True(host.SupportsGist);
}
[Fact]
public async Task SupportsGistIsFalseWhenGistScopeIsNotPresent()
{
var apiClient = Substitute.For<IApiClient>();
apiClient.HostAddress.Returns(HostAddress.GitHubDotComHostAddress);
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("baymax", new[] { "foo" })));
var hostCache = new InMemoryBlobCache();
var modelService = new ModelService(apiClient, hostCache, Substitute.For<IAvatarProvider>());
var loginCache = new TestLoginCache();
var usage = Substitute.For<IUsageTracker>();
var host = new RepositoryHost(apiClient, modelService, loginCache, Substitute.For<ITwoFactorChallengeHandler>(), usage);
var result = await host.LogIn("baymax", "aPassword");
Assert.Equal(AuthenticationResult.Success, result);
Assert.False(host.SupportsGist);
}
[Fact]
public async Task SupportsGistIsTrueWhenScopesAreNull()
{
// TODO: Check assumptions here. From my conversation with @shana it seems that the first login
// will be done with basic auth and from then on a token will be used. So if it's the first login,
// it's from this version and so gists will be supported. However I've been unable to repro this
// behavior.
var apiClient = Substitute.For<IApiClient>();
apiClient.HostAddress.Returns(HostAddress.GitHubDotComHostAddress);
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("baymax")));
var hostCache = new InMemoryBlobCache();
var modelService = new ModelService(apiClient, hostCache, Substitute.For<IAvatarProvider>());
var loginCache = new TestLoginCache();
var usage = Substitute.For<IUsageTracker>();
var host = new RepositoryHost(apiClient, modelService, loginCache, Substitute.For<ITwoFactorChallengeHandler>(), usage);
var result = await host.LogIn("baymax", "aPassword");
Assert.Equal(AuthenticationResult.Success, result);
Assert.True(host.SupportsGist);
await usage.Received().IncrementLoginCount();
}
}
}

Просмотреть файл

@ -26,19 +26,11 @@ public class LoginToGitHubViewModelTests
.Returns(_ => Observable.Throw<AuthenticationResult>(new ForbiddenException(response)));
var browser = Substitute.For<IVisualStudioBrowser>();
var loginViewModel = new LoginToGitHubViewModel(repositoryHosts, browser);
var message = "";
using (UserError.RegisterHandler<UserError>(x =>
{
message = x.ErrorMessage;
return Observable.Return(RecoveryOptionResult.RetryOperation);
}))
{
loginViewModel.Login.Execute(null);
}
loginViewModel.Login.Execute(null);
Assert.Equal("Make sure to use your password and not a Personal Access token to sign in.",
message);
loginViewModel.Error.ErrorMessage);
}
}

Просмотреть файл

@ -88,8 +88,8 @@ public class RepositoryPublishViewModelTests
public class TheConnectionsProperty : TestBaseClass
{
[Theory]
[InlineData(GitHubUrls.GitHub, "https://ghe.io" )]
[InlineData("https://ghe.io", null)]
[InlineData(GitHubUrls.GitHub, "https://github.enterprise" )]
[InlineData("https://github.enterprise", null)]
[InlineData(GitHubUrls.GitHub, null)]
public void ConnectionsMatchHosts(string arg1, string arg2)
{
@ -123,8 +123,8 @@ public class RepositoryPublishViewModelTests
public class TheSelectedConnectionProperty : TestBaseClass
{
[Theory]
[InlineData(GitHubUrls.GitHub, "https://ghe.io")]
[InlineData("https://ghe.io", GitHubUrls.GitHub)]
[InlineData(GitHubUrls.GitHub, "https://github.enterprise")]
[InlineData("https://github.enterprise", GitHubUrls.GitHub)]
public void DefaultsToGitHub(string arg1, string arg2)
{
var args = Helpers.GetArgs(arg1, arg2);
@ -160,7 +160,7 @@ public class RepositoryPublishViewModelTests
var hsts = new List<IRepositoryHost>();
var conns = new List<IConnection>();
Helpers.SetupConnections(hosts, cm, adds, conns, hsts, GitHubUrls.GitHub);
Helpers.SetupConnections(hosts, cm, adds, conns, hsts, "https://ghe.io");
Helpers.SetupConnections(hosts, cm, adds, conns, hsts, "https://github.enterprise");
var gitHubAccounts = new List<IAccount> { Substitute.For<IAccount>(), Substitute.For<IAccount>() };
var enterpriseAccounts = new List<IAccount> { Substitute.For<IAccount>() };
@ -342,7 +342,7 @@ public class RepositoryPublishViewModelTests
[Fact]
public async Task ResetsWhenSwitchingHosts()
{
var args = Helpers.GetArgs(GitHubUrls.GitHub, "https://ghe.io");
var args = Helpers.GetArgs(GitHubUrls.GitHub, "https://github.enterprise");
var cm = Substitutes.ConnectionManager;
var hosts = Substitute.For<IRepositoryHosts>();

Просмотреть файл

@ -0,0 +1,151 @@
using System;
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;
using System.Threading.Tasks;
using GitHub.Authentication;
using GitHub.Services;
using GitHub.ViewModels;
using NSubstitute;
using Octokit;
using ReactiveUI;
using Xunit;
namespace UnitTests.GitHub.App.ViewModels
{
public class TwoFactorDialogViewModelTests
{
public class TheShowMethod
{
[Fact]
public void ClearsIsBusy()
{
var target = CreateTarget();
var exception = new TwoFactorChallengeFailedException();
target.OkCommand.ExecuteAsync();
target.Show(new TwoFactorRequiredUserError(exception));
Assert.False(target.IsBusy);
}
[Fact]
public void InvalidAuthenticationCodeIsSetWhenRetryFailed()
{
var target = CreateTarget();
var exception = new TwoFactorChallengeFailedException();
target.Show(new TwoFactorRequiredUserError(exception));
Assert.True(target.InvalidAuthenticationCode);
}
[Fact]
public async Task OkCommandCompletesAndReturnsNullWithNoAuthorizationCode()
{
var target = CreateTarget();
var exception = new TwoFactorChallengeFailedException();
var userError = new TwoFactorRequiredUserError(exception);
var task = target.Show(userError).ToTask();
target.OkCommand.Execute(null);
var result = await task;
// This isn't correct but it doesn't matter as the dialog will be closed.
Assert.True(target.IsBusy);
Assert.Null(result);
}
[Fact]
public async Task OkCommandCompletesAndReturnsAuthorizationCode()
{
var target = CreateTarget();
var exception = new TwoFactorChallengeFailedException();
var userError = new TwoFactorRequiredUserError(exception);
var task = target.Show(userError).ToTask();
target.AuthenticationCode = "123456";
target.OkCommand.Execute(null);
var result = await task;
Assert.True(target.IsBusy);
Assert.Equal("123456", result.AuthenticationCode);
}
[Fact]
public async Task ResendCodeCommandCompletesAndReturnsRequestResendCode()
{
var target = CreateTarget();
var exception = new TwoFactorChallengeFailedException();
var userError = new TwoFactorRequiredUserError(exception);
var task = target.Show(userError).ToTask();
target.AuthenticationCode = "123456";
target.ResendCodeCommand.Execute(null);
var result = await task;
Assert.False(target.IsBusy);
Assert.Equal(TwoFactorChallengeResult.RequestResendCode, result);
}
[Fact]
public async Task ShowErrorMessageIsClearedWhenAuthenticationCodeSent()
{
var target = CreateTarget();
var exception = new TwoFactorChallengeFailedException();
var userError = new TwoFactorRequiredUserError(exception);
var task = target.Show(userError).ToTask();
Assert.True(target.ShowErrorMessage);
target.ResendCodeCommand.Execute(null);
var result = await task;
Assert.False(target.ShowErrorMessage);
}
}
public class TheCancelCommand
{
[Fact]
public async Task CancelCommandCompletesAndReturnsNull()
{
var target = CreateTarget();
var exception = new TwoFactorChallengeFailedException();
var userError = new TwoFactorRequiredUserError(exception);
var task = target.Show(userError).ToTask();
target.AuthenticationCode = "123456";
target.Cancel.Execute(null);
var result = await task;
Assert.False(target.IsBusy);
Assert.Null(result);
}
[Fact]
public async Task Cancel_Resets_TwoFactorType()
{
var target = CreateTarget();
var exception = new TwoFactorRequiredException(TwoFactorType.Sms);
var userError = new TwoFactorRequiredUserError(exception);
var task = target.Show(userError).ToTask();
Assert.Equal(TwoFactorType.Sms, target.TwoFactorType);
target.Cancel.Execute(null);
await task;
// TwoFactorType must be cleared here as the UIController uses it as a trigger
// to show the 2FA dialog view.
Assert.Equal(TwoFactorType.None, target.TwoFactorType);
}
}
static TwoFactorDialogViewModel CreateTarget()
{
var browser = Substitute.For<IVisualStudioBrowser>();
var twoFactorChallengeHandler = Substitute.For<IDelegatingTwoFactorChallengeHandler>();
return new TwoFactorDialogViewModel(browser, twoFactorChallengeHandler);
}
}
}

Просмотреть файл

@ -8,55 +8,15 @@ using NSubstitute;
using Rothko;
using Xunit;
using UnitTests;
using System.Collections.Generic;
using System.Linq;
using GitHub.Factories;
using GitHub.Api;
public class ConnectionManagerTests
{
public class TheConnectionsProperty : TestBaseClass
{
[Fact]
public void IsLoadedFromCache()
{
const string cacheData = @"{""connections"":[{""HostUrl"":""https://github.com"", ""UserName"":""shana""},{""HostUrl"":""https://ghe.io"", ""UserName"":""haacked""}]}";
var program = Substitute.For<IProgram>();
program.ApplicationName.Returns("GHfVS");
var operatingSystem = Substitute.For<IOperatingSystem>();
operatingSystem.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData)
.Returns(@"c:\fake");
operatingSystem.File.Exists(@"c:\fake\GHfVS\ghfvs.connections").Returns(true);
operatingSystem.File.ReadAllText(@"c:\fake\GHfVS\ghfvs.connections", Encoding.UTF8).Returns(cacheData);
var manager = new ConnectionManager(program, operatingSystem, Substitutes.IVSGitServices);
var connections = manager.Connections;
Assert.Equal(2, connections.Count);
Assert.Equal(new Uri("https://api.github.com"), connections[0].HostAddress.ApiUri);
Assert.Equal(new Uri("https://ghe.io/api/v3/"), connections[1].HostAddress.ApiUri);
}
[Theory]
[InlineData("|!This ain't no JSON what even is this?")]
[InlineData(null)]
[InlineData("")]
[InlineData("{}")]
[InlineData(@"{""connections"":null}")]
[InlineData(@"{""connections"":{}}")]
public void IsEmptyWhenCacheCorrupt(string cacheJson)
{
var program = Substitute.For<IProgram>();
program.ApplicationName.Returns("GHfVS");
var operatingSystem = Substitute.For<IOperatingSystem>();
operatingSystem.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData)
.Returns(@"c:\fake");
operatingSystem.File.Exists(@"c:\fake\GHfVS\ghfvs.connections").Returns(true);
operatingSystem.File.ReadAllText(@"c:\fake\GHfVS\ghfvs.connections", Encoding.UTF8).Returns(cacheJson);
var manager = new ConnectionManager(program, operatingSystem, Substitutes.IVSGitServices);
var connections = manager.Connections;
Assert.Equal(0, connections.Count);
operatingSystem.File.Received().Delete(@"c:\fake\GHfVS\ghfvs.connections");
}
[Fact]
public void IsSavedToDiskWhenConnectionAdded()
{
@ -67,13 +27,16 @@ public class ConnectionManagerTests
.Returns(@"c:\fake");
operatingSystem.File.Exists(@"c:\fake\GHfVS\ghfvs.connections").Returns(true);
operatingSystem.File.ReadAllText(@"c:\fake\GHfVS\ghfvs.connections", Encoding.UTF8).Returns("");
var manager = new ConnectionManager(program, operatingSystem, Substitutes.IVSGitServices);
var cache = Substitute.For<IConnectionCache>();
var loginManager = Substitute.For<ILoginManager>();
var apiClientFactory = Substitute.For<IApiClientFactory>();
var manager = new ConnectionManager(program, Substitutes.IVSGitServices, cache, loginManager, apiClientFactory);
manager.Connections.Add(new Connection(manager, HostAddress.GitHubDotComHostAddress, "coolio"));
Assert.Equal(1, manager.Connections.Count);
operatingSystem.File.Received().WriteAllText(@"c:\fake\GHfVS\ghfvs.connections",
@"{""connections"":[{""HostUrl"":""https://github.com/"",""UserName"":""coolio""}]}");
cache.Received(1).Save(Arg.Is<IEnumerable<ConnectionDetails>>(x =>
x.SequenceEqual(new[] { new ConnectionDetails(HostAddress.GitHubDotComHostAddress, "coolio") })));
}
}
}

Просмотреть файл

@ -0,0 +1,79 @@
using System;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using GitHub.Models;
using GitHub.Primitives;
using GitHub.VisualStudio;
using NSubstitute;
using Rothko;
using Xunit;
public class JsonConnectionCacheTests
{
public class TheLoadMethod : TestBaseClass
{
[Fact]
public async Task IsLoadedFromCache()
{
const string cacheData = @"{""connections"":[{""HostUrl"":""https://github.com"", ""UserName"":""shana""},{""HostUrl"":""https://github.enterprise"", ""UserName"":""haacked""}]}";
var program = Substitute.For<IProgram>();
program.ApplicationName.Returns("GHfVS");
var operatingSystem = Substitute.For<IOperatingSystem>();
operatingSystem.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData)
.Returns(@"c:\fake");
operatingSystem.File.Exists(@"c:\fake\GHfVS\ghfvs.connections").Returns(true);
operatingSystem.File.ReadAllText(@"c:\fake\GHfVS\ghfvs.connections", Encoding.UTF8).Returns(cacheData);
var cache = new JsonConnectionCache(program, operatingSystem);
var connections = (await cache.Load()).ToList();
Assert.Equal(2, connections.Count);
Assert.Equal(new Uri("https://api.github.com"), connections[0].HostAddress.ApiUri);
Assert.Equal(new Uri("https://github.enterprise/api/v3/"), connections[1].HostAddress.ApiUri);
}
[Theory]
[InlineData("|!This ain't no JSON what even is this?")]
[InlineData(null)]
[InlineData("")]
[InlineData("{}")]
[InlineData(@"{""connections"":null}")]
[InlineData(@"{""connections"":{}}")]
public async Task IsEmptyWhenCacheCorrupt(string cacheJson)
{
var program = Substitute.For<IProgram>();
program.ApplicationName.Returns("GHfVS");
var operatingSystem = Substitute.For<IOperatingSystem>();
operatingSystem.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData)
.Returns(@"c:\fake");
operatingSystem.File.Exists(@"c:\fake\GHfVS\ghfvs.connections").Returns(true);
operatingSystem.File.ReadAllText(@"c:\fake\GHfVS\ghfvs.connections", Encoding.UTF8).Returns(cacheJson);
var cache = new JsonConnectionCache(program, operatingSystem);
var connections = (await cache.Load()).ToList();
Assert.Equal(0, connections.Count);
operatingSystem.File.Received().Delete(@"c:\fake\GHfVS\ghfvs.connections");
}
[Fact]
public async Task IsSavedToDisk()
{
var program = Substitute.For<IProgram>();
program.ApplicationName.Returns("GHfVS");
var operatingSystem = Substitute.For<IOperatingSystem>();
operatingSystem.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData)
.Returns(@"c:\fake");
operatingSystem.File.Exists(@"c:\fake\GHfVS\ghfvs.connections").Returns(true);
operatingSystem.File.ReadAllText(@"c:\fake\GHfVS\ghfvs.connections", Encoding.UTF8).Returns("");
var cache = new JsonConnectionCache(program, operatingSystem);
var connections = (await cache.Load()).ToList();
connections.Add(new ConnectionDetails(HostAddress.GitHubDotComHostAddress, "coolio"));
await cache.Save(connections);
operatingSystem.File.Received().WriteAllText(@"c:\fake\GHfVS\ghfvs.connections",
@"{""connections"":[{""HostUrl"":""https://github.com/"",""UserName"":""coolio""}]}");
}
}
}

Просмотреть файл

@ -64,7 +64,7 @@ namespace UnitTests
public static IRepositoryHosts RepositoryHosts { get { return Substitute.For<IRepositoryHosts>(); } }
public static IConnection Connection { get { return Substitute.For<IConnection>(); } }
public static IConnectionManager ConnectionManager { get { return Substitute.For<IConnectionManager>(); } }
public static ITwoFactorChallengeHandler TwoFactorChallengeHandler { get { return Substitute.For<ITwoFactorChallengeHandler>(); } }
public static IDelegatingTwoFactorChallengeHandler TwoFactorChallengeHandler { get { return Substitute.For<IDelegatingTwoFactorChallengeHandler>(); } }
public static IGistPublishService GistPublishService { get { return Substitute.For<IGistPublishService>(); } }
public static IPullRequestService PullRequestService { get { return Substitute.For<IPullRequestService>(); } }
public static IUIProvider UIProvider { get { return Substitute.For<IUIProvider>(); } }
@ -129,7 +129,7 @@ namespace UnitTests
ret.GetService(typeof(IConnection)).Returns(Connection);
ret.GetService(typeof(IConnectionManager)).Returns(ConnectionManager);
ret.GetService(typeof(IAvatarProvider)).Returns(avatarProvider);
ret.GetService(typeof(ITwoFactorChallengeHandler)).Returns(TwoFactorChallengeHandler);
ret.GetService(typeof(IDelegatingTwoFactorChallengeHandler)).Returns(TwoFactorChallengeHandler);
ret.GetService(typeof(IGistPublishService)).Returns(GistPublishService);
ret.GetService(typeof(IPullRequestService)).Returns(PullRequestService);
ret.GetService(typeof(IUIProvider)).Returns(UIProvider);
@ -200,9 +200,9 @@ namespace UnitTests
return provider.GetService(typeof(IAvatarProvider)) as IAvatarProvider;
}
public static ITwoFactorChallengeHandler GetTwoFactorChallengeHandler(this IServiceProvider provider)
public static IDelegatingTwoFactorChallengeHandler GetTwoFactorChallengeHandler(this IServiceProvider provider)
{
return provider.GetService(typeof(ITwoFactorChallengeHandler)) as ITwoFactorChallengeHandler;
return provider.GetService(typeof(IDelegatingTwoFactorChallengeHandler)) as IDelegatingTwoFactorChallengeHandler;
}
public static IGistPublishService GetGistPublishService(this IServiceProvider provider)

Просмотреть файл

@ -158,9 +158,9 @@
<HintPath>..\..\packages\Microsoft.VisualStudio.TextManager.Interop.8.0.8.0.50727\lib\Microsoft.VisualStudio.TextManager.Interop.8.0.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="NSubstitute, Version=1.8.1.0, Culture=neutral, PublicKeyToken=92dd2e9066daa5ca, processorArchitecture=MSIL">
<SpecificVersion>False</SpecificVersion>
<HintPath>..\..\packages\NSubstitute.1.8.1.0\lib\net45\NSubstitute.dll</HintPath>
<Reference Include="NSubstitute, Version=1.10.0.0, Culture=neutral, PublicKeyToken=92dd2e9066daa5ca, processorArchitecture=MSIL">
<HintPath>..\..\packages\NSubstitute.1.10.0.0\lib\net45\NSubstitute.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
@ -214,6 +214,7 @@
</ItemGroup>
<ItemGroup>
<Compile Include="Args.cs" />
<Compile Include="GitHub.Api\LoginManagerTests.cs" />
<Compile Include="GitHub.Api\SimpleApiClientFactoryTests.cs" />
<Compile Include="GitHub.Api\SimpleApiClientTests.cs" />
<Compile Include="GitHub.App\Caches\CredentialCacheTests.cs" />
@ -239,6 +240,7 @@
<Compile Include="GitHub.App\ViewModels\RepositoryCloneViewModelTests.cs" />
<Compile Include="GitHub.App\ViewModels\RepositoryCreationViewModelTests.cs" />
<Compile Include="GitHub.App\ViewModels\RepositoryPublishViewModelTests.cs" />
<Compile Include="GitHub.App\ViewModels\TwoFactorDialogViewModelTests.cs" />
<Compile Include="GitHub.Exports.Reactive\Caches\AccountCacheItemTests.cs" />
<Compile Include="GitHub.Exports\GitServiceTests.cs" />
<Compile Include="GitHub.Exports\VSServicesTests.cs" />
@ -250,6 +252,7 @@
<Compile Include="GitHub.UI\Converters.cs" />
<Compile Include="GitHub.UI\TestAutomation\ResourceValueTests.cs" />
<Compile Include="GitHub.UI\TwoFactorInputTests.cs" />
<Compile Include="GitHub.VisualStudio\Services\JsonConnectionCacheTests.cs" />
<Compile Include="GitHub.VisualStudio\Services\ConnectionManagerTests.cs" />
<Compile Include="GitHub.VisualStudio\Services\RepositoryPublishServiceTests.cs" />
<Compile Include="GitHub.VisualStudio\TeamExplorer\Home\GraphsNavigationItemTests.cs" />

Просмотреть файл

@ -23,7 +23,7 @@
<package id="Microsoft.VisualStudio.Text.UI.Wpf" version="14.3.25407" targetFramework="net461" />
<package id="Microsoft.VisualStudio.TextManager.Interop" version="7.10.6070" targetFramework="net461" />
<package id="Microsoft.VisualStudio.TextManager.Interop.8.0" version="8.0.50727" targetFramework="net461" />
<package id="NSubstitute" version="1.8.1.0" targetFramework="net45" />
<package id="NSubstitute" version="1.10.0.0" targetFramework="net461" />
<package id="Rx-Core" version="2.2.5-custom" targetFramework="net45" />
<package id="Rx-Interfaces" version="2.2.5-custom" targetFramework="net45" />
<package id="Rx-Linq" version="2.2.5-custom" targetFramework="net45" />

Просмотреть файл

@ -3,8 +3,8 @@ using System.Resources;
using System.Runtime.InteropServices;
[assembly: AssemblyProduct("GitHub Extension for Visual Studio")]
[assembly: AssemblyVersion("2.2.1.100")]
[assembly: AssemblyFileVersion("2.2.1.100")]
[assembly: AssemblyVersion("2.3.0.0")]
[assembly: AssemblyFileVersion("2.3.0.0")]
[assembly: ComVisible(false)]
[assembly: AssemblyCompany("GitHub, Inc.")]
[assembly: AssemblyCopyright("Copyright © GitHub, Inc. 2014-2016")]
@ -16,6 +16,6 @@ using System.Runtime.InteropServices;
namespace System
{
internal static class AssemblyVersionInformation {
internal const string Version = "2.2.1.100";
internal const string Version = "2.3.0.0";
}
}