From 7e5438ccdd65b76e7ee4cd73f043d66f032189fe Mon Sep 17 00:00:00 2001 From: Jelle Buning Date: Thu, 22 Oct 2026 21:37:23 +0200 Subject: [PATCH 1/6] Replace PAT with github login (#3) Sign in with the GitHub account through the OAuth device flow instead of pasting a personal access token. The access token is stored in the existing credential store, and a previously saved token is removed. The OAuth App client id is read from KONTEXT_GITHUB_CLIENT_ID. --- .github/README.md | 6 +- src/Kontext.Contracts/IGitHubDeviceLogin.cs | 10 + src/Kontext.Domain/DeviceCodeChallenge.cs | 3 + src/Kontext.GitHub/DeviceFlowResponses.cs | 107 ++++ src/Kontext.GitHub/GitHubDeviceLogin.cs | 118 ++++ src/Kontext.GitHub/GitHubOptions.cs | 3 + src/Kontext.GitHub/OctokitCall.cs | 2 +- .../ServiceCollectionExtensions.cs | 5 +- src/Kontext.Presentation/ShellViewModel.cs | 40 +- src/Kontext/ServiceCollectionExtensions.cs | 8 +- .../Services/CredentialLockerTokenStore.cs | 22 +- src/Kontext/Views/SignInView.xaml | 32 +- src/Kontext/Views/SignInView.xaml.cs | 12 +- .../CompositionTests.cs | 3 +- .../DeviceFlowResponsesTests.cs | 196 +++++++ .../GitHubDeviceLoginTests.cs | 519 ++++++++++++++++++ .../Kontext.GitHub.Tests.csproj | 1 + .../ShellViewModelTests.cs | 216 +++++++- tests/Kontext.TestSupport/GitHubFakes.cs | 54 ++ 19 files changed, 1295 insertions(+), 62 deletions(-) create mode 100644 src/Kontext.Contracts/IGitHubDeviceLogin.cs create mode 100644 src/Kontext.Domain/DeviceCodeChallenge.cs create mode 100644 src/Kontext.GitHub/DeviceFlowResponses.cs create mode 100644 src/Kontext.GitHub/GitHubDeviceLogin.cs create mode 100644 src/Kontext.GitHub/GitHubOptions.cs create mode 100644 tests/Kontext.GitHub.Tests/DeviceFlowResponsesTests.cs create mode 100644 tests/Kontext.GitHub.Tests/GitHubDeviceLoginTests.cs diff --git a/.github/README.md b/.github/README.md index 4e8c019..badb327 100644 --- a/.github/README.md +++ b/.github/README.md @@ -81,7 +81,11 @@ Before beginning, ensure that your development environment is properly configure * [.NET 10 SDK](https://dotnet.microsoft.com/download) * [Git for Windows](https://git-scm.com/download/win) on your `PATH` * The [Claude Code](https://docs.claude.com/en/docs/claude-code) CLI (`claude`) on your `PATH` -* A GitHub personal access token with the `repo` scope; Kontext asks for it on first start and stores it in Windows Credential Locker +* A GitHub account; you sign in with it on first start (GitHub device flow) and Kontext stores the resulting token in Windows Credential Locker + +### Configuration + +Sign-in needs a GitHub OAuth App with Device Flow enabled. Set `KONTEXT_GITHUB_CLIENT_ID` to its client id before starting Kontext (the client id is not a secret). Organisations that restrict OAuth apps must approve the app before their repositories and issues become available. ### Build and run diff --git a/src/Kontext.Contracts/IGitHubDeviceLogin.cs b/src/Kontext.Contracts/IGitHubDeviceLogin.cs new file mode 100644 index 0000000..fdb6304 --- /dev/null +++ b/src/Kontext.Contracts/IGitHubDeviceLogin.cs @@ -0,0 +1,10 @@ +using Kontext.Domain; + +namespace Kontext.Contracts; + +public interface IGitHubDeviceLogin +{ + Task StartAsync(CancellationToken cancellationToken = default); + + Task WaitForAccessTokenAsync(DeviceCodeChallenge challenge, CancellationToken cancellationToken = default); +} diff --git a/src/Kontext.Domain/DeviceCodeChallenge.cs b/src/Kontext.Domain/DeviceCodeChallenge.cs new file mode 100644 index 0000000..ace83d8 --- /dev/null +++ b/src/Kontext.Domain/DeviceCodeChallenge.cs @@ -0,0 +1,3 @@ +namespace Kontext.Domain; + +public sealed record DeviceCodeChallenge(string DeviceCode, string UserCode, Uri VerificationUri, DateTimeOffset ExpiresAt, TimeSpan PollInterval); diff --git a/src/Kontext.GitHub/DeviceFlowResponses.cs b/src/Kontext.GitHub/DeviceFlowResponses.cs new file mode 100644 index 0000000..e246edc --- /dev/null +++ b/src/Kontext.GitHub/DeviceFlowResponses.cs @@ -0,0 +1,107 @@ +using System.Text.Json; +using Kontext.Domain; + +namespace Kontext.GitHub; + +internal enum DeviceTokenStatus +{ + Granted, + Pending, + SlowDown, + Expired, + Denied, + Failed, +} + +internal sealed record DeviceTokenResult( + DeviceTokenStatus Status, + string? Token = null, + int? IntervalSeconds = null, + string? Error = null, + string? Description = null); + +internal static class DeviceFlowResponses +{ + private const int DefaultIntervalSeconds = 5; + + public static DeviceCodeChallenge ParseDeviceCode(string json, DateTimeOffset now) + { + using var document = Parse(json); + var root = document.RootElement; + + if (Text(root, "error") is { } error) + { + throw new GitHubException(Describe(error, Text(root, "error_description"))); + } + + var deviceCode = Text(root, "device_code"); + var userCode = Text(root, "user_code"); + var verificationUri = Text(root, "verification_uri"); + if (string.IsNullOrWhiteSpace(deviceCode) + || string.IsNullOrWhiteSpace(userCode) + || !Uri.TryCreate(verificationUri, UriKind.Absolute, out var uri) + || !root.TryGetProperty("expires_in", out var expiresIn) + || !expiresIn.TryGetInt32(out var expiresInSeconds)) + { + throw Unexpected(); + } + + var interval = root.TryGetProperty("interval", out var intervalValue) && intervalValue.TryGetInt32(out var seconds) && seconds > 0 + ? seconds + : DefaultIntervalSeconds; + + return new DeviceCodeChallenge(deviceCode, userCode, uri, now.AddSeconds(expiresInSeconds), TimeSpan.FromSeconds(interval)); + } + + public static DeviceTokenResult ParseTokenPoll(string json) + { + using var document = Parse(json); + var root = document.RootElement; + + if (Text(root, "access_token") is { Length: > 0 } token) + { + return new DeviceTokenResult(DeviceTokenStatus.Granted, Token: token); + } + + var error = Text(root, "error"); + var description = Text(root, "error_description"); + return error switch + { + "authorization_pending" => new DeviceTokenResult(DeviceTokenStatus.Pending), + "slow_down" => new DeviceTokenResult( + DeviceTokenStatus.SlowDown, + IntervalSeconds: root.TryGetProperty("interval", out var interval) && interval.TryGetInt32(out var seconds) ? seconds : null), + "expired_token" => new DeviceTokenResult(DeviceTokenStatus.Expired), + "access_denied" => new DeviceTokenResult(DeviceTokenStatus.Denied), + null => throw Unexpected(), + _ => new DeviceTokenResult(DeviceTokenStatus.Failed, Error: error, Description: description), + }; + } + + public static string Describe(string error, string? description) => + string.IsNullOrWhiteSpace(description) ? $"GitHub sign-in failed ({error})." : $"GitHub sign-in failed: {description}"; + + private static JsonDocument Parse(string json) + { + try + { + var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + document.Dispose(); + throw Unexpected(); + } + + return document; + } + catch (JsonException ex) + { + throw new GitHubException("GitHub sent a response Kontext could not read.", ex); + } + } + + private static string? Text(JsonElement root, string name) => + root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + private static GitHubException Unexpected() => new("GitHub sent a response Kontext could not read."); +} diff --git a/src/Kontext.GitHub/GitHubDeviceLogin.cs b/src/Kontext.GitHub/GitHubDeviceLogin.cs new file mode 100644 index 0000000..451287f --- /dev/null +++ b/src/Kontext.GitHub/GitHubDeviceLogin.cs @@ -0,0 +1,118 @@ +using System.Net.Http.Headers; +using Kontext.Contracts; +using Kontext.Domain; + +namespace Kontext.GitHub; + +internal sealed class GitHubDeviceLogin(GitHubOptions options, HttpClient http, TimeProvider time) : IGitHubDeviceLogin +{ + private const string DeviceCodeUrl = "https://github.com/login/device/code"; + private const string AccessTokenUrl = "https://github.com/login/oauth/access_token"; + private const string DeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code"; + private const string Scope = "repo"; + private const string NotConfigured = "GitHub sign-in is not configured. Set the KONTEXT_GITHUB_CLIENT_ID environment variable to the client id of a GitHub OAuth App with device flow enabled, then restart Kontext."; + + private static readonly TimeSpan SlowDownStep = TimeSpan.FromSeconds(5); + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(options.ClientId)) + { + throw new GitHubException(NotConfigured); + } + + var body = await PostAsync( + DeviceCodeUrl, + new Dictionary + { + ["client_id"] = options.ClientId, + ["scope"] = Scope, + }, + cancellationToken); + + var challenge = DeviceFlowResponses.ParseDeviceCode(body, time.GetUtcNow()); + if (challenge.VerificationUri.Scheme != Uri.UriSchemeHttps + || !string.Equals(challenge.VerificationUri.Host, "github.com", StringComparison.OrdinalIgnoreCase)) + { + throw new GitHubException("GitHub sent an unexpected verification address, so the sign-in was stopped."); + } + + return challenge; + } + + public async Task WaitForAccessTokenAsync(DeviceCodeChallenge challenge, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(challenge); + + if (string.IsNullOrWhiteSpace(options.ClientId)) + { + throw new GitHubException(NotConfigured); + } + + var interval = challenge.PollInterval; + while (true) + { + await Task.Delay(interval, time, cancellationToken); + + if (time.GetUtcNow() >= challenge.ExpiresAt) + { + throw Expired(); + } + + var body = await PostAsync( + AccessTokenUrl, + new Dictionary + { + ["client_id"] = options.ClientId, + ["device_code"] = challenge.DeviceCode, + ["grant_type"] = DeviceGrantType, + }, + cancellationToken); + + var result = DeviceFlowResponses.ParseTokenPoll(body); + switch (result.Status) + { + case DeviceTokenStatus.Granted: + return result.Token!; + case DeviceTokenStatus.Pending: + break; + case DeviceTokenStatus.SlowDown: + interval = result.IntervalSeconds is { } seconds && seconds > 0 + ? TimeSpan.FromSeconds(seconds) + : interval + SlowDownStep; + break; + case DeviceTokenStatus.Expired: + throw Expired(); + case DeviceTokenStatus.Denied: + throw new AuthenticationFailedException("Sign-in was cancelled on GitHub."); + default: + throw new GitHubException($"{DeviceFlowResponses.Describe(result.Error ?? "unknown", result.Description)} Check the client id and that device flow is enabled for the GitHub OAuth App."); + } + } + } + + private static AuthenticationFailedException Expired() => new("The sign-in code expired. Please try again."); + + private async Task PostAsync(string url, Dictionary form, CancellationToken cancellationToken) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = new FormUrlEncodedContent(form) }; + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("Kontext", "1.0")); + + using var response = await http.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode && !body.TrimStart().StartsWith('{')) + { + throw new GitHubException($"GitHub returned an error (HTTP {(int)response.StatusCode}) while signing in."); + } + + return body; + } + catch (HttpRequestException ex) + { + throw new GitHubException("Could not reach GitHub. Check your internet connection.", ex); + } + } +} diff --git a/src/Kontext.GitHub/GitHubOptions.cs b/src/Kontext.GitHub/GitHubOptions.cs new file mode 100644 index 0000000..d68faaa --- /dev/null +++ b/src/Kontext.GitHub/GitHubOptions.cs @@ -0,0 +1,3 @@ +namespace Kontext.GitHub; + +public sealed record GitHubOptions(string? ClientId); diff --git a/src/Kontext.GitHub/OctokitCall.cs b/src/Kontext.GitHub/OctokitCall.cs index d5d5316..cd80e50 100644 --- a/src/Kontext.GitHub/OctokitCall.cs +++ b/src/Kontext.GitHub/OctokitCall.cs @@ -12,7 +12,7 @@ public static async Task RunAsync(Func> call, CancellationToken ca } catch (Octokit.AuthorizationException ex) { - throw new AuthenticationFailedException("GitHub rejected this token. Check that it is correct, not expired, and not revoked.", ex); + throw new AuthenticationFailedException("GitHub rejected this sign-in. It may have expired or been revoked.", ex); } catch (Octokit.ApiException ex) { diff --git a/src/Kontext.GitHub/ServiceCollectionExtensions.cs b/src/Kontext.GitHub/ServiceCollectionExtensions.cs index fe01081..c363df8 100644 --- a/src/Kontext.GitHub/ServiceCollectionExtensions.cs +++ b/src/Kontext.GitHub/ServiceCollectionExtensions.cs @@ -5,8 +5,11 @@ namespace Kontext.GitHub; public static class ServiceCollectionExtensions { - public static IServiceCollection AddKontextGitHub(this IServiceCollection services) + public static IServiceCollection AddKontextGitHub(this IServiceCollection services, GitHubOptions options) { + services.AddSingleton(options); + services.AddSingleton(new HttpClient()); + services.AddSingleton(); services.AddSingleton(); return services; } diff --git a/src/Kontext.Presentation/ShellViewModel.cs b/src/Kontext.Presentation/ShellViewModel.cs index 2ad2b96..dea8ece 100644 --- a/src/Kontext.Presentation/ShellViewModel.cs +++ b/src/Kontext.Presentation/ShellViewModel.cs @@ -6,7 +6,7 @@ namespace Kontext.Presentation; -public sealed partial class ShellViewModel(IGitHubService gitHub, IGitHubTokenStore tokenStore, WorkspaceViewModel workspace) : ObservableObject +public sealed partial class ShellViewModel(IGitHubService gitHub, IGitHubDeviceLogin deviceLogin, IGitHubTokenStore tokenStore, WorkspaceViewModel workspace) : ObservableObject { public WorkspaceViewModel Workspace { get; } = workspace; @@ -18,10 +18,6 @@ public sealed partial class ShellViewModel(IGitHubService gitHub, IGitHubTokenSt [NotifyPropertyChangedFor(nameof(ShowSignIn))] public partial bool IsStarting { get; set; } = true; - [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(SignInCommand))] - public partial string Token { get; set; } = string.Empty; - [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SignInCommand))] public partial bool IsBusy { get; set; } @@ -30,6 +26,15 @@ public sealed partial class ShellViewModel(IGitHubService gitHub, IGitHubTokenSt [NotifyPropertyChangedFor(nameof(HasError))] public partial string? ErrorMessage { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasDeviceCode))] + public partial string? UserCode { get; set; } + + [ObservableProperty] + public partial Uri? VerificationUrl { get; set; } + + public bool HasDeviceCode => !string.IsNullOrEmpty(UserCode); + public bool IsSignedIn => User is not null; public bool IsSignedOut => User is null; @@ -70,7 +75,7 @@ private async Task SignInWithSavedTokenAsync(string savedToken) catch (AuthenticationFailedException) { tokenStore.Clear(); - ErrorMessage = "Your saved GitHub token is no longer valid. Please sign in again."; + ErrorMessage = "Your saved GitHub sign-in is no longer valid. Please sign in again."; } catch (KontextException ex) { @@ -82,25 +87,33 @@ private async Task SignInWithSavedTokenAsync(string savedToken) } } - [RelayCommand(CanExecute = nameof(CanSignIn))] - private async Task SignInAsync() + [RelayCommand(IncludeCancelCommand = true, CanExecute = nameof(CanSignIn))] + private async Task SignInAsync(CancellationToken cancellationToken) { ErrorMessage = null; IsBusy = true; try { - var token = Token.Trim(); - User = await gitHub.AuthenticateAsync(token); + var challenge = await deviceLogin.StartAsync(cancellationToken); + UserCode = challenge.UserCode; + VerificationUrl = challenge.VerificationUri; + + var token = await deviceLogin.WaitForAccessTokenAsync(challenge, cancellationToken); + User = await gitHub.AuthenticateAsync(token, cancellationToken); tokenStore.Save(token); - Token = string.Empty; Workspace.Sidebar.RefreshCommand.Execute(null); } + catch (OperationCanceledException) + { + } catch (KontextException ex) { ErrorMessage = ex.Message; } finally { + UserCode = null; + VerificationUrl = null; IsBusy = false; } } @@ -108,6 +121,9 @@ private async Task SignInAsync() [RelayCommand] private void SignOut() { + SignInCancelCommand.Execute(null); + UserCode = null; + VerificationUrl = null; gitHub.SignOut(); tokenStore.Clear(); Workspace.Sidebar.Clear(); @@ -116,5 +132,5 @@ private void SignOut() User = null; } - private bool CanSignIn() => !IsBusy && !string.IsNullOrWhiteSpace(Token); + private bool CanSignIn() => !IsBusy; } diff --git a/src/Kontext/ServiceCollectionExtensions.cs b/src/Kontext/ServiceCollectionExtensions.cs index 325e43c..a01d8d5 100644 --- a/src/Kontext/ServiceCollectionExtensions.cs +++ b/src/Kontext/ServiceCollectionExtensions.cs @@ -26,7 +26,7 @@ public static IServiceCollection AddKontext(this IServiceCollection services) : overridden; services - .AddKontextGitHub() + .AddKontextGitHub(new GitHubOptions(GitHubClientId())) .AddKontextGitAutomation(new GitAutomationOptions( StateDirectory: dataRoot, WorktreesRoot: Path.Combine(dataRoot, "worktrees"), @@ -59,6 +59,12 @@ public static IServiceCollection AddKontext(this IServiceCollection services) return services; } + private static string? GitHubClientId() + { + var configured = Environment.GetEnvironmentVariable("KONTEXT_GITHUB_CLIENT_ID"); + return string.IsNullOrWhiteSpace(configured) ? null : configured.Trim(); + } + private static string ClaudeConfigDirectory() { var configured = Environment.GetEnvironmentVariable("CLAUDE_CONFIG_DIR"); diff --git a/src/Kontext/Services/CredentialLockerTokenStore.cs b/src/Kontext/Services/CredentialLockerTokenStore.cs index 848f85d..69d15c6 100644 --- a/src/Kontext/Services/CredentialLockerTokenStore.cs +++ b/src/Kontext/Services/CredentialLockerTokenStore.cs @@ -6,14 +6,17 @@ namespace Kontext.Services; internal sealed class CredentialLockerTokenStore : IGitHubTokenStore { private const string Resource = "Kontext/GitHub"; - private const string UserName = "personal-access-token"; + private const string UserName = "github-oauth-token"; + private const string LegacyUserName = "personal-access-token"; private const int ElementNotFound = unchecked((int)0x80070490); private readonly PasswordVault _vault = new(); public string? Load() { - var credential = Find(); + RemoveLegacy(); + + var credential = Find(UserName); credential?.RetrievePassword(); return credential?.Password; } @@ -22,18 +25,27 @@ internal sealed class CredentialLockerTokenStore : IGitHubTokenStore public void Clear() { - var credential = Find(); + var credential = Find(UserName); if (credential is not null) { _vault.Remove(credential); } } - private PasswordCredential? Find() + private void RemoveLegacy() + { + var legacy = Find(LegacyUserName); + if (legacy is not null) + { + _vault.Remove(legacy); + } + } + + private PasswordCredential? Find(string userName) { try { - return _vault.Retrieve(Resource, UserName); + return _vault.Retrieve(Resource, userName); } catch (Exception ex) when (ex.HResult == ElementNotFound) { diff --git a/src/Kontext/Views/SignInView.xaml b/src/Kontext/Views/SignInView.xaml index 3c95922..560f72b 100644 --- a/src/Kontext/Views/SignInView.xaml +++ b/src/Kontext/Views/SignInView.xaml @@ -22,20 +22,20 @@ Foreground="{ThemeResource TextFillColorSecondaryBrush}" Style="{StaticResource BodyTextBlockStyle}" TextWrapping="Wrap"> - Kontext uses a personal access token to read your boards and issues. The token is stored encrypted in Windows Credential Locker. + Kontext signs you in with your GitHub account. You approve it on github.com; no password or token is typed here. - - - + + + + + + - - - - - - " + } +} diff --git a/tests/Kontext.Claude.Tests/ClaudeTranscriptsTests.cs b/tests/Kontext.Claude.IntegrationTests/ClaudeTranscriptsTests.cs similarity index 97% rename from tests/Kontext.Claude.Tests/ClaudeTranscriptsTests.cs rename to tests/Kontext.Claude.IntegrationTests/ClaudeTranscriptsTests.cs index 62f7e1c..bddfc6e 100644 --- a/tests/Kontext.Claude.Tests/ClaudeTranscriptsTests.cs +++ b/tests/Kontext.Claude.IntegrationTests/ClaudeTranscriptsTests.cs @@ -1,6 +1,6 @@ using Kontext.Domain; -namespace Kontext.Claude.Tests; +namespace Kontext.Claude.IntegrationTests; [TestFixture] public class ClaudeTranscriptsTests diff --git a/tests/Kontext.Claude.IntegrationTests/Kontext.Claude.IntegrationTests.csproj b/tests/Kontext.Claude.IntegrationTests/Kontext.Claude.IntegrationTests.csproj new file mode 100644 index 0000000..800de4f --- /dev/null +++ b/tests/Kontext.Claude.IntegrationTests/Kontext.Claude.IntegrationTests.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + Kontext.Claude.IntegrationTests + false + true + + + + + + + + + all + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/tests/Kontext.Claude.Tests/LoopbackClaudeHookServerTests.cs b/tests/Kontext.Claude.IntegrationTests/LoopbackClaudeHookServerTests.cs similarity index 99% rename from tests/Kontext.Claude.Tests/LoopbackClaudeHookServerTests.cs rename to tests/Kontext.Claude.IntegrationTests/LoopbackClaudeHookServerTests.cs index e4ab12a..f59b6c5 100644 --- a/tests/Kontext.Claude.Tests/LoopbackClaudeHookServerTests.cs +++ b/tests/Kontext.Claude.IntegrationTests/LoopbackClaudeHookServerTests.cs @@ -3,7 +3,7 @@ using Kontext.Domain; using Kontext.TestSupport; -namespace Kontext.Claude.Tests; +namespace Kontext.Claude.IntegrationTests; [TestFixture] public class LoopbackClaudeHookServerTests diff --git a/tests/Kontext.Claude.Tests/ClaudeActivityMapperTests.cs b/tests/Kontext.Claude.UnitTests/ClaudeActivityMapperTests.cs similarity index 98% rename from tests/Kontext.Claude.Tests/ClaudeActivityMapperTests.cs rename to tests/Kontext.Claude.UnitTests/ClaudeActivityMapperTests.cs index cf08947..34adcf8 100644 --- a/tests/Kontext.Claude.Tests/ClaudeActivityMapperTests.cs +++ b/tests/Kontext.Claude.UnitTests/ClaudeActivityMapperTests.cs @@ -1,6 +1,6 @@ using Kontext.Domain; -namespace Kontext.Claude.Tests; +namespace Kontext.Claude.UnitTests; [TestFixture] public class ClaudeActivityMapperTests diff --git a/tests/Kontext.Claude.Tests/ClaudeActivityReporterTests.cs b/tests/Kontext.Claude.UnitTests/ClaudeActivityReporterTests.cs similarity index 98% rename from tests/Kontext.Claude.Tests/ClaudeActivityReporterTests.cs rename to tests/Kontext.Claude.UnitTests/ClaudeActivityReporterTests.cs index 977a42d..8620071 100644 --- a/tests/Kontext.Claude.Tests/ClaudeActivityReporterTests.cs +++ b/tests/Kontext.Claude.UnitTests/ClaudeActivityReporterTests.cs @@ -2,7 +2,7 @@ using Kontext.Kernel; using Kontext.TestSupport; -namespace Kontext.Claude.Tests; +namespace Kontext.Claude.UnitTests; [TestFixture] public class ClaudeActivityReporterTests diff --git a/tests/Kontext.Claude.Tests/ClaudeEnvironmentScrubberTests.cs b/tests/Kontext.Claude.UnitTests/ClaudeEnvironmentScrubberTests.cs similarity index 98% rename from tests/Kontext.Claude.Tests/ClaudeEnvironmentScrubberTests.cs rename to tests/Kontext.Claude.UnitTests/ClaudeEnvironmentScrubberTests.cs index 3b37326..16bc25c 100644 --- a/tests/Kontext.Claude.Tests/ClaudeEnvironmentScrubberTests.cs +++ b/tests/Kontext.Claude.UnitTests/ClaudeEnvironmentScrubberTests.cs @@ -1,4 +1,4 @@ -namespace Kontext.Claude.Tests; +namespace Kontext.Claude.UnitTests; [TestFixture] public class ClaudeEnvironmentScrubberTests diff --git a/tests/Kontext.Claude.Tests/ClaudeFakes.cs b/tests/Kontext.Claude.UnitTests/ClaudeFakes.cs similarity index 94% rename from tests/Kontext.Claude.Tests/ClaudeFakes.cs rename to tests/Kontext.Claude.UnitTests/ClaudeFakes.cs index ede39a2..f829c66 100644 --- a/tests/Kontext.Claude.Tests/ClaudeFakes.cs +++ b/tests/Kontext.Claude.UnitTests/ClaudeFakes.cs @@ -1,6 +1,6 @@ using Kontext.Domain; -namespace Kontext.Claude.Tests; +namespace Kontext.Claude.UnitTests; internal sealed class FakeClaudeHooks : IClaudeHooks { public const string DefaultSettings = "{\"hooks\":{}}"; diff --git a/tests/Kontext.Claude.Tests/ClaudeHookPayloadTests.cs b/tests/Kontext.Claude.UnitTests/ClaudeHookPayloadTests.cs similarity index 96% rename from tests/Kontext.Claude.Tests/ClaudeHookPayloadTests.cs rename to tests/Kontext.Claude.UnitTests/ClaudeHookPayloadTests.cs index 1303667..8897ffc 100644 --- a/tests/Kontext.Claude.Tests/ClaudeHookPayloadTests.cs +++ b/tests/Kontext.Claude.UnitTests/ClaudeHookPayloadTests.cs @@ -1,5 +1,5 @@ -namespace Kontext.Claude.Tests; +namespace Kontext.Claude.UnitTests; [TestFixture] public class ClaudeHookPayloadTests diff --git a/tests/Kontext.Claude.Tests/ClaudeHookSettingsBuilderTests.cs b/tests/Kontext.Claude.UnitTests/ClaudeHookSettingsBuilderTests.cs similarity index 98% rename from tests/Kontext.Claude.Tests/ClaudeHookSettingsBuilderTests.cs rename to tests/Kontext.Claude.UnitTests/ClaudeHookSettingsBuilderTests.cs index 3a16a24..9a4de43 100644 --- a/tests/Kontext.Claude.Tests/ClaudeHookSettingsBuilderTests.cs +++ b/tests/Kontext.Claude.UnitTests/ClaudeHookSettingsBuilderTests.cs @@ -1,7 +1,7 @@ using System.Text.Json; using Kontext.Domain; -namespace Kontext.Claude.Tests; +namespace Kontext.Claude.UnitTests; [TestFixture] public class ClaudeHookSettingsBuilderTests diff --git a/tests/Kontext.Claude.Tests/ClaudeHooksTests.cs b/tests/Kontext.Claude.UnitTests/ClaudeHooksTests.cs similarity index 98% rename from tests/Kontext.Claude.Tests/ClaudeHooksTests.cs rename to tests/Kontext.Claude.UnitTests/ClaudeHooksTests.cs index b14a8a3..19a1dd1 100644 --- a/tests/Kontext.Claude.Tests/ClaudeHooksTests.cs +++ b/tests/Kontext.Claude.UnitTests/ClaudeHooksTests.cs @@ -2,7 +2,7 @@ using Kontext.Kernel; using Kontext.TestSupport; -namespace Kontext.Claude.Tests; +namespace Kontext.Claude.UnitTests; [TestFixture] public class ClaudeHooksTests diff --git a/tests/Kontext.Claude.Tests/ClaudeLaunchBuilderTests.cs b/tests/Kontext.Claude.UnitTests/ClaudeLaunchBuilderTests.cs similarity index 98% rename from tests/Kontext.Claude.Tests/ClaudeLaunchBuilderTests.cs rename to tests/Kontext.Claude.UnitTests/ClaudeLaunchBuilderTests.cs index 1d721a4..2800371 100644 --- a/tests/Kontext.Claude.Tests/ClaudeLaunchBuilderTests.cs +++ b/tests/Kontext.Claude.UnitTests/ClaudeLaunchBuilderTests.cs @@ -1,7 +1,7 @@ using Kontext.Domain; using Kontext.TestSupport; -namespace Kontext.Claude.Tests; +namespace Kontext.Claude.UnitTests; [TestFixture] public class ClaudeLaunchBuilderTests diff --git a/tests/Kontext.Claude.Tests/ClaudeLauncherTests.cs b/tests/Kontext.Claude.UnitTests/ClaudeLauncherTests.cs similarity index 99% rename from tests/Kontext.Claude.Tests/ClaudeLauncherTests.cs rename to tests/Kontext.Claude.UnitTests/ClaudeLauncherTests.cs index 88f7a18..0389f21 100644 --- a/tests/Kontext.Claude.Tests/ClaudeLauncherTests.cs +++ b/tests/Kontext.Claude.UnitTests/ClaudeLauncherTests.cs @@ -2,7 +2,7 @@ using Kontext.Kernel; using Kontext.TestSupport; -namespace Kontext.Claude.Tests; +namespace Kontext.Claude.UnitTests; [TestFixture] public class ClaudeLauncherTests diff --git a/tests/Kontext.Claude.Tests/Kontext.Claude.Tests.csproj b/tests/Kontext.Claude.UnitTests/Kontext.Claude.UnitTests.csproj similarity index 95% rename from tests/Kontext.Claude.Tests/Kontext.Claude.Tests.csproj rename to tests/Kontext.Claude.UnitTests/Kontext.Claude.UnitTests.csproj index 350c055..392458d 100644 --- a/tests/Kontext.Claude.Tests/Kontext.Claude.Tests.csproj +++ b/tests/Kontext.Claude.UnitTests/Kontext.Claude.UnitTests.csproj @@ -2,7 +2,7 @@ net10.0 - Kontext.Claude.Tests + Kontext.Claude.UnitTests false true diff --git a/tests/Kontext.ExternalTools.Tests/Fakes.cs b/tests/Kontext.ExternalTools.UnitTests/Fakes.cs similarity index 86% rename from tests/Kontext.ExternalTools.Tests/Fakes.cs rename to tests/Kontext.ExternalTools.UnitTests/Fakes.cs index 5afc044..a739aeb 100644 --- a/tests/Kontext.ExternalTools.Tests/Fakes.cs +++ b/tests/Kontext.ExternalTools.UnitTests/Fakes.cs @@ -1,6 +1,6 @@ using Kontext.Domain; -namespace Kontext.ExternalTools.Tests; +namespace Kontext.ExternalTools.UnitTests; internal sealed class FakeProcessStarter : IProcessStarter { @@ -39,8 +39,3 @@ public IReadOnlyList Find(string folder, string pattern) : []; } } - -internal sealed class FakeVisualStudioLocator(string? devenv) : IVisualStudioLocator -{ - public string? FindDevenv() => devenv; -} diff --git a/tests/Kontext.ExternalTools.Tests/Kontext.ExternalTools.Tests.csproj b/tests/Kontext.ExternalTools.UnitTests/Kontext.ExternalTools.UnitTests.csproj similarity index 94% rename from tests/Kontext.ExternalTools.Tests/Kontext.ExternalTools.Tests.csproj rename to tests/Kontext.ExternalTools.UnitTests/Kontext.ExternalTools.UnitTests.csproj index 1b9c380..9fc2fec 100644 --- a/tests/Kontext.ExternalTools.Tests/Kontext.ExternalTools.Tests.csproj +++ b/tests/Kontext.ExternalTools.UnitTests/Kontext.ExternalTools.UnitTests.csproj @@ -2,7 +2,7 @@ net10.0 - Kontext.ExternalTools.Tests + Kontext.ExternalTools.UnitTests false true diff --git a/tests/Kontext.ExternalTools.Tests/ToolTests.cs b/tests/Kontext.ExternalTools.UnitTests/ToolTests.cs similarity index 66% rename from tests/Kontext.ExternalTools.Tests/ToolTests.cs rename to tests/Kontext.ExternalTools.UnitTests/ToolTests.cs index 26c39a0..6fb2598 100644 --- a/tests/Kontext.ExternalTools.Tests/ToolTests.cs +++ b/tests/Kontext.ExternalTools.UnitTests/ToolTests.cs @@ -1,7 +1,7 @@ using Kontext.Domain; using Kontext.TestSupport; -namespace Kontext.ExternalTools.Tests; +namespace Kontext.ExternalTools.UnitTests; [TestFixture] public class ToolTests @@ -49,45 +49,35 @@ public void TheTerminalFallsBackToPowerShellWhenWindowsTerminalIsNotInstalled() } [Test] - public void VisualStudioOpensTheSolutionOfTheFolder() + public void TheSolutionOfTheFolderIsOpenedWithItsDefaultApp() { var files = new FakeFolderFiles().WithFolder(Folder, @"C:\wt\4-add\App.sln"); - var tool = new VisualStudioTool(files, new FakeVisualStudioLocator(null), _processes); + var tool = new SolutionTool(files, _processes); tool.Open(Folder); - Assert.That(tool.Tool, Is.EqualTo(WorkspaceTool.VisualStudio)); + Assert.That(tool.Tool, Is.EqualTo(WorkspaceTool.Solution)); Assert.That(_processes.Started.Single().FileName, Is.EqualTo(@"C:\wt\4-add\App.sln")); Assert.That(_processes.Started.Single().UseShellExecute, Is.True); } [Test] - public void VisualStudioPrefersTheNewSolutionFormat() + public void TheNewSolutionFormatIsPreferred() { var files = new FakeFolderFiles().WithFolder(Folder, @"C:\wt\4-add\App.sln", @"C:\wt\4-add\App.slnx"); - new VisualStudioTool(files, new FakeVisualStudioLocator(null), _processes).Open(Folder); + new SolutionTool(files, _processes).Open(Folder); Assert.That(_processes.Started.Single().FileName, Is.EqualTo(@"C:\wt\4-add\App.slnx")); } [Test] - public void VisualStudioOpensTheFolderItselfWhenThereIsNoSolution() + public void SaysSoWhenTheFolderHasNoSolution() { var files = new FakeFolderFiles().WithFolder(Folder); + var tool = new SolutionTool(files, _processes); - new VisualStudioTool(files, new FakeVisualStudioLocator(@"C:\VS\devenv.exe"), _processes).Open(Folder); - - Assert.That(_processes.Started.Single().FileName, Is.EqualTo(@"C:\VS\devenv.exe")); - Assert.That(_processes.Started.Single().Arguments, Is.EqualTo(Expect.Seq(Folder))); - } - - [Test] - public void VisualStudioSaysSoWhenItIsNotInstalled() - { - var files = new FakeFolderFiles().WithFolder(Folder); - var tool = new VisualStudioTool(files, new FakeVisualStudioLocator(null), _processes); - - Assert.That(() => tool.Open(Folder), Throws.TypeOf().With.Message.Contains("Visual Studio")); + Assert.That(() => tool.Open(Folder), Throws.TypeOf().With.Message.Contains("solution")); + Assert.That(_processes.Started, Is.Empty); } } diff --git a/tests/Kontext.ExternalTools.Tests/WorkspaceOpenerTests.cs b/tests/Kontext.ExternalTools.UnitTests/WorkspaceOpenerTests.cs similarity index 89% rename from tests/Kontext.ExternalTools.Tests/WorkspaceOpenerTests.cs rename to tests/Kontext.ExternalTools.UnitTests/WorkspaceOpenerTests.cs index d80ba74..abbf665 100644 --- a/tests/Kontext.ExternalTools.Tests/WorkspaceOpenerTests.cs +++ b/tests/Kontext.ExternalTools.UnitTests/WorkspaceOpenerTests.cs @@ -1,6 +1,6 @@ using Kontext.Domain; -namespace Kontext.ExternalTools.Tests; +namespace Kontext.ExternalTools.UnitTests; [TestFixture] public class WorkspaceOpenerTests @@ -47,6 +47,6 @@ public void SaysSoWhenTheFolderIsGone() [Test] public void SaysSoWhenNoToolKnowsHowToOpenIt() { - Assert.That(() => _opener.Open(WorkspaceTool.VisualStudio, Folder), Throws.TypeOf()); + Assert.That(() => _opener.Open(WorkspaceTool.Solution, Folder), Throws.TypeOf()); } } diff --git a/tests/Kontext.GitAutomation.IntegrationTests/Fakes.cs b/tests/Kontext.GitAutomation.IntegrationTests/Fakes.cs new file mode 100644 index 0000000..ac3d6b4 --- /dev/null +++ b/tests/Kontext.GitAutomation.IntegrationTests/Fakes.cs @@ -0,0 +1,28 @@ +using Kontext.Domain; +using Kontext.Kernel; + +namespace Kontext.GitAutomation.IntegrationTests; + +internal sealed class FakeGitProcess : IGitProcess +{ + public List<(string WorkingDirectory, string[] Arguments)> Runs { get; } = []; + + public GitResult Result { get; set; } = new(0, string.Empty, string.Empty); + + public Task RunAsync(string workingDirectory, IReadOnlyList arguments, CancellationToken cancellationToken = default) + { + Runs.Add((workingDirectory, [.. arguments])); + return Task.FromResult(Result); + } +} + +internal sealed class NoGitAuthentication : IGitAuthentication +{ + public IReadOnlyDictionary EnvironmentVariables() => new Dictionary(); +} + +internal sealed class FakeCloneResolver(RepositoryRef answer) : IRepositoryCloneResolver +{ + public Task ResolveAsync(RepositoryRef repository, CancellationToken cancellationToken = default) + => Task.FromResult(answer); +} diff --git a/tests/Kontext.GitAutomation.Tests/GitWorktreeServiceIntegrationTests.cs b/tests/Kontext.GitAutomation.IntegrationTests/GitWorktreeServiceIntegrationTests.cs similarity index 98% rename from tests/Kontext.GitAutomation.Tests/GitWorktreeServiceIntegrationTests.cs rename to tests/Kontext.GitAutomation.IntegrationTests/GitWorktreeServiceIntegrationTests.cs index 7567994..26990dd 100644 --- a/tests/Kontext.GitAutomation.Tests/GitWorktreeServiceIntegrationTests.cs +++ b/tests/Kontext.GitAutomation.IntegrationTests/GitWorktreeServiceIntegrationTests.cs @@ -1,7 +1,8 @@ using Kontext.Domain; using Kontext.Kernel; +using Kontext.TestSupport; -namespace Kontext.GitAutomation.Tests; +namespace Kontext.GitAutomation.IntegrationTests; [TestFixture] public class GitWorktreeServiceIntegrationTests @@ -90,7 +91,7 @@ public async Task ReusesAnExistingBranchWhenItsWorktreeWasRemoved() [Test] public async Task ARealWorktreeIsRediscoveredByTheFinderInALaterRun() { - var issue = TestIssues.Create(4, "Add notifications", "acme", "app"); + var issue = Issues.Create(4, "Add notifications", "acme/app"); var branch = new BranchNamer().Name(issue); var created = await _service.CreateAsync(new WorktreeRequest(_repository, branch)); var store = new FakeCloneStore { Saved = { ["acme/app"] = _clone } }; @@ -105,7 +106,7 @@ public async Task ARealWorktreeIsRediscoveredByTheFinderInALaterRun() [Test] public async Task ProvisioningAnIssueWhoseWorktreeWasMadeUnderTheEarlierPrefixReusesItInsteadOfFailing() { - var issue = TestIssues.Create(4, "Add notifications", "acme", "app"); + var issue = Issues.Create(4, "Add notifications", "acme/app"); var earlier = await _service.CreateAsync(new WorktreeRequest(_repository, "kontext/4-add-notifications")); var provisioner = new WorkspaceProvisioner(new FakeCloneResolver(_repository), new BranchNamer(), _service); diff --git a/tests/Kontext.GitAutomation.Tests/JsonRepositoryCloneStoreTests.cs b/tests/Kontext.GitAutomation.IntegrationTests/JsonRepositoryCloneStoreTests.cs similarity index 97% rename from tests/Kontext.GitAutomation.Tests/JsonRepositoryCloneStoreTests.cs rename to tests/Kontext.GitAutomation.IntegrationTests/JsonRepositoryCloneStoreTests.cs index 5fc0038..9436b1a 100644 --- a/tests/Kontext.GitAutomation.Tests/JsonRepositoryCloneStoreTests.cs +++ b/tests/Kontext.GitAutomation.IntegrationTests/JsonRepositoryCloneStoreTests.cs @@ -1,6 +1,7 @@ using Kontext.Kernel; +using Kontext.TestSupport; -namespace Kontext.GitAutomation.Tests; +namespace Kontext.GitAutomation.IntegrationTests; [TestFixture] public class JsonRepositoryCloneStoreTests diff --git a/tests/Kontext.GitAutomation.Tests/Kontext.GitAutomation.Tests.csproj b/tests/Kontext.GitAutomation.IntegrationTests/Kontext.GitAutomation.IntegrationTests.csproj similarity index 91% rename from tests/Kontext.GitAutomation.Tests/Kontext.GitAutomation.Tests.csproj rename to tests/Kontext.GitAutomation.IntegrationTests/Kontext.GitAutomation.IntegrationTests.csproj index bc61cb7..7f0d09a 100644 --- a/tests/Kontext.GitAutomation.Tests/Kontext.GitAutomation.Tests.csproj +++ b/tests/Kontext.GitAutomation.IntegrationTests/Kontext.GitAutomation.IntegrationTests.csproj @@ -25,6 +25,7 @@ + diff --git a/tests/Kontext.GitAutomation.Tests/RepositoryClonerTests.cs b/tests/Kontext.GitAutomation.IntegrationTests/RepositoryClonerTests.cs similarity index 98% rename from tests/Kontext.GitAutomation.Tests/RepositoryClonerTests.cs rename to tests/Kontext.GitAutomation.IntegrationTests/RepositoryClonerTests.cs index de56f49..647ca5c 100644 --- a/tests/Kontext.GitAutomation.Tests/RepositoryClonerTests.cs +++ b/tests/Kontext.GitAutomation.IntegrationTests/RepositoryClonerTests.cs @@ -1,6 +1,7 @@ using Kontext.Kernel; +using Kontext.TestSupport; -namespace Kontext.GitAutomation.Tests; +namespace Kontext.GitAutomation.IntegrationTests; [TestFixture] public class RepositoryClonerTests diff --git a/tests/Kontext.GitAutomation.Tests/TempDirectory.cs b/tests/Kontext.GitAutomation.IntegrationTests/TempDirectory.cs similarity index 92% rename from tests/Kontext.GitAutomation.Tests/TempDirectory.cs rename to tests/Kontext.GitAutomation.IntegrationTests/TempDirectory.cs index d361db6..ae1d703 100644 --- a/tests/Kontext.GitAutomation.Tests/TempDirectory.cs +++ b/tests/Kontext.GitAutomation.IntegrationTests/TempDirectory.cs @@ -1,4 +1,4 @@ -namespace Kontext.GitAutomation.Tests; +namespace Kontext.GitAutomation.IntegrationTests; internal sealed class TempDirectory : IDisposable { diff --git a/tests/Kontext.GitAutomation.Tests/BranchNamerTests.cs b/tests/Kontext.GitAutomation.UnitTests/BranchNamerTests.cs similarity index 98% rename from tests/Kontext.GitAutomation.Tests/BranchNamerTests.cs rename to tests/Kontext.GitAutomation.UnitTests/BranchNamerTests.cs index d611848..e0719f5 100644 --- a/tests/Kontext.GitAutomation.Tests/BranchNamerTests.cs +++ b/tests/Kontext.GitAutomation.UnitTests/BranchNamerTests.cs @@ -1,4 +1,4 @@ -namespace Kontext.GitAutomation.Tests; +namespace Kontext.GitAutomation.UnitTests; [TestFixture] public class BranchNamerTests diff --git a/tests/Kontext.GitAutomation.Tests/ExistingWorkspaceFinderTests.cs b/tests/Kontext.GitAutomation.UnitTests/ExistingWorkspaceFinderTests.cs similarity index 98% rename from tests/Kontext.GitAutomation.Tests/ExistingWorkspaceFinderTests.cs rename to tests/Kontext.GitAutomation.UnitTests/ExistingWorkspaceFinderTests.cs index 1750a74..bec10ad 100644 --- a/tests/Kontext.GitAutomation.Tests/ExistingWorkspaceFinderTests.cs +++ b/tests/Kontext.GitAutomation.UnitTests/ExistingWorkspaceFinderTests.cs @@ -1,7 +1,8 @@ using Kontext.Domain; using Kontext.Kernel; +using Kontext.TestSupport; -namespace Kontext.GitAutomation.Tests; +namespace Kontext.GitAutomation.UnitTests; [TestFixture] public class ExistingWorkspaceFinderTests diff --git a/tests/Kontext.GitAutomation.Tests/Fakes.cs b/tests/Kontext.GitAutomation.UnitTests/Fakes.cs similarity index 87% rename from tests/Kontext.GitAutomation.Tests/Fakes.cs rename to tests/Kontext.GitAutomation.UnitTests/Fakes.cs index f6a20b1..c28464f 100644 --- a/tests/Kontext.GitAutomation.Tests/Fakes.cs +++ b/tests/Kontext.GitAutomation.UnitTests/Fakes.cs @@ -2,19 +2,7 @@ using Kontext.Domain; using Kontext.Kernel; -namespace Kontext.GitAutomation.Tests; - -internal sealed class FakeCloneStore : IRepositoryCloneStore -{ - public Dictionary Saved { get; } = new(StringComparer.OrdinalIgnoreCase); - - public string? Find(RepositoryRef repository) => Saved.GetValueOrDefault(repository.FullName); - - public void Save(RepositoryRef repository, string localPath) => Saved[repository.FullName] = localPath; - - public IReadOnlyList List() - => Saved.Keys.Select(name => new RepositoryRef(name.Split('/')[0], name.Split('/')[1])).ToList(); -} +namespace Kontext.GitAutomation.UnitTests; internal sealed class FakeCloner(string clonedTo) : IRepositoryCloner { @@ -45,11 +33,6 @@ public Task RunAsync(string workingDirectory, IReadOnlyList a } } -internal sealed class NoGitAuthentication : IGitAuthentication -{ - public IReadOnlyDictionary EnvironmentVariables() => new Dictionary(); -} - internal sealed class FakeInspector : IGitRepositoryInspector { private readonly Dictionary _folders = new(StringComparer.OrdinalIgnoreCase); diff --git a/tests/Kontext.GitAutomation.Tests/GitHubTokenAuthenticationTests.cs b/tests/Kontext.GitAutomation.UnitTests/GitHubTokenAuthenticationTests.cs similarity index 97% rename from tests/Kontext.GitAutomation.Tests/GitHubTokenAuthenticationTests.cs rename to tests/Kontext.GitAutomation.UnitTests/GitHubTokenAuthenticationTests.cs index 3bbc4a8..904d8f7 100644 --- a/tests/Kontext.GitAutomation.Tests/GitHubTokenAuthenticationTests.cs +++ b/tests/Kontext.GitAutomation.UnitTests/GitHubTokenAuthenticationTests.cs @@ -1,7 +1,7 @@ using System.Text; using Kontext.Contracts; -namespace Kontext.GitAutomation.Tests; +namespace Kontext.GitAutomation.UnitTests; [TestFixture] public class GitHubTokenAuthenticationTests diff --git a/tests/Kontext.GitAutomation.Tests/GitRemoteUrlParserTests.cs b/tests/Kontext.GitAutomation.UnitTests/GitRemoteUrlParserTests.cs similarity index 96% rename from tests/Kontext.GitAutomation.Tests/GitRemoteUrlParserTests.cs rename to tests/Kontext.GitAutomation.UnitTests/GitRemoteUrlParserTests.cs index 52bd429..ac561b4 100644 --- a/tests/Kontext.GitAutomation.Tests/GitRemoteUrlParserTests.cs +++ b/tests/Kontext.GitAutomation.UnitTests/GitRemoteUrlParserTests.cs @@ -1,4 +1,4 @@ -namespace Kontext.GitAutomation.Tests; +namespace Kontext.GitAutomation.UnitTests; [TestFixture] public class GitRemoteUrlParserTests diff --git a/tests/Kontext.GitAutomation.UnitTests/Kontext.GitAutomation.UnitTests.csproj b/tests/Kontext.GitAutomation.UnitTests/Kontext.GitAutomation.UnitTests.csproj new file mode 100644 index 0000000..7f0d09a --- /dev/null +++ b/tests/Kontext.GitAutomation.UnitTests/Kontext.GitAutomation.UnitTests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + false + true + + + + + + + + + all + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/tests/Kontext.GitAutomation.Tests/RepositoryCloneResolverTests.cs b/tests/Kontext.GitAutomation.UnitTests/RepositoryCloneResolverTests.cs similarity index 98% rename from tests/Kontext.GitAutomation.Tests/RepositoryCloneResolverTests.cs rename to tests/Kontext.GitAutomation.UnitTests/RepositoryCloneResolverTests.cs index 6e1a008..eaed30c 100644 --- a/tests/Kontext.GitAutomation.Tests/RepositoryCloneResolverTests.cs +++ b/tests/Kontext.GitAutomation.UnitTests/RepositoryCloneResolverTests.cs @@ -1,6 +1,7 @@ using Kontext.Kernel; +using Kontext.TestSupport; -namespace Kontext.GitAutomation.Tests; +namespace Kontext.GitAutomation.UnitTests; [TestFixture] public class RepositoryCloneResolverTests diff --git a/tests/Kontext.GitAutomation.Tests/WorkspaceProvisionerTests.cs b/tests/Kontext.GitAutomation.UnitTests/WorkspaceProvisionerTests.cs similarity index 98% rename from tests/Kontext.GitAutomation.Tests/WorkspaceProvisionerTests.cs rename to tests/Kontext.GitAutomation.UnitTests/WorkspaceProvisionerTests.cs index e135d5b..8d26b85 100644 --- a/tests/Kontext.GitAutomation.Tests/WorkspaceProvisionerTests.cs +++ b/tests/Kontext.GitAutomation.UnitTests/WorkspaceProvisionerTests.cs @@ -1,7 +1,7 @@ using Kontext.Domain; using Kontext.Kernel; -namespace Kontext.GitAutomation.Tests; +namespace Kontext.GitAutomation.UnitTests; [TestFixture] public class WorkspaceProvisionerTests diff --git a/tests/Kontext.GitAutomation.Tests/WorkspaceRemoverTests.cs b/tests/Kontext.GitAutomation.UnitTests/WorkspaceRemoverTests.cs similarity index 98% rename from tests/Kontext.GitAutomation.Tests/WorkspaceRemoverTests.cs rename to tests/Kontext.GitAutomation.UnitTests/WorkspaceRemoverTests.cs index 2c2e26a..54734b9 100644 --- a/tests/Kontext.GitAutomation.Tests/WorkspaceRemoverTests.cs +++ b/tests/Kontext.GitAutomation.UnitTests/WorkspaceRemoverTests.cs @@ -1,7 +1,8 @@ using Kontext.Domain; using Kontext.Kernel; +using Kontext.TestSupport; -namespace Kontext.GitAutomation.Tests; +namespace Kontext.GitAutomation.UnitTests; [TestFixture] public class WorkspaceRemoverTests diff --git a/tests/Kontext.GitAutomation.Tests/WorktreePathProviderTests.cs b/tests/Kontext.GitAutomation.UnitTests/WorktreePathProviderTests.cs similarity index 94% rename from tests/Kontext.GitAutomation.Tests/WorktreePathProviderTests.cs rename to tests/Kontext.GitAutomation.UnitTests/WorktreePathProviderTests.cs index f405ece..c673075 100644 --- a/tests/Kontext.GitAutomation.Tests/WorktreePathProviderTests.cs +++ b/tests/Kontext.GitAutomation.UnitTests/WorktreePathProviderTests.cs @@ -1,6 +1,6 @@ using Kontext.Kernel; -namespace Kontext.GitAutomation.Tests; +namespace Kontext.GitAutomation.UnitTests; [TestFixture] public class WorktreePathProviderTests diff --git a/tests/Kontext.GitAutomation.Tests/WorktreePorcelainParserTests.cs b/tests/Kontext.GitAutomation.UnitTests/WorktreePorcelainParserTests.cs similarity index 98% rename from tests/Kontext.GitAutomation.Tests/WorktreePorcelainParserTests.cs rename to tests/Kontext.GitAutomation.UnitTests/WorktreePorcelainParserTests.cs index b8b7aa1..94a966f 100644 --- a/tests/Kontext.GitAutomation.Tests/WorktreePorcelainParserTests.cs +++ b/tests/Kontext.GitAutomation.UnitTests/WorktreePorcelainParserTests.cs @@ -1,4 +1,4 @@ -namespace Kontext.GitAutomation.Tests; +namespace Kontext.GitAutomation.UnitTests; [TestFixture] public class WorktreePorcelainParserTests diff --git a/tests/Kontext.GitHub.Tests/DeviceFlowResponsesTests.cs b/tests/Kontext.GitHub.UnitTests/DeviceFlowResponsesTests.cs similarity index 99% rename from tests/Kontext.GitHub.Tests/DeviceFlowResponsesTests.cs rename to tests/Kontext.GitHub.UnitTests/DeviceFlowResponsesTests.cs index 5f6c9f2..05e0566 100644 --- a/tests/Kontext.GitHub.Tests/DeviceFlowResponsesTests.cs +++ b/tests/Kontext.GitHub.UnitTests/DeviceFlowResponsesTests.cs @@ -1,6 +1,6 @@ using System.Globalization; -namespace Kontext.GitHub.Tests; +namespace Kontext.GitHub.UnitTests; [TestFixture] public class DeviceFlowResponsesTests diff --git a/tests/Kontext.GitHub.Tests/GitHubDeviceLoginStartTests.cs b/tests/Kontext.GitHub.UnitTests/GitHubDeviceLoginStartTests.cs similarity index 96% rename from tests/Kontext.GitHub.Tests/GitHubDeviceLoginStartTests.cs rename to tests/Kontext.GitHub.UnitTests/GitHubDeviceLoginStartTests.cs index d53ae0f..98f3cdb 100644 --- a/tests/Kontext.GitHub.Tests/GitHubDeviceLoginStartTests.cs +++ b/tests/Kontext.GitHub.UnitTests/GitHubDeviceLoginStartTests.cs @@ -1,6 +1,6 @@ using System.Net; -namespace Kontext.GitHub.Tests; +namespace Kontext.GitHub.UnitTests; [TestFixture] public class GitHubDeviceLoginStartTests : GitHubDeviceLoginTestBase @@ -12,7 +12,7 @@ public async Task WithoutAClientIdSaysSignInIsNotConfiguredAndMakesNoRequest(str { var ex = await Assert.ThrowsAsync(() => Create(clientId).StartAsync()); - Assert.That(ex.Message, Does.Contain("not configured").And.Contain("KONTEXT_GITHUB_CLIENT_ID")); + Assert.That(ex.Message, Does.Contain("not configured")); Assert.That(Handler.Requests, Is.Empty); } diff --git a/tests/Kontext.GitHub.Tests/GitHubDeviceLoginTestBase.cs b/tests/Kontext.GitHub.UnitTests/GitHubDeviceLoginTestBase.cs similarity index 98% rename from tests/Kontext.GitHub.Tests/GitHubDeviceLoginTestBase.cs rename to tests/Kontext.GitHub.UnitTests/GitHubDeviceLoginTestBase.cs index 0d7c16c..6a826c4 100644 --- a/tests/Kontext.GitHub.Tests/GitHubDeviceLoginTestBase.cs +++ b/tests/Kontext.GitHub.UnitTests/GitHubDeviceLoginTestBase.cs @@ -2,7 +2,7 @@ using Kontext.Domain; using Microsoft.Extensions.Time.Testing; -namespace Kontext.GitHub.Tests; +namespace Kontext.GitHub.UnitTests; public abstract class GitHubDeviceLoginTestBase { diff --git a/tests/Kontext.GitHub.Tests/GitHubDeviceLoginWaitTests.cs b/tests/Kontext.GitHub.UnitTests/GitHubDeviceLoginWaitTests.cs similarity index 99% rename from tests/Kontext.GitHub.Tests/GitHubDeviceLoginWaitTests.cs rename to tests/Kontext.GitHub.UnitTests/GitHubDeviceLoginWaitTests.cs index 348ed73..f8d2d9a 100644 --- a/tests/Kontext.GitHub.Tests/GitHubDeviceLoginWaitTests.cs +++ b/tests/Kontext.GitHub.UnitTests/GitHubDeviceLoginWaitTests.cs @@ -1,7 +1,7 @@ using System.Net; using Kontext.Domain; -namespace Kontext.GitHub.Tests; +namespace Kontext.GitHub.UnitTests; [TestFixture] public class GitHubDeviceLoginWaitTests : GitHubDeviceLoginTestBase diff --git a/tests/Kontext.GitHub.Tests/IssueMapperTests.cs b/tests/Kontext.GitHub.UnitTests/IssueMapperTests.cs similarity index 99% rename from tests/Kontext.GitHub.Tests/IssueMapperTests.cs rename to tests/Kontext.GitHub.UnitTests/IssueMapperTests.cs index 4a9d234..8fdf22f 100644 --- a/tests/Kontext.GitHub.Tests/IssueMapperTests.cs +++ b/tests/Kontext.GitHub.UnitTests/IssueMapperTests.cs @@ -3,7 +3,7 @@ using Octokit; using Octokit.Internal; -namespace Kontext.GitHub.Tests; +namespace Kontext.GitHub.UnitTests; [TestFixture] public class IssueMapperTests diff --git a/tests/Kontext.GitHub.Tests/Kontext.GitHub.Tests.csproj b/tests/Kontext.GitHub.UnitTests/Kontext.GitHub.UnitTests.csproj similarity index 100% rename from tests/Kontext.GitHub.Tests/Kontext.GitHub.Tests.csproj rename to tests/Kontext.GitHub.UnitTests/Kontext.GitHub.UnitTests.csproj diff --git a/tests/Kontext.GitHub.Tests/StubHttpMessageHandler.cs b/tests/Kontext.GitHub.UnitTests/StubHttpMessageHandler.cs similarity index 98% rename from tests/Kontext.GitHub.Tests/StubHttpMessageHandler.cs rename to tests/Kontext.GitHub.UnitTests/StubHttpMessageHandler.cs index f000a95..27ee89d 100644 --- a/tests/Kontext.GitHub.Tests/StubHttpMessageHandler.cs +++ b/tests/Kontext.GitHub.UnitTests/StubHttpMessageHandler.cs @@ -1,7 +1,7 @@ using System.Net; using System.Text; -namespace Kontext.GitHub.Tests; +namespace Kontext.GitHub.UnitTests; internal sealed record RecordedRequest(HttpMethod Method, string Url, string Accept, Dictionary Form); diff --git a/tests/Kontext.Presentation.Tests/AppLifetimeTests.cs b/tests/Kontext.Presentation.UnitTests/AppLifetimeTests.cs similarity index 93% rename from tests/Kontext.Presentation.Tests/AppLifetimeTests.cs rename to tests/Kontext.Presentation.UnitTests/AppLifetimeTests.cs index dc51262..37f8665 100644 --- a/tests/Kontext.Presentation.Tests/AppLifetimeTests.cs +++ b/tests/Kontext.Presentation.UnitTests/AppLifetimeTests.cs @@ -1,5 +1,5 @@ -namespace Kontext.Presentation.Tests; +namespace Kontext.Presentation.UnitTests; [TestFixture] public class AppLifetimeTests diff --git a/tests/Kontext.Presentation.Tests/DisplayTextTests.cs b/tests/Kontext.Presentation.UnitTests/DisplayTextTests.cs similarity index 97% rename from tests/Kontext.Presentation.Tests/DisplayTextTests.cs rename to tests/Kontext.Presentation.UnitTests/DisplayTextTests.cs index 77f8d1a..7adb0a9 100644 --- a/tests/Kontext.Presentation.Tests/DisplayTextTests.cs +++ b/tests/Kontext.Presentation.UnitTests/DisplayTextTests.cs @@ -1,6 +1,6 @@ using Kontext.Domain; -namespace Kontext.Presentation.Tests; +namespace Kontext.Presentation.UnitTests; [TestFixture] public class DisplayTextTests diff --git a/tests/Kontext.Presentation.Tests/IssueDetailViewModelTests.cs b/tests/Kontext.Presentation.UnitTests/IssueDetailViewModelTests.cs similarity index 97% rename from tests/Kontext.Presentation.Tests/IssueDetailViewModelTests.cs rename to tests/Kontext.Presentation.UnitTests/IssueDetailViewModelTests.cs index dd58e80..3abe9dd 100644 --- a/tests/Kontext.Presentation.Tests/IssueDetailViewModelTests.cs +++ b/tests/Kontext.Presentation.UnitTests/IssueDetailViewModelTests.cs @@ -3,7 +3,7 @@ using Kontext.TestSupport; using Microsoft.Extensions.Time.Testing; -namespace Kontext.Presentation.Tests; +namespace Kontext.Presentation.UnitTests; [TestFixture] public class IssueDetailViewModelTests @@ -431,7 +431,7 @@ public async Task HidesTheNativeTerminalWhileTheDialogIsOpenSoItIsNotCoveredByIt } [TestCase(WorkspaceTool.Explorer)] [TestCase(WorkspaceTool.Terminal)] - [TestCase(WorkspaceTool.VisualStudio)] + [TestCase(WorkspaceTool.Solution)] public void OpensTheWorktreeInTheChosenTool(WorkspaceTool tool) { _detail.Item = Item(IssueWorkStatus.Ready, branch: "features/4-add", path: @"C:\wt\4"); @@ -457,12 +457,12 @@ public void OffersToOpenOnlyWhenThereIsAWorktree() [Test] public void ShowsWhyAToolCouldNotBeOpenedAndForgetsItOnceAnotherIssueIsSelected() { - _opener.FailWith = new ExternalToolException("Visual Studio was not found on this computer."); + _opener.FailWith = new ExternalToolException("No solution file (.slnx or .sln) was found in this folder."); _detail.Item = Item(IssueWorkStatus.Ready, branch: "features/4-add", path: @"C:\wt\4"); - _detail.OpenInCommand.Execute(WorkspaceTool.VisualStudio); + _detail.OpenInCommand.Execute(WorkspaceTool.Solution); - Assert.That(_detail.ToolError, Is.EqualTo("Visual Studio was not found on this computer.")); + Assert.That(_detail.ToolError, Is.EqualTo("No solution file (.slnx or .sln) was found in this folder.")); Assert.That(_detail.HasToolError, Is.True); _detail.Item = Item(IssueWorkStatus.Ready, branch: "features/4-add", path: @"C:\wt\4"); diff --git a/tests/Kontext.Presentation.Tests/IssueGrouperTests.cs b/tests/Kontext.Presentation.UnitTests/IssueGrouperTests.cs similarity index 97% rename from tests/Kontext.Presentation.Tests/IssueGrouperTests.cs rename to tests/Kontext.Presentation.UnitTests/IssueGrouperTests.cs index 3bcc6ec..7be8691 100644 --- a/tests/Kontext.Presentation.Tests/IssueGrouperTests.cs +++ b/tests/Kontext.Presentation.UnitTests/IssueGrouperTests.cs @@ -1,7 +1,7 @@ using Kontext.Domain; using Kontext.TestSupport; -namespace Kontext.Presentation.Tests; +namespace Kontext.Presentation.UnitTests; [TestFixture] public class IssueGrouperTests diff --git a/tests/Kontext.Presentation.Tests/IssueItemViewModelTests.cs b/tests/Kontext.Presentation.UnitTests/IssueItemViewModelTests.cs similarity index 97% rename from tests/Kontext.Presentation.Tests/IssueItemViewModelTests.cs rename to tests/Kontext.Presentation.UnitTests/IssueItemViewModelTests.cs index dddf844..636b3fd 100644 --- a/tests/Kontext.Presentation.Tests/IssueItemViewModelTests.cs +++ b/tests/Kontext.Presentation.UnitTests/IssueItemViewModelTests.cs @@ -1,7 +1,7 @@ using Kontext.Domain; using Kontext.TestSupport; -namespace Kontext.Presentation.Tests; +namespace Kontext.Presentation.UnitTests; [TestFixture] public class IssueItemViewModelTests diff --git a/tests/Kontext.Presentation.Tests/IssueSearchTests.cs b/tests/Kontext.Presentation.UnitTests/IssueSearchTests.cs similarity index 98% rename from tests/Kontext.Presentation.Tests/IssueSearchTests.cs rename to tests/Kontext.Presentation.UnitTests/IssueSearchTests.cs index cacfd1a..baeffb7 100644 --- a/tests/Kontext.Presentation.Tests/IssueSearchTests.cs +++ b/tests/Kontext.Presentation.UnitTests/IssueSearchTests.cs @@ -1,7 +1,7 @@ using Kontext.Domain; using Kontext.TestSupport; -namespace Kontext.Presentation.Tests; +namespace Kontext.Presentation.UnitTests; [TestFixture] public class IssueSearchTests diff --git a/tests/Kontext.Presentation.Tests/IssueSidebarViewModelTests.cs b/tests/Kontext.Presentation.UnitTests/IssueSidebarViewModelTests.cs similarity index 99% rename from tests/Kontext.Presentation.Tests/IssueSidebarViewModelTests.cs rename to tests/Kontext.Presentation.UnitTests/IssueSidebarViewModelTests.cs index 2ad2ab5..1d7270e 100644 --- a/tests/Kontext.Presentation.Tests/IssueSidebarViewModelTests.cs +++ b/tests/Kontext.Presentation.UnitTests/IssueSidebarViewModelTests.cs @@ -2,7 +2,7 @@ using Kontext.Kernel; using Kontext.TestSupport; -namespace Kontext.Presentation.Tests; +namespace Kontext.Presentation.UnitTests; [TestFixture] public class IssueSidebarViewModelTests diff --git a/tests/Kontext.Presentation.Tests/Kontext.Presentation.Tests.csproj b/tests/Kontext.Presentation.UnitTests/Kontext.Presentation.UnitTests.csproj similarity index 95% rename from tests/Kontext.Presentation.Tests/Kontext.Presentation.Tests.csproj rename to tests/Kontext.Presentation.UnitTests/Kontext.Presentation.UnitTests.csproj index cd681ab..9cc117f 100644 --- a/tests/Kontext.Presentation.Tests/Kontext.Presentation.Tests.csproj +++ b/tests/Kontext.Presentation.UnitTests/Kontext.Presentation.UnitTests.csproj @@ -2,7 +2,7 @@ net10.0 - Kontext.Presentation.Tests + Kontext.Presentation.UnitTests false true diff --git a/tests/Kontext.Presentation.Tests/LinkPolicyTests.cs b/tests/Kontext.Presentation.UnitTests/LinkPolicyTests.cs similarity index 95% rename from tests/Kontext.Presentation.Tests/LinkPolicyTests.cs rename to tests/Kontext.Presentation.UnitTests/LinkPolicyTests.cs index ed91f29..18f85a5 100644 --- a/tests/Kontext.Presentation.Tests/LinkPolicyTests.cs +++ b/tests/Kontext.Presentation.UnitTests/LinkPolicyTests.cs @@ -1,4 +1,4 @@ -namespace Kontext.Presentation.Tests; +namespace Kontext.Presentation.UnitTests; [TestFixture] public class LinkPolicyTests diff --git a/tests/Kontext.Presentation.Tests/SelectionAutoStartTests.cs b/tests/Kontext.Presentation.UnitTests/SelectionAutoStartTests.cs similarity index 99% rename from tests/Kontext.Presentation.Tests/SelectionAutoStartTests.cs rename to tests/Kontext.Presentation.UnitTests/SelectionAutoStartTests.cs index 522daa8..379277d 100644 --- a/tests/Kontext.Presentation.Tests/SelectionAutoStartTests.cs +++ b/tests/Kontext.Presentation.UnitTests/SelectionAutoStartTests.cs @@ -3,7 +3,7 @@ using Kontext.TestSupport; using Microsoft.Extensions.Time.Testing; -namespace Kontext.Presentation.Tests; +namespace Kontext.Presentation.UnitTests; [TestFixture] public class SelectionAutoStartTests diff --git a/tests/Kontext.Presentation.Tests/ShellViewModelFixture.cs b/tests/Kontext.Presentation.UnitTests/ShellViewModelFixture.cs similarity index 97% rename from tests/Kontext.Presentation.Tests/ShellViewModelFixture.cs rename to tests/Kontext.Presentation.UnitTests/ShellViewModelFixture.cs index e1dd3b4..d822d75 100644 --- a/tests/Kontext.Presentation.Tests/ShellViewModelFixture.cs +++ b/tests/Kontext.Presentation.UnitTests/ShellViewModelFixture.cs @@ -1,6 +1,6 @@ using Kontext.TestSupport; -namespace Kontext.Presentation.Tests; +namespace Kontext.Presentation.UnitTests; public abstract class ShellViewModelFixture { diff --git a/tests/Kontext.Presentation.Tests/ShellViewModelSignInTests.cs b/tests/Kontext.Presentation.UnitTests/ShellViewModelSignInTests.cs similarity index 99% rename from tests/Kontext.Presentation.Tests/ShellViewModelSignInTests.cs rename to tests/Kontext.Presentation.UnitTests/ShellViewModelSignInTests.cs index e096c1e..ca39f3d 100644 --- a/tests/Kontext.Presentation.Tests/ShellViewModelSignInTests.cs +++ b/tests/Kontext.Presentation.UnitTests/ShellViewModelSignInTests.cs @@ -1,7 +1,7 @@ using Kontext.Domain; using Kontext.TestSupport; -namespace Kontext.Presentation.Tests; +namespace Kontext.Presentation.UnitTests; [TestFixture] public class ShellViewModelSignInTests : ShellViewModelFixture diff --git a/tests/Kontext.Presentation.Tests/ShellViewModelTests.cs b/tests/Kontext.Presentation.UnitTests/ShellViewModelTests.cs similarity index 99% rename from tests/Kontext.Presentation.Tests/ShellViewModelTests.cs rename to tests/Kontext.Presentation.UnitTests/ShellViewModelTests.cs index 830b3ce..794f92e 100644 --- a/tests/Kontext.Presentation.Tests/ShellViewModelTests.cs +++ b/tests/Kontext.Presentation.UnitTests/ShellViewModelTests.cs @@ -1,7 +1,7 @@ using Kontext.Domain; using Kontext.TestSupport; -namespace Kontext.Presentation.Tests; +namespace Kontext.Presentation.UnitTests; [TestFixture] public class ShellViewModelTests : ShellViewModelFixture diff --git a/tests/Kontext.Presentation.Tests/WorkspaceIssuesTests.cs b/tests/Kontext.Presentation.UnitTests/WorkspaceIssuesTests.cs similarity index 98% rename from tests/Kontext.Presentation.Tests/WorkspaceIssuesTests.cs rename to tests/Kontext.Presentation.UnitTests/WorkspaceIssuesTests.cs index 410ec10..55a2741 100644 --- a/tests/Kontext.Presentation.Tests/WorkspaceIssuesTests.cs +++ b/tests/Kontext.Presentation.UnitTests/WorkspaceIssuesTests.cs @@ -2,7 +2,7 @@ using Kontext.Kernel; using Kontext.TestSupport; -namespace Kontext.Presentation.Tests; +namespace Kontext.Presentation.UnitTests; [TestFixture] public class WorkspaceIssuesTests diff --git a/tests/Kontext.Presentation.Tests/WorkspaceViewModelTests.cs b/tests/Kontext.Presentation.UnitTests/WorkspaceViewModelTests.cs similarity index 99% rename from tests/Kontext.Presentation.Tests/WorkspaceViewModelTests.cs rename to tests/Kontext.Presentation.UnitTests/WorkspaceViewModelTests.cs index 5149ffd..e0e687a 100644 --- a/tests/Kontext.Presentation.Tests/WorkspaceViewModelTests.cs +++ b/tests/Kontext.Presentation.UnitTests/WorkspaceViewModelTests.cs @@ -1,7 +1,7 @@ using Kontext.TestSupport; using Microsoft.Extensions.Time.Testing; -namespace Kontext.Presentation.Tests; +namespace Kontext.Presentation.UnitTests; [TestFixture] public class WorkspaceViewModelTests diff --git a/tests/Kontext.Sessions.Tests/JsonSessionStoreTests.cs b/tests/Kontext.Sessions.IntegrationTests/JsonSessionStoreTests.cs similarity index 98% rename from tests/Kontext.Sessions.Tests/JsonSessionStoreTests.cs rename to tests/Kontext.Sessions.IntegrationTests/JsonSessionStoreTests.cs index 9e0e96c..ffe6936 100644 --- a/tests/Kontext.Sessions.Tests/JsonSessionStoreTests.cs +++ b/tests/Kontext.Sessions.IntegrationTests/JsonSessionStoreTests.cs @@ -1,7 +1,7 @@ using Kontext.Domain; using Kontext.Kernel; -namespace Kontext.Sessions.Tests; +namespace Kontext.Sessions.IntegrationTests; [TestFixture] public class JsonSessionStoreTests diff --git a/tests/Kontext.Sessions.IntegrationTests/Kontext.Sessions.IntegrationTests.csproj b/tests/Kontext.Sessions.IntegrationTests/Kontext.Sessions.IntegrationTests.csproj new file mode 100644 index 0000000..c77b427 --- /dev/null +++ b/tests/Kontext.Sessions.IntegrationTests/Kontext.Sessions.IntegrationTests.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + Kontext.Sessions.IntegrationTests + false + true + + + + + + + + + all + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/tests/Kontext.Sessions.Tests/IssueSessionClearerTests.cs b/tests/Kontext.Sessions.UnitTests/IssueSessionClearerTests.cs similarity index 98% rename from tests/Kontext.Sessions.Tests/IssueSessionClearerTests.cs rename to tests/Kontext.Sessions.UnitTests/IssueSessionClearerTests.cs index 9741f66..01b6199 100644 --- a/tests/Kontext.Sessions.Tests/IssueSessionClearerTests.cs +++ b/tests/Kontext.Sessions.UnitTests/IssueSessionClearerTests.cs @@ -2,7 +2,7 @@ using Kontext.Kernel; using Kontext.TestSupport; -namespace Kontext.Sessions.Tests; +namespace Kontext.Sessions.UnitTests; [TestFixture] public class IssueSessionClearerTests diff --git a/tests/Kontext.Sessions.Tests/IssueSessionCoordinatorTests.cs b/tests/Kontext.Sessions.UnitTests/IssueSessionCoordinatorTests.cs similarity index 99% rename from tests/Kontext.Sessions.Tests/IssueSessionCoordinatorTests.cs rename to tests/Kontext.Sessions.UnitTests/IssueSessionCoordinatorTests.cs index 8ed3dea..702cfd9 100644 --- a/tests/Kontext.Sessions.Tests/IssueSessionCoordinatorTests.cs +++ b/tests/Kontext.Sessions.UnitTests/IssueSessionCoordinatorTests.cs @@ -2,7 +2,7 @@ using Kontext.Kernel; using Kontext.TestSupport; -namespace Kontext.Sessions.Tests; +namespace Kontext.Sessions.UnitTests; [TestFixture] public class IssueSessionCoordinatorTests diff --git a/tests/Kontext.Sessions.Tests/IssueSessionRegistryTests.cs b/tests/Kontext.Sessions.UnitTests/IssueSessionRegistryTests.cs similarity index 98% rename from tests/Kontext.Sessions.Tests/IssueSessionRegistryTests.cs rename to tests/Kontext.Sessions.UnitTests/IssueSessionRegistryTests.cs index c592b7e..ca546fa 100644 --- a/tests/Kontext.Sessions.Tests/IssueSessionRegistryTests.cs +++ b/tests/Kontext.Sessions.UnitTests/IssueSessionRegistryTests.cs @@ -3,7 +3,7 @@ using Kontext.Kernel; using Kontext.TestSupport; -namespace Kontext.Sessions.Tests; +namespace Kontext.Sessions.UnitTests; [TestFixture] public class IssueSessionRegistryTests diff --git a/tests/Kontext.Sessions.Tests/IssueWorkTrackerTests.cs b/tests/Kontext.Sessions.UnitTests/IssueWorkTrackerTests.cs similarity index 98% rename from tests/Kontext.Sessions.Tests/IssueWorkTrackerTests.cs rename to tests/Kontext.Sessions.UnitTests/IssueWorkTrackerTests.cs index 262dfcf..0cc1b4c 100644 --- a/tests/Kontext.Sessions.Tests/IssueWorkTrackerTests.cs +++ b/tests/Kontext.Sessions.UnitTests/IssueWorkTrackerTests.cs @@ -3,7 +3,7 @@ using Kontext.Kernel; using Kontext.TestSupport; -namespace Kontext.Sessions.Tests; +namespace Kontext.Sessions.UnitTests; [TestFixture] public class IssueWorkTrackerTests diff --git a/tests/Kontext.Sessions.Tests/Kontext.Sessions.Tests.csproj b/tests/Kontext.Sessions.UnitTests/Kontext.Sessions.UnitTests.csproj similarity index 95% rename from tests/Kontext.Sessions.Tests/Kontext.Sessions.Tests.csproj rename to tests/Kontext.Sessions.UnitTests/Kontext.Sessions.UnitTests.csproj index 761b195..30af42c 100644 --- a/tests/Kontext.Sessions.Tests/Kontext.Sessions.Tests.csproj +++ b/tests/Kontext.Sessions.UnitTests/Kontext.Sessions.UnitTests.csproj @@ -2,7 +2,7 @@ net10.0 - Kontext.Sessions.Tests + Kontext.Sessions.UnitTests false true diff --git a/tests/Kontext.TerminalHost.Tests/CommandLineBuilderTests.cs b/tests/Kontext.TerminalHost.UnitTests/CommandLineBuilderTests.cs similarity index 98% rename from tests/Kontext.TerminalHost.Tests/CommandLineBuilderTests.cs rename to tests/Kontext.TerminalHost.UnitTests/CommandLineBuilderTests.cs index b2fe549..d9e97c6 100644 --- a/tests/Kontext.TerminalHost.Tests/CommandLineBuilderTests.cs +++ b/tests/Kontext.TerminalHost.UnitTests/CommandLineBuilderTests.cs @@ -1,6 +1,6 @@ using System.Runtime.InteropServices; -namespace Kontext.TerminalHost.Tests; +namespace Kontext.TerminalHost.UnitTests; [TestFixture] public class CommandLineBuilderTests diff --git a/tests/Kontext.TerminalHost.Tests/Kontext.TerminalHost.Tests.csproj b/tests/Kontext.TerminalHost.UnitTests/Kontext.TerminalHost.UnitTests.csproj similarity index 100% rename from tests/Kontext.TerminalHost.Tests/Kontext.TerminalHost.Tests.csproj rename to tests/Kontext.TerminalHost.UnitTests/Kontext.TerminalHost.UnitTests.csproj diff --git a/tests/Kontext.TerminalHost.Tests/MouseReportEncoderTests.cs b/tests/Kontext.TerminalHost.UnitTests/MouseReportEncoderTests.cs similarity index 98% rename from tests/Kontext.TerminalHost.Tests/MouseReportEncoderTests.cs rename to tests/Kontext.TerminalHost.UnitTests/MouseReportEncoderTests.cs index 84c15cb..173263a 100644 --- a/tests/Kontext.TerminalHost.Tests/MouseReportEncoderTests.cs +++ b/tests/Kontext.TerminalHost.UnitTests/MouseReportEncoderTests.cs @@ -1,4 +1,4 @@ -namespace Kontext.TerminalHost.Tests; +namespace Kontext.TerminalHost.UnitTests; [TestFixture] public class MouseReportEncoderTests diff --git a/tests/Kontext.TerminalHost.Tests/MouseReportingStateTests.cs b/tests/Kontext.TerminalHost.UnitTests/MouseReportingStateTests.cs similarity index 99% rename from tests/Kontext.TerminalHost.Tests/MouseReportingStateTests.cs rename to tests/Kontext.TerminalHost.UnitTests/MouseReportingStateTests.cs index 382d94e..d788572 100644 --- a/tests/Kontext.TerminalHost.Tests/MouseReportingStateTests.cs +++ b/tests/Kontext.TerminalHost.UnitTests/MouseReportingStateTests.cs @@ -1,4 +1,4 @@ -namespace Kontext.TerminalHost.Tests; +namespace Kontext.TerminalHost.UnitTests; [TestFixture] public class MouseReportingStateTests diff --git a/tests/Kontext.TerminalHost.Tests/PasteEncoderTests.cs b/tests/Kontext.TerminalHost.UnitTests/PasteEncoderTests.cs similarity index 98% rename from tests/Kontext.TerminalHost.Tests/PasteEncoderTests.cs rename to tests/Kontext.TerminalHost.UnitTests/PasteEncoderTests.cs index 43f2108..b527a00 100644 --- a/tests/Kontext.TerminalHost.Tests/PasteEncoderTests.cs +++ b/tests/Kontext.TerminalHost.UnitTests/PasteEncoderTests.cs @@ -1,4 +1,4 @@ -namespace Kontext.TerminalHost.Tests; +namespace Kontext.TerminalHost.UnitTests; [TestFixture] public class PasteEncoderTests diff --git a/tests/Kontext.TerminalHost.Tests/TerminalShortcutReaderTests.cs b/tests/Kontext.TerminalHost.UnitTests/TerminalShortcutReaderTests.cs similarity index 98% rename from tests/Kontext.TerminalHost.Tests/TerminalShortcutReaderTests.cs rename to tests/Kontext.TerminalHost.UnitTests/TerminalShortcutReaderTests.cs index 6c710e6..9b8375c 100644 --- a/tests/Kontext.TerminalHost.Tests/TerminalShortcutReaderTests.cs +++ b/tests/Kontext.TerminalHost.UnitTests/TerminalShortcutReaderTests.cs @@ -1,4 +1,4 @@ -namespace Kontext.TerminalHost.Tests; +namespace Kontext.TerminalHost.UnitTests; [TestFixture] public class TerminalShortcutReaderTests diff --git a/tests/Kontext.TerminalHost.Tests/TerminalZoomTests.cs b/tests/Kontext.TerminalHost.UnitTests/TerminalZoomTests.cs similarity index 97% rename from tests/Kontext.TerminalHost.Tests/TerminalZoomTests.cs rename to tests/Kontext.TerminalHost.UnitTests/TerminalZoomTests.cs index dbc1c50..b6ff756 100644 --- a/tests/Kontext.TerminalHost.Tests/TerminalZoomTests.cs +++ b/tests/Kontext.TerminalHost.UnitTests/TerminalZoomTests.cs @@ -1,4 +1,4 @@ -namespace Kontext.TerminalHost.Tests; +namespace Kontext.TerminalHost.UnitTests; [TestFixture] public class TerminalZoomTests diff --git a/tests/Kontext.TerminalHost.Tests/ZoomGestureReaderTests.cs b/tests/Kontext.TerminalHost.UnitTests/ZoomGestureReaderTests.cs similarity index 97% rename from tests/Kontext.TerminalHost.Tests/ZoomGestureReaderTests.cs rename to tests/Kontext.TerminalHost.UnitTests/ZoomGestureReaderTests.cs index b066fa4..b5cc326 100644 --- a/tests/Kontext.TerminalHost.Tests/ZoomGestureReaderTests.cs +++ b/tests/Kontext.TerminalHost.UnitTests/ZoomGestureReaderTests.cs @@ -1,4 +1,4 @@ -namespace Kontext.TerminalHost.Tests; +namespace Kontext.TerminalHost.UnitTests; [TestFixture] public class ZoomGestureReaderTests diff --git a/tests/Kontext.TestSupport/CloneStoreFakes.cs b/tests/Kontext.TestSupport/CloneStoreFakes.cs new file mode 100644 index 0000000..2cebb0e --- /dev/null +++ b/tests/Kontext.TestSupport/CloneStoreFakes.cs @@ -0,0 +1,17 @@ +using Kontext.Contracts; +using Kontext.Domain; +using Kontext.Kernel; + +namespace Kontext.TestSupport; + +public sealed class FakeCloneStore : IRepositoryCloneStore +{ + public Dictionary Saved { get; } = new(StringComparer.OrdinalIgnoreCase); + + public string? Find(RepositoryRef repository) => Saved.GetValueOrDefault(repository.FullName); + + public void Save(RepositoryRef repository, string localPath) => Saved[repository.FullName] = localPath; + + public IReadOnlyList List() + => Saved.Keys.Select(name => new RepositoryRef(name.Split('/')[0], name.Split('/')[1])).ToList(); +} From 108e33601f2af4150cfdc3a6b371a7d8b31ae094 Mon Sep 17 00:00:00 2001 From: Jelle Buning Date: Tue, 3 Nov 2026 13:54:11 +0100 Subject: [PATCH 4/6] . --- src/Kontext/Views/SignInView.xaml | 20 +++++++++++++++---- src/Kontext/Views/SignInView.xaml.cs | 29 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/Kontext/Views/SignInView.xaml b/src/Kontext/Views/SignInView.xaml index c59b2ba..8f1f6b0 100644 --- a/src/Kontext/Views/SignInView.xaml +++ b/src/Kontext/Views/SignInView.xaml @@ -27,10 +27,22 @@ - + + + + Date: Tue, 3 Nov 2026 14:24:46 +0100 Subject: [PATCH 5/6] Added pat option for enterprice --- .../IEnterpriseServerPrompt.cs | 8 + src/Kontext.Contracts/IGitHubDeviceLogin.cs | 2 +- src/Kontext.Contracts/IGitHubService.cs | 6 +- src/Kontext.Contracts/IGitHubTokenStore.cs | 8 +- src/Kontext.Domain/DeviceCodeChallenge.cs | 11 +- src/Kontext.Domain/EnterpriseCredentials.cs | 3 + src/Kontext.Domain/GitHubServer.cs | 57 +++++ src/Kontext.Domain/GitHubUser.cs | 4 +- .../GitHubTokenAuthentication.cs | 37 ++- .../GitRemoteUrlParser.cs | 26 ++- .../GitRepositoryInspector.cs | 4 +- .../IGitRepositoryInspector.cs | 2 +- .../JsonRepositoryCloneStore.cs | 16 +- src/Kontext.GitAutomation/PathSegment.cs | 10 + .../RepositoryCloneResolver.cs | 4 +- src/Kontext.GitAutomation/RepositoryCloner.cs | 15 +- .../WorktreePathProvider.cs | 10 +- src/Kontext.GitHub/GitHubDeviceLogin.cs | 36 +-- src/Kontext.GitHub/GitHubService.cs | 124 ++++++---- src/Kontext.GitHub/IssueMapper.cs | 10 +- src/Kontext.Kernel/GitHubHosts.cs | 8 + src/Kontext.Kernel/IssueKey.cs | 9 +- src/Kontext.Kernel/RepositoryRef.cs | 4 +- .../IssueDetailViewModel.cs | 2 +- src/Kontext.Presentation/IssueSearch.cs | 2 +- .../IssueSidebarViewModel.cs | 2 +- src/Kontext.Presentation/ShellViewModel.cs | 178 ++++++++++++-- src/Kontext/ServiceCollectionExtensions.cs | 1 + .../Services/ContentDialogEnterprisePrompt.cs | 69 ++++++ .../Services/CredentialLockerTokenStore.cs | 47 +++- src/Kontext/Views/DeviceCodePanel.xaml | 29 +++ src/Kontext/Views/DeviceCodePanel.xaml.cs | 51 ++++ src/Kontext/Views/SettingsView.xaml | 76 +++++- src/Kontext/Views/ShellPage.xaml.cs | 11 + src/Kontext/Views/SignInView.xaml | 41 ++-- src/Kontext/Views/SignInView.xaml.cs | 60 +---- .../CompositionTests.cs | 2 + .../EnterpriseCloneTests.cs | 56 +++++ .../EnterpriseHostTests.cs | 84 +++++++ .../Kontext.GitAutomation.UnitTests/Fakes.cs | 4 +- .../GitHubTokenAuthenticationTests.cs | 42 +++- .../GitHubEnterpriseLoginTests.cs | 74 ++++++ .../GitHubServerTests.cs | 62 +++++ .../ShellViewModelEnterpriseTests.cs | 221 ++++++++++++++++++ .../ShellViewModelFixture.cs | 9 +- tests/Kontext.TestSupport/DeviceLoginFakes.cs | 5 +- tests/Kontext.TestSupport/GitHubFakes.cs | 79 ++++++- 47 files changed, 1362 insertions(+), 259 deletions(-) create mode 100644 src/Kontext.Contracts/IEnterpriseServerPrompt.cs create mode 100644 src/Kontext.Domain/EnterpriseCredentials.cs create mode 100644 src/Kontext.Domain/GitHubServer.cs create mode 100644 src/Kontext.GitAutomation/PathSegment.cs create mode 100644 src/Kontext.Kernel/GitHubHosts.cs create mode 100644 src/Kontext/Services/ContentDialogEnterprisePrompt.cs create mode 100644 src/Kontext/Views/DeviceCodePanel.xaml create mode 100644 src/Kontext/Views/DeviceCodePanel.xaml.cs create mode 100644 tests/Kontext.GitAutomation.IntegrationTests/EnterpriseCloneTests.cs create mode 100644 tests/Kontext.GitAutomation.UnitTests/EnterpriseHostTests.cs create mode 100644 tests/Kontext.GitHub.UnitTests/GitHubEnterpriseLoginTests.cs create mode 100644 tests/Kontext.GitHub.UnitTests/GitHubServerTests.cs create mode 100644 tests/Kontext.Presentation.UnitTests/ShellViewModelEnterpriseTests.cs diff --git a/src/Kontext.Contracts/IEnterpriseServerPrompt.cs b/src/Kontext.Contracts/IEnterpriseServerPrompt.cs new file mode 100644 index 0000000..0ed0ed5 --- /dev/null +++ b/src/Kontext.Contracts/IEnterpriseServerPrompt.cs @@ -0,0 +1,8 @@ +using Kontext.Domain; + +namespace Kontext.Contracts; + +public interface IEnterpriseServerPrompt +{ + Task AskAsync(); +} diff --git a/src/Kontext.Contracts/IGitHubDeviceLogin.cs b/src/Kontext.Contracts/IGitHubDeviceLogin.cs index fdb6304..5637b50 100644 --- a/src/Kontext.Contracts/IGitHubDeviceLogin.cs +++ b/src/Kontext.Contracts/IGitHubDeviceLogin.cs @@ -4,7 +4,7 @@ namespace Kontext.Contracts; public interface IGitHubDeviceLogin { - Task StartAsync(CancellationToken cancellationToken = default); + Task StartAsync(GitHubServer? server = null, CancellationToken cancellationToken = default); Task WaitForAccessTokenAsync(DeviceCodeChallenge challenge, CancellationToken cancellationToken = default); } diff --git a/src/Kontext.Contracts/IGitHubService.cs b/src/Kontext.Contracts/IGitHubService.cs index 8f2cd91..aef754f 100644 --- a/src/Kontext.Contracts/IGitHubService.cs +++ b/src/Kontext.Contracts/IGitHubService.cs @@ -5,11 +5,9 @@ namespace Kontext.Contracts; public interface IGitHubService { - GitHubUser? CurrentUser { get; } + Task AuthenticateAsync(string host, string accessToken, CancellationToken cancellationToken = default); - Task AuthenticateAsync(string accessToken, CancellationToken cancellationToken = default); - - void SignOut(); + void SignOut(string host); Task> GetIssuesAsync(RepositoryRef? repository = null, CancellationToken cancellationToken = default); diff --git a/src/Kontext.Contracts/IGitHubTokenStore.cs b/src/Kontext.Contracts/IGitHubTokenStore.cs index c8b9cde..de23912 100644 --- a/src/Kontext.Contracts/IGitHubTokenStore.cs +++ b/src/Kontext.Contracts/IGitHubTokenStore.cs @@ -2,9 +2,11 @@ namespace Kontext.Contracts; public interface IGitHubTokenStore { - string? Load(); + string? Load(string host); - void Save(string token); + void Save(string host, string token); - void Clear(); + void Clear(string host); + + string? FindEnterpriseHost(); } diff --git a/src/Kontext.Domain/DeviceCodeChallenge.cs b/src/Kontext.Domain/DeviceCodeChallenge.cs index ace83d8..c0897ba 100644 --- a/src/Kontext.Domain/DeviceCodeChallenge.cs +++ b/src/Kontext.Domain/DeviceCodeChallenge.cs @@ -1,3 +1,12 @@ +using Kontext.Kernel; + namespace Kontext.Domain; -public sealed record DeviceCodeChallenge(string DeviceCode, string UserCode, Uri VerificationUri, DateTimeOffset ExpiresAt, TimeSpan PollInterval); +public sealed record DeviceCodeChallenge( + string DeviceCode, + string UserCode, + Uri VerificationUri, + DateTimeOffset ExpiresAt, + TimeSpan PollInterval, + string Host = GitHubHosts.Default, + string? ClientId = null); diff --git a/src/Kontext.Domain/EnterpriseCredentials.cs b/src/Kontext.Domain/EnterpriseCredentials.cs new file mode 100644 index 0000000..22ec654 --- /dev/null +++ b/src/Kontext.Domain/EnterpriseCredentials.cs @@ -0,0 +1,3 @@ +namespace Kontext.Domain; + +public sealed record EnterpriseCredentials(GitHubServer Server, string Token); diff --git a/src/Kontext.Domain/GitHubServer.cs b/src/Kontext.Domain/GitHubServer.cs new file mode 100644 index 0000000..51854a0 --- /dev/null +++ b/src/Kontext.Domain/GitHubServer.cs @@ -0,0 +1,57 @@ +using Kontext.Kernel; + +namespace Kontext.Domain; + +public sealed record GitHubServer(string Host, string? ClientId = null) +{ + public static GitHubServer GitHub { get; } = new(GitHubHosts.Default); + + public bool IsEnterprise => !GitHubHosts.IsDefault(Host); + + public Uri WebUri => new($"https://{Host}/"); + + public Uri ApiUri => GitHubHosts.IsDefault(Host) + ? new Uri("https://api.github.com/") + : Host.EndsWith(".ghe.com", StringComparison.OrdinalIgnoreCase) + ? new Uri($"https://api.{Host}/") + : new Uri($"https://{Host}/api/v3/"); + + public static bool TryCreate(string? address, out GitHubServer? server, out string? error) + { + server = null; + + if (string.IsNullOrWhiteSpace(address)) + { + error = "Enter the address of your GitHub Enterprise server."; + return false; + } + + var text = address.Trim(); + if (!text.Contains("://", StringComparison.Ordinal)) + { + text = "https://" + text; + } + + if (!Uri.TryCreate(text, UriKind.Absolute, out var uri) + || uri.Scheme != Uri.UriSchemeHttps + || string.IsNullOrEmpty(uri.Host) + || !string.IsNullOrEmpty(uri.UserInfo) + || !uri.Host.Contains('.', StringComparison.Ordinal) + || uri.AbsolutePath.Trim('/').Length > 0) + { + error = "That is not a valid server address. Use something like github.yourcompany.com."; + return false; + } + + var host = uri.Authority.ToLowerInvariant(); + if (GitHubHosts.IsDefault(host) || host == "www.github.com") + { + error = "That is regular GitHub. Use \"Sign in with GitHub\" for github.com."; + return false; + } + + server = new GitHubServer(host); + error = null; + return true; + } +} diff --git a/src/Kontext.Domain/GitHubUser.cs b/src/Kontext.Domain/GitHubUser.cs index a95d0c3..6789008 100644 --- a/src/Kontext.Domain/GitHubUser.cs +++ b/src/Kontext.Domain/GitHubUser.cs @@ -1,3 +1,5 @@ +using Kontext.Kernel; + namespace Kontext.Domain; -public sealed record GitHubUser(string Login, string? Name, Uri ProfileUrl); +public sealed record GitHubUser(string Login, string? Name, Uri ProfileUrl, string Host = GitHubHosts.Default); diff --git a/src/Kontext.GitAutomation/GitHubTokenAuthentication.cs b/src/Kontext.GitAutomation/GitHubTokenAuthentication.cs index 9d5897f..d5481cf 100644 --- a/src/Kontext.GitAutomation/GitHubTokenAuthentication.cs +++ b/src/Kontext.GitAutomation/GitHubTokenAuthentication.cs @@ -1,5 +1,6 @@ using System.Text; using Kontext.Contracts; +using Kontext.Kernel; namespace Kontext.GitAutomation; @@ -7,22 +8,38 @@ internal sealed class GitHubTokenAuthentication(IGitHubTokenStore tokens) : IGit { public IReadOnlyDictionary EnvironmentVariables() { - var token = tokens.Load(); - if (string.IsNullOrWhiteSpace(token)) + var headers = SignedInHosts() + .Select(host => (Host: host, Token: tokens.Load(host))) + .Where(account => !string.IsNullOrWhiteSpace(account.Token)) + .ToList(); + + if (headers.Count == 0) { return new Dictionary(); } - var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{token}")); + var environment = new Dictionary { ["GIT_CONFIG_COUNT"] = (headers.Count + 1).ToString(System.Globalization.CultureInfo.InvariantCulture) }; - return new Dictionary + for (var i = 0; i < headers.Count; i++) { - ["GIT_CONFIG_COUNT"] = "2", - ["GIT_CONFIG_KEY_0"] = "http.https://github.com/.extraheader", - ["GIT_CONFIG_VALUE_0"] = $"AUTHORIZATION: basic {basic}", + var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{headers[i].Token}")); + environment[$"GIT_CONFIG_KEY_{i}"] = $"http.https://{headers[i].Host}/.extraheader"; + environment[$"GIT_CONFIG_VALUE_{i}"] = $"AUTHORIZATION: basic {basic}"; + } + + environment[$"GIT_CONFIG_KEY_{headers.Count}"] = "credential.helper"; + environment[$"GIT_CONFIG_VALUE_{headers.Count}"] = string.Empty; + + return environment; + } - ["GIT_CONFIG_KEY_1"] = "credential.helper", - ["GIT_CONFIG_VALUE_1"] = string.Empty, - }; + private IEnumerable SignedInHosts() + { + yield return GitHubHosts.Default; + + if (tokens.FindEnterpriseHost() is { } enterprise) + { + yield return enterprise; + } } } diff --git a/src/Kontext.GitAutomation/GitRemoteUrlParser.cs b/src/Kontext.GitAutomation/GitRemoteUrlParser.cs index 9b86f3c..7ee8835 100644 --- a/src/Kontext.GitAutomation/GitRemoteUrlParser.cs +++ b/src/Kontext.GitAutomation/GitRemoteUrlParser.cs @@ -3,22 +3,26 @@ namespace Kontext.GitAutomation; -internal static partial class GitRemoteUrlParser +internal static class GitRemoteUrlParser { - public static RepositoryRef? Parse(string remoteUrl) + public static RepositoryRef? Parse(string remoteUrl, string host = GitHubHosts.Default) { - var match = UrlForm().Match(remoteUrl.Trim()); + var escaped = Regex.Escape(host); + var text = remoteUrl.Trim(); + + var match = Regex.Match( + text, + $@"^(?:https?|git|ssh)://(?:[^@/]+@)?(?:www\.)?{escaped}(?::\d+)?/(?[^/]+)/(?[^/]+?)(?:\.git)?/?$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (!match.Success) { - match = ScpForm().Match(remoteUrl.Trim()); + match = Regex.Match( + text, + $@"^(?:[^@/]+@)?{escaped}:(?[^/]+)/(?[^/]+?)(?:\.git)?/?$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); } - return match.Success ? new RepositoryRef(match.Groups["owner"].Value, match.Groups["name"].Value) : null; + return match.Success ? new RepositoryRef(match.Groups["owner"].Value, match.Groups["name"].Value, Host: host) : null; } - - [GeneratedRegex(@"^(?:https?|git|ssh)://(?:[^@/]+@)?(?:www\.)?github\.com(?::\d+)?/(?[^/]+)/(?[^/]+?)(?:\.git)?/?$", RegexOptions.IgnoreCase)] - private static partial Regex UrlForm(); - - [GeneratedRegex(@"^(?:[^@/]+@)?github\.com:(?[^/]+)/(?[^/]+?)(?:\.git)?/?$", RegexOptions.IgnoreCase)] - private static partial Regex ScpForm(); } diff --git a/src/Kontext.GitAutomation/GitRepositoryInspector.cs b/src/Kontext.GitAutomation/GitRepositoryInspector.cs index 3984518..d8c8ab6 100644 --- a/src/Kontext.GitAutomation/GitRepositoryInspector.cs +++ b/src/Kontext.GitAutomation/GitRepositoryInspector.cs @@ -10,7 +10,7 @@ internal sealed class GitRepositoryInspector(IGitProcess git) : IGitRepositoryIn return result.Succeeded && result.Output.Length > 0 ? Path.GetFullPath(result.Output) : null; } - public async Task> GetGitHubRemotesAsync(string repositoryRoot, CancellationToken cancellationToken = default) + public async Task> GetGitHubRemotesAsync(string repositoryRoot, string host = GitHubHosts.Default, CancellationToken cancellationToken = default) { var result = await git.RunAsync(repositoryRoot, ["config", "--get-regexp", @"^remote\..*\.url$"], cancellationToken); if (!result.Succeeded) @@ -21,7 +21,7 @@ public async Task> GetGitHubRemotesAsync(string rep return result.StandardOutput .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(line => line[(line.IndexOf(' ', StringComparison.Ordinal) + 1)..]) - .Select(GitRemoteUrlParser.Parse) + .Select(url => GitRemoteUrlParser.Parse(url, host)) .OfType() .Distinct() .ToList(); diff --git a/src/Kontext.GitAutomation/IGitRepositoryInspector.cs b/src/Kontext.GitAutomation/IGitRepositoryInspector.cs index 556ec98..026a325 100644 --- a/src/Kontext.GitAutomation/IGitRepositoryInspector.cs +++ b/src/Kontext.GitAutomation/IGitRepositoryInspector.cs @@ -6,5 +6,5 @@ public interface IGitRepositoryInspector { Task FindRepositoryRootAsync(string folder, CancellationToken cancellationToken = default); - Task> GetGitHubRemotesAsync(string repositoryRoot, CancellationToken cancellationToken = default); + Task> GetGitHubRemotesAsync(string repositoryRoot, string host = GitHubHosts.Default, CancellationToken cancellationToken = default); } diff --git a/src/Kontext.GitAutomation/JsonRepositoryCloneStore.cs b/src/Kontext.GitAutomation/JsonRepositoryCloneStore.cs index 7f05a6e..389e0a7 100644 --- a/src/Kontext.GitAutomation/JsonRepositoryCloneStore.cs +++ b/src/Kontext.GitAutomation/JsonRepositoryCloneStore.cs @@ -17,7 +17,7 @@ internal sealed class JsonRepositoryCloneStore(GitAutomationOptions options) : I lock (_gate) { - return Load().GetValueOrDefault(repository.FullName); + return Load().GetValueOrDefault(repository.DisplayName); } } @@ -27,8 +27,8 @@ public IReadOnlyList List() { return Load().Keys .Select(name => name.Split('/')) - .Where(parts => parts is [{ Length: > 0 }, { Length: > 0 }]) - .Select(parts => new RepositoryRef(parts[0], parts[1])) + .Select(ToRepository) + .OfType() .ToList(); } } @@ -41,13 +41,21 @@ public void Save(RepositoryRef repository, string localPath) lock (_gate) { var clones = Load(); - clones[repository.FullName] = localPath; + clones[repository.DisplayName] = localPath; Directory.CreateDirectory(options.StateDirectory); File.WriteAllText(_filePath, JsonSerializer.Serialize(clones, JsonOptions)); } } + // Keys are "owner/name" for github.com (the original format) and "host/owner/name" for any other server. + private static RepositoryRef? ToRepository(string[] parts) => parts switch + { + [{ Length: > 0 } owner, { Length: > 0 } name] => new RepositoryRef(owner, name), + [{ Length: > 0 } host, { Length: > 0 } owner, { Length: > 0 } name] => new RepositoryRef(owner, name, Host: host), + _ => null, + }; + private Dictionary Load() { var empty = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/src/Kontext.GitAutomation/PathSegment.cs b/src/Kontext.GitAutomation/PathSegment.cs new file mode 100644 index 0000000..4977cd4 --- /dev/null +++ b/src/Kontext.GitAutomation/PathSegment.cs @@ -0,0 +1,10 @@ +namespace Kontext.GitAutomation; + +internal static class PathSegment +{ + public static string Safe(string segment) + { + var invalid = Path.GetInvalidFileNameChars(); + return string.Concat(segment.Select(character => invalid.Contains(character) ? '_' : character)); + } +} diff --git a/src/Kontext.GitAutomation/RepositoryCloneResolver.cs b/src/Kontext.GitAutomation/RepositoryCloneResolver.cs index d1167c0..6a7d964 100644 --- a/src/Kontext.GitAutomation/RepositoryCloneResolver.cs +++ b/src/Kontext.GitAutomation/RepositoryCloneResolver.cs @@ -27,10 +27,10 @@ public async Task ResolveAsync(RepositoryRef repository, Cancella var root = await inspector.FindRepositoryRootAsync(cloned, cancellationToken) ?? throw new GitAutomationException($"'{cloned}' is not inside a git repository."); - var remotes = await inspector.GetGitHubRemotesAsync(root, cancellationToken); + var remotes = await inspector.GetGitHubRemotesAsync(root, repository.Host, cancellationToken); if (!remotes.Any(remote => string.Equals(remote.FullName, repository.FullName, StringComparison.OrdinalIgnoreCase))) { - var found = remotes.Count == 0 ? "has no GitHub remote" : $"is a clone of {string.Join(", ", remotes.Select(remote => remote.FullName))}"; + var found = remotes.Count == 0 ? $"has no {repository.Host} remote" : $"is a clone of {string.Join(", ", remotes.Select(remote => remote.FullName))}"; throw new GitAutomationException($"'{root}' {found}, not {repository.FullName}."); } diff --git a/src/Kontext.GitAutomation/RepositoryCloner.cs b/src/Kontext.GitAutomation/RepositoryCloner.cs index b984054..61eee00 100644 --- a/src/Kontext.GitAutomation/RepositoryCloner.cs +++ b/src/Kontext.GitAutomation/RepositoryCloner.cs @@ -11,7 +11,7 @@ public async Task CloneAsync(RepositoryRef repository, CancellationToken { ArgumentNullException.ThrowIfNull(repository); - var target = Path.Combine(options.ClonesRoot, repository.Owner, repository.Name); + var target = TargetFor(repository); var gate = _gates.GetOrAdd(target, _ => new SemaphoreSlim(1, 1)); await gate.WaitAsync(cancellationToken); @@ -30,7 +30,7 @@ public async Task CloneAsync(RepositoryRef repository, CancellationToken var parent = Path.GetDirectoryName(target)!; Directory.CreateDirectory(parent); - await git.RunCheckedAsync(parent, ["clone", $"https://github.com/{repository.Owner}/{repository.Name}.git", target], cancellationToken); + await git.RunCheckedAsync(parent, ["clone", $"https://{repository.Host}/{repository.Owner}/{repository.Name}.git", target], cancellationToken); return target; } finally @@ -38,4 +38,15 @@ public async Task CloneAsync(RepositoryRef repository, CancellationToken gate.Release(); } } + + private string TargetFor(RepositoryRef repository) + { + var owned = Path.Combine(options.ClonesRoot, repository.Owner, repository.Name); + + // github.com keeps its original layout; every other server gets a folder named after it, + // which can never collide with a github.com owner because those cannot contain dots. + return GitHubHosts.IsDefault(repository.Host) + ? owned + : Path.Combine(options.ClonesRoot, PathSegment.Safe(repository.Host), repository.Owner, repository.Name); + } } diff --git a/src/Kontext.GitAutomation/WorktreePathProvider.cs b/src/Kontext.GitAutomation/WorktreePathProvider.cs index 57c657a..8fbaf93 100644 --- a/src/Kontext.GitAutomation/WorktreePathProvider.cs +++ b/src/Kontext.GitAutomation/WorktreePathProvider.cs @@ -11,12 +11,10 @@ public string GetPath(RepositoryRef repository, string branchName) var folder = branchName[(branchName.LastIndexOf('/') + 1)..]; - return Path.Combine(options.WorktreesRoot, Safe(repository.Owner), Safe(repository.Name), Safe(folder)); - } + var root = GitHubHosts.IsDefault(repository.Host) + ? options.WorktreesRoot + : Path.Combine(options.WorktreesRoot, PathSegment.Safe(repository.Host)); - private static string Safe(string segment) - { - var invalid = Path.GetInvalidFileNameChars(); - return string.Concat(segment.Select(character => invalid.Contains(character) ? '_' : character)); + return Path.Combine(root, PathSegment.Safe(repository.Owner), PathSegment.Safe(repository.Name), PathSegment.Safe(folder)); } } diff --git a/src/Kontext.GitHub/GitHubDeviceLogin.cs b/src/Kontext.GitHub/GitHubDeviceLogin.cs index 421830d..65c79a5 100644 --- a/src/Kontext.GitHub/GitHubDeviceLogin.cs +++ b/src/Kontext.GitHub/GitHubDeviceLogin.cs @@ -1,36 +1,37 @@ using Kontext.Contracts; using Kontext.Domain; +using Kontext.Kernel; namespace Kontext.GitHub; internal sealed class GitHubDeviceLogin(GitHubOptions options, GitHubFormPoster poster, TimeProvider time) : IGitHubDeviceLogin { - private const string DeviceCodeUrl = "https://github.com/login/device/code"; - private const string AccessTokenUrl = "https://github.com/login/oauth/access_token"; private const string DeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code"; private const string NotConfigured = "GitHub sign-in is not configured. Set GitHub:ClientId to the client id of a GitHub OAuth App with device flow enabled, in appsettings.local.json next to Kontext.exe, then restart Kontext."; private static readonly TimeSpan SlowDownStep = TimeSpan.FromSeconds(5); - public async Task StartAsync(CancellationToken cancellationToken = default) + public async Task StartAsync(GitHubServer? server = null, CancellationToken cancellationToken = default) { - var clientId = RequireClientId(); - var body = await poster.PostAsync(DeviceCodeUrl, new Dictionary { ["client_id"] = clientId, ["scope"] = "repo" }, cancellationToken); + var host = server?.Host ?? GitHubHosts.Default; + var clientId = RequireClientId(server?.ClientId); + var body = await poster.PostAsync(DeviceCodeUrl(host), new Dictionary { ["client_id"] = clientId, ["scope"] = "repo" }, cancellationToken); var challenge = DeviceFlowResponses.ParseDeviceCode(body, time.GetUtcNow()); if (challenge.VerificationUri.Scheme != Uri.UriSchemeHttps - || !string.Equals(challenge.VerificationUri.Host, "github.com", StringComparison.OrdinalIgnoreCase)) + || !string.Equals(challenge.VerificationUri.Authority, host, StringComparison.OrdinalIgnoreCase)) { - throw new GitHubException("GitHub sent an unexpected verification address, so the sign-in was stopped."); + throw new GitHubException($"{Label(host)} sent an unexpected verification address, so the sign-in was stopped."); } - return challenge; + return challenge with { Host = host, ClientId = clientId }; } public async Task WaitForAccessTokenAsync(DeviceCodeChallenge challenge, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(challenge); - var clientId = RequireClientId(); + var clientId = RequireClientId(challenge.ClientId); + var accessTokenUrl = AccessTokenUrl(challenge.Host); var interval = challenge.PollInterval; while (true) @@ -42,7 +43,7 @@ public async Task WaitForAccessTokenAsync(DeviceCodeChallenge challenge, } var form = new Dictionary { ["client_id"] = clientId, ["device_code"] = challenge.DeviceCode, ["grant_type"] = DeviceGrantType }; - var result = DeviceFlowResponses.ParseTokenPoll(await poster.PostAsync(AccessTokenUrl, form, cancellationToken)); + var result = DeviceFlowResponses.ParseTokenPoll(await poster.PostAsync(accessTokenUrl, form, cancellationToken)); switch (result.Status) { case DeviceTokenStatus.Granted: @@ -55,15 +56,24 @@ public async Task WaitForAccessTokenAsync(DeviceCodeChallenge challenge, case DeviceTokenStatus.Expired: throw Expired(); case DeviceTokenStatus.Denied: - throw new AuthenticationFailedException("Sign-in was cancelled on GitHub."); + throw new AuthenticationFailedException($"Sign-in was cancelled on {Label(challenge.Host)}."); default: throw new GitHubException($"{result.Message} Check the client id and that device flow is enabled for the GitHub OAuth App."); } } } + private static string Label(string host) => GitHubHosts.IsDefault(host) ? "GitHub" : host; + + private static string DeviceCodeUrl(string host) => $"https://{host}/login/device/code"; + + private static string AccessTokenUrl(string host) => $"https://{host}/login/oauth/access_token"; + private static AuthenticationFailedException Expired() => new("The sign-in code expired. Please try again."); - private string RequireClientId() => - string.IsNullOrWhiteSpace(options.ClientId) ? throw new GitHubException(NotConfigured) : options.ClientId; + private string RequireClientId(string? explicitClientId) + { + var clientId = string.IsNullOrWhiteSpace(explicitClientId) ? options.ClientId : explicitClientId; + return string.IsNullOrWhiteSpace(clientId) ? throw new GitHubException(NotConfigured) : clientId; + } } diff --git a/src/Kontext.GitHub/GitHubService.cs b/src/Kontext.GitHub/GitHubService.cs index 55f20f5..fe71f7a 100644 --- a/src/Kontext.GitHub/GitHubService.cs +++ b/src/Kontext.GitHub/GitHubService.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Kontext.Contracts; using Kontext.Domain; using Kontext.Kernel; @@ -13,35 +14,77 @@ internal sealed class GitHubService : IGitHubService private static readonly ProductHeaderValue ProductHeader = new("Kontext"); - private GitHubClient _client = new(ProductHeader); + private readonly ConcurrentDictionary _clients = new(StringComparer.OrdinalIgnoreCase); - public GitHubUser? CurrentUser { get; private set; } - - public async Task AuthenticateAsync(string accessToken, CancellationToken cancellationToken = default) + public async Task AuthenticateAsync(string host, string accessToken, CancellationToken cancellationToken = default) { + ArgumentException.ThrowIfNullOrWhiteSpace(host); ArgumentException.ThrowIfNullOrWhiteSpace(accessToken); - var candidate = new GitHubClient(ProductHeader) { Credentials = new Credentials(accessToken) }; + var candidate = CreateClient(host, accessToken); var user = await OctokitCall.RunAsync(() => candidate.User.Current(), cancellationToken); - _client = candidate; - CurrentUser = new GitHubUser(user.Login, user.Name, new Uri(user.HtmlUrl)); - return CurrentUser; + _clients[host] = candidate; + return new GitHubUser(user.Login, user.Name, new Uri(user.HtmlUrl), host); } - public void SignOut() + public void SignOut(string host) => _clients.TryRemove(host, out _); + + public async Task> GetIssuesAsync(RepositoryRef? repository = null, CancellationToken cancellationToken = default) { - _client = new GitHubClient(ProductHeader); - CurrentUser = null; + if (repository is not null) + { + return await GetRepositoryIssuesAsync(ClientFor(repository.Host), repository, cancellationToken); + } + + var signedIn = _clients.ToArray(); + if (signedIn.Length == 0) + { + return []; + } + + var attempts = await Task.WhenAll(signedIn.Select(entry => TryGetAllIssuesAsync(entry.Value, entry.Key, cancellationToken))); + + // One unreachable server (a company server that needs a VPN, say) must not hide the issues + // of the other account, so only fail when every signed-in account failed. + if (attempts.All(attempt => attempt.Failure is not null)) + { + throw attempts[0].Failure!; + } + + return attempts.SelectMany(attempt => attempt.Issues).OrderByDescending(issue => issue.UpdatedAt).ToList(); } - public async Task> GetIssuesAsync(RepositoryRef? repository = null, CancellationToken cancellationToken = default) + public async Task GetIssueAsync(RepositoryRef repository, int number, CancellationToken cancellationToken = default) { - var client = _client; + ArgumentNullException.ThrowIfNull(repository); + + var client = ClientFor(repository.Host); + var issue = await OctokitCall.RunAsync(() => client.Issue.Get(repository.Owner, repository.Name, number), cancellationToken); - var issues = repository is null - ? await OctokitCall.RunAsync( + return IssueMapper.Map(issue, repository, repository.Host) + ?? throw new GitHubException($"{repository.FullName}#{number} is a pull request, not an issue."); + } + + private static GitHubClient CreateClient(string host, string accessToken) + { + var client = GitHubHosts.IsDefault(host) + ? new GitHubClient(ProductHeader) + : new GitHubClient(ProductHeader, new GitHubServer(host).ApiUri); + + client.Credentials = new Credentials(accessToken); + return client; + } + + private GitHubClient ClientFor(string host) => + _clients.TryGetValue(host, out var client) ? client : throw new GitHubException($"You are not signed in to {host}."); + + private static async Task<(IReadOnlyList Issues, KontextException? Failure)> TryGetAllIssuesAsync(GitHubClient client, string host, CancellationToken cancellationToken) + { + try + { + var issues = await OctokitCall.RunAsync( () => client.Issue.GetAllForCurrent( new IssueRequest { @@ -51,34 +94,37 @@ public async Task> GetIssuesAsync(RepositoryRef? repo SortDirection = SortDirection.Descending, }, new ApiOptions { PageSize = PageSize, PageCount = AllRepositoriesPageCount }), - cancellationToken) - : await OctokitCall.RunAsync( - () => client.Issue.GetAllForRepository( - repository.Owner, - repository.Name, - new RepositoryIssueRequest - { - State = ItemStateFilter.Open, - SortProperty = IssueSort.Updated, - SortDirection = SortDirection.Descending, - }, - new ApiOptions { PageSize = PageSize }), cancellationToken); - return issues - .Select(issue => IssueMapper.Map(issue, repository)) - .OfType() - .ToList(); + return (Map(issues, null, host), null); + } + catch (KontextException ex) + { + return ([], ex); + } } - public async Task GetIssueAsync(RepositoryRef repository, int number, CancellationToken cancellationToken = default) + private static async Task> GetRepositoryIssuesAsync(GitHubClient client, RepositoryRef repository, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(repository); - - var client = _client; - var issue = await OctokitCall.RunAsync(() => client.Issue.Get(repository.Owner, repository.Name, number), cancellationToken); - - return IssueMapper.Map(issue, repository) - ?? throw new GitHubException($"{repository.FullName}#{number} is a pull request, not an issue."); + var issues = await OctokitCall.RunAsync( + () => client.Issue.GetAllForRepository( + repository.Owner, + repository.Name, + new RepositoryIssueRequest + { + State = ItemStateFilter.Open, + SortProperty = IssueSort.Updated, + SortDirection = SortDirection.Descending, + }, + new ApiOptions { PageSize = PageSize }), + cancellationToken); + + return Map(issues, repository, repository.Host); } + + private static List Map(IEnumerable issues, RepositoryRef? repository, string host) => + issues + .Select(issue => IssueMapper.Map(issue, repository, host)) + .OfType() + .ToList(); } diff --git a/src/Kontext.GitHub/IssueMapper.cs b/src/Kontext.GitHub/IssueMapper.cs index 337d3a3..fce5bde 100644 --- a/src/Kontext.GitHub/IssueMapper.cs +++ b/src/Kontext.GitHub/IssueMapper.cs @@ -6,7 +6,7 @@ namespace Kontext.GitHub; internal static class IssueMapper { - public static GitHubIssue? Map(Issue issue, RepositoryRef? requestedRepository) + public static GitHubIssue? Map(Issue issue, RepositoryRef? requestedRepository, string host = GitHubHosts.Default) { if (issue.PullRequest is not null) { @@ -14,8 +14,8 @@ internal static class IssueMapper } var repository = issue.Repository is { } source - ? new RepositoryRef(source.Owner.Login, source.Name) - : requestedRepository ?? RepositoryFromIssueUrl(issue.HtmlUrl); + ? new RepositoryRef(source.Owner.Login, source.Name, Host: host) + : requestedRepository ?? RepositoryFromIssueUrl(issue.HtmlUrl, host); return new GitHubIssue( repository, @@ -29,9 +29,9 @@ internal static class IssueMapper issue.UpdatedAt ?? issue.CreatedAt); } - private static RepositoryRef RepositoryFromIssueUrl(string htmlUrl) + private static RepositoryRef RepositoryFromIssueUrl(string htmlUrl, string host) { var segments = new Uri(htmlUrl).AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); - return new RepositoryRef(segments[0], segments[1]); + return new RepositoryRef(segments[0], segments[1], Host: host); } } diff --git a/src/Kontext.Kernel/GitHubHosts.cs b/src/Kontext.Kernel/GitHubHosts.cs new file mode 100644 index 0000000..b3e289d --- /dev/null +++ b/src/Kontext.Kernel/GitHubHosts.cs @@ -0,0 +1,8 @@ +namespace Kontext.Kernel; + +public static class GitHubHosts +{ + public const string Default = "github.com"; + + public static bool IsDefault(string? host) => string.Equals(host, Default, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Kontext.Kernel/IssueKey.cs b/src/Kontext.Kernel/IssueKey.cs index 826cf5c..b493680 100644 --- a/src/Kontext.Kernel/IssueKey.cs +++ b/src/Kontext.Kernel/IssueKey.cs @@ -2,14 +2,15 @@ namespace Kontext.Kernel; public readonly record struct IssueKey { - public IssueKey(string repositoryFullName, int number) + public IssueKey(string repositoryFullName, int number, string host = GitHubHosts.Default) { RepositoryFullName = repositoryFullName.ToUpperInvariant(); Number = number; + Host = host.ToUpperInvariant(); } public IssueKey(RepositoryRef repository, int number) - : this(repository.FullName, number) + : this(repository.FullName, number, repository.Host) { } @@ -17,5 +18,7 @@ public IssueKey(RepositoryRef repository, int number) public int Number { get; } - public override string ToString() => $"{RepositoryFullName}#{Number}"; + public string Host { get; } + + public override string ToString() => GitHubHosts.IsDefault(Host) ? $"{RepositoryFullName}#{Number}" : $"{Host}/{RepositoryFullName}#{Number}"; } diff --git a/src/Kontext.Kernel/RepositoryRef.cs b/src/Kontext.Kernel/RepositoryRef.cs index 2fa89fc..4b366b1 100644 --- a/src/Kontext.Kernel/RepositoryRef.cs +++ b/src/Kontext.Kernel/RepositoryRef.cs @@ -1,6 +1,8 @@ namespace Kontext.Kernel; -public sealed record RepositoryRef(string Owner, string Name, string? LocalPath = null) +public sealed record RepositoryRef(string Owner, string Name, string? LocalPath = null, string Host = GitHubHosts.Default) { public string FullName => $"{Owner}/{Name}"; + + public string DisplayName => GitHubHosts.IsDefault(Host) ? FullName : $"{Host}/{FullName}"; } diff --git a/src/Kontext.Presentation/IssueDetailViewModel.cs b/src/Kontext.Presentation/IssueDetailViewModel.cs index 50ccea5..c9d7435 100644 --- a/src/Kontext.Presentation/IssueDetailViewModel.cs +++ b/src/Kontext.Presentation/IssueDetailViewModel.cs @@ -52,7 +52,7 @@ public IssueDetailViewModel( public string Title => Item?.Title ?? string.Empty; - public string RepositoryName => Item?.Issue.Repository.FullName ?? string.Empty; + public string RepositoryName => Item?.Issue.Repository.DisplayName ?? string.Empty; public string NumberText => Item?.NumberText ?? string.Empty; diff --git a/src/Kontext.Presentation/IssueSearch.cs b/src/Kontext.Presentation/IssueSearch.cs index 3df0168..a034a79 100644 --- a/src/Kontext.Presentation/IssueSearch.cs +++ b/src/Kontext.Presentation/IssueSearch.cs @@ -27,7 +27,7 @@ private static bool MatchesWord(GitHubIssue issue, string word) } return Contains(issue.Title, word) - || Contains(issue.Repository.FullName, word) + || Contains(issue.Repository.DisplayName, word) || Contains(issue.Author, word) || issue.Labels.Any(label => Contains(label, word)); } diff --git a/src/Kontext.Presentation/IssueSidebarViewModel.cs b/src/Kontext.Presentation/IssueSidebarViewModel.cs index 4a7082d..30b70be 100644 --- a/src/Kontext.Presentation/IssueSidebarViewModel.cs +++ b/src/Kontext.Presentation/IssueSidebarViewModel.cs @@ -171,7 +171,7 @@ private void ApplyFilter() { var visible = _all.Where(item => IssueSearch.Matches(item.Issue, SearchText)).ToList(); - var groups = IssueGrouper.Group(visible, item => item.Issue.Repository.FullName, item => item.Issue.UpdatedAt); + var groups = IssueGrouper.Group(visible, item => item.Issue.Repository.DisplayName, item => item.Issue.UpdatedAt); Groups.Clear(); foreach (var group in groups) diff --git a/src/Kontext.Presentation/ShellViewModel.cs b/src/Kontext.Presentation/ShellViewModel.cs index dea8ece..6295f1a 100644 --- a/src/Kontext.Presentation/ShellViewModel.cs +++ b/src/Kontext.Presentation/ShellViewModel.cs @@ -6,20 +6,31 @@ namespace Kontext.Presentation; -public sealed partial class ShellViewModel(IGitHubService gitHub, IGitHubDeviceLogin deviceLogin, IGitHubTokenStore tokenStore, WorkspaceViewModel workspace) : ObservableObject +public sealed partial class ShellViewModel( + IGitHubService gitHub, + IGitHubDeviceLogin deviceLogin, + IGitHubTokenStore tokenStore, + IEnterpriseServerPrompt enterprisePrompt, + WorkspaceViewModel workspace) : ObservableObject { public WorkspaceViewModel Workspace { get; } = workspace; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(IsSignedIn), nameof(IsSignedOut), nameof(ShowSignIn), nameof(SignedInText))] + [NotifyPropertyChangedFor(nameof(IsSignedIn), nameof(IsSignedOut), nameof(ShowSignIn), nameof(SignedInText), nameof(GitHubStatusText), nameof(HasGitHubAccount), nameof(HasNoGitHubAccount))] + [NotifyCanExecuteChangedFor(nameof(SignInCommand))] public partial GitHubUser? User { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsSignedIn), nameof(IsSignedOut), nameof(ShowSignIn), nameof(EnterpriseSignedInText), nameof(EnterpriseStatusText), nameof(HasEnterpriseAccount), nameof(HasNoEnterpriseAccount))] + [NotifyCanExecuteChangedFor(nameof(SignInEnterpriseCommand))] + public partial GitHubUser? EnterpriseUser { get; set; } + [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowSignIn))] public partial bool IsStarting { get; set; } = true; [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(SignInCommand))] + [NotifyCanExecuteChangedFor(nameof(SignInCommand), nameof(SignInEnterpriseCommand))] public partial bool IsBusy { get; set; } [ObservableProperty] @@ -31,32 +42,55 @@ public sealed partial class ShellViewModel(IGitHubService gitHub, IGitHubDeviceL public partial string? UserCode { get; set; } [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DeviceCodePrompt), nameof(VerificationLinkText))] public partial Uri? VerificationUrl { get; set; } public bool HasDeviceCode => !string.IsNullOrEmpty(UserCode); - public bool IsSignedIn => User is not null; + public string DeviceCodePrompt => VerificationUrl is null ? string.Empty : $"Enter this code on {VerificationUrl.Authority}:"; + + public string VerificationLinkText => VerificationUrl is null ? string.Empty : $"Open {VerificationUrl.Authority}{VerificationUrl.AbsolutePath}"; - public bool IsSignedOut => User is null; + public bool IsSignedIn => User is not null || EnterpriseUser is not null; + + public bool IsSignedOut => !IsSignedIn; public bool ShowSignIn => IsSignedOut && !IsStarting; + public bool HasGitHubAccount => User is not null; + + public bool HasNoGitHubAccount => User is null; + + public bool HasEnterpriseAccount => EnterpriseUser is not null; + + public bool HasNoEnterpriseAccount => EnterpriseUser is null; + public bool HasError => !string.IsNullOrEmpty(ErrorMessage); public string SignedInText => User is null ? string.Empty : $"Signed in as {User.Name ?? User.Login} (@{User.Login})"; + public string EnterpriseSignedInText => EnterpriseUser is null ? string.Empty : $"Signed in to {EnterpriseUser.Host} as {EnterpriseUser.Name ?? EnterpriseUser.Login} (@{EnterpriseUser.Login})"; + + public string GitHubStatusText => User is null ? "Not signed in" : SignedInText; + + public string EnterpriseStatusText => EnterpriseUser is null ? "Not signed in" : EnterpriseSignedInText; + [RelayCommand] private async Task InitializeAsync() { try { - var savedToken = tokenStore.Load(); - if (savedToken is null) + var signedIn = await SignInWithSavedTokenAsync(GitHubHosts.Default, "GitHub"); + + if (tokenStore.FindEnterpriseHost() is { } enterpriseHost) { - return; + signedIn |= await SignInWithSavedTokenAsync(enterpriseHost, $"GitHub Enterprise ({enterpriseHost})"); } - await SignInWithSavedTokenAsync(savedToken); + if (signedIn) + { + Workspace.Sidebar.RefreshCommand.Execute(null); + } } finally { @@ -64,22 +98,30 @@ private async Task InitializeAsync() } } - private async Task SignInWithSavedTokenAsync(string savedToken) + private async Task SignInWithSavedTokenAsync(string host, string accountName) { + var savedToken = tokenStore.Load(host); + if (savedToken is null) + { + return false; + } + IsBusy = true; try { - User = await gitHub.AuthenticateAsync(savedToken); - Workspace.Sidebar.RefreshCommand.Execute(null); + ShowUser(await gitHub.AuthenticateAsync(host, savedToken)); + return true; } catch (AuthenticationFailedException) { - tokenStore.Clear(); - ErrorMessage = "Your saved GitHub sign-in is no longer valid. Please sign in again."; + tokenStore.Clear(host); + AddError($"Your saved {accountName} sign-in is no longer valid. Please sign in again."); + return false; } catch (KontextException ex) { - ErrorMessage = ex.Message; + AddError(ex.Message); + return false; } finally { @@ -94,14 +136,12 @@ private async Task SignInAsync(CancellationToken cancellationToken) IsBusy = true; try { - var challenge = await deviceLogin.StartAsync(cancellationToken); + var challenge = await deviceLogin.StartAsync(GitHubServer.GitHub, cancellationToken); UserCode = challenge.UserCode; VerificationUrl = challenge.VerificationUri; var token = await deviceLogin.WaitForAccessTokenAsync(challenge, cancellationToken); - User = await gitHub.AuthenticateAsync(token, cancellationToken); - tokenStore.Save(token); - Workspace.Sidebar.RefreshCommand.Execute(null); + await CompleteSignInAsync(GitHubHosts.Default, token, cancellationToken); } catch (OperationCanceledException) { @@ -118,19 +158,107 @@ private async Task SignInAsync(CancellationToken cancellationToken) } } + [RelayCommand(IncludeCancelCommand = true, CanExecute = nameof(CanSignInEnterprise))] + private async Task SignInEnterpriseAsync(CancellationToken cancellationToken) + { + var credentials = await enterprisePrompt.AskAsync(); + if (credentials is null) + { + return; + } + + ErrorMessage = null; + IsBusy = true; + try + { + await CompleteSignInAsync(credentials.Server.Host, credentials.Token, cancellationToken); + } + catch (OperationCanceledException) + { + } + catch (KontextException ex) + { + ErrorMessage = ex.Message; + } + finally + { + IsBusy = false; + } + } + + private async Task CompleteSignInAsync(string host, string token, CancellationToken cancellationToken) + { + ShowUser(await gitHub.AuthenticateAsync(host, token, cancellationToken)); + tokenStore.Save(host, token); + Workspace.Sidebar.RefreshCommand.Execute(null); + } + [RelayCommand] - private void SignOut() + private void CancelSignIn() { SignInCancelCommand.Execute(null); + SignInEnterpriseCancelCommand.Execute(null); + } + + [RelayCommand] + private void SignOut() => SignOutOf(GitHubHosts.Default, SignInCancelCommand); + + [RelayCommand] + private void SignOutEnterprise() + { + var host = EnterpriseUser?.Host ?? tokenStore.FindEnterpriseHost(); + if (host is null) + { + SignInEnterpriseCancelCommand.Execute(null); + return; + } + + SignOutOf(host, SignInEnterpriseCancelCommand); + } + + private void SignOutOf(string host, System.Windows.Input.ICommand cancelSignIn) + { + cancelSignIn.Execute(null); UserCode = null; VerificationUrl = null; - gitHub.SignOut(); - tokenStore.Clear(); + gitHub.SignOut(host); + tokenStore.Clear(host); + ErrorMessage = null; + + if (GitHubHosts.IsDefault(host)) + { + User = null; + } + else + { + EnterpriseUser = null; + } + + if (IsSignedIn) + { + Workspace.Sidebar.RefreshCommand.Execute(null); + return; + } + Workspace.Sidebar.Clear(); Workspace.IsSettingsOpen = false; - ErrorMessage = null; - User = null; } - private bool CanSignIn() => !IsBusy; + private void ShowUser(GitHubUser user) + { + if (GitHubHosts.IsDefault(user.Host)) + { + User = user; + } + else + { + EnterpriseUser = user; + } + } + + private void AddError(string message) => ErrorMessage = string.IsNullOrEmpty(ErrorMessage) ? message : $"{ErrorMessage} {message}"; + + private bool CanSignIn() => !IsBusy && User is null; + + private bool CanSignInEnterprise() => !IsBusy && EnterpriseUser is null; } diff --git a/src/Kontext/ServiceCollectionExtensions.cs b/src/Kontext/ServiceCollectionExtensions.cs index 3d6b26a..bdf40c7 100644 --- a/src/Kontext/ServiceCollectionExtensions.cs +++ b/src/Kontext/ServiceCollectionExtensions.cs @@ -42,6 +42,7 @@ public static IServiceCollection AddKontext(this IServiceCollection services, IC services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(_ => new DispatcherQueueDispatcher(DispatcherQueue.GetForCurrentThread())); diff --git a/src/Kontext/Services/ContentDialogEnterprisePrompt.cs b/src/Kontext/Services/ContentDialogEnterprisePrompt.cs new file mode 100644 index 0000000..7f2b7ac --- /dev/null +++ b/src/Kontext/Services/ContentDialogEnterprisePrompt.cs @@ -0,0 +1,69 @@ +using Kontext.Contracts; +using Kontext.Domain; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; + +namespace Kontext.Services; + +internal sealed class ContentDialogEnterprisePrompt(XamlRootProvider root) : IEnterpriseServerPrompt +{ + private string _lastAddress = string.Empty; + + public async Task AskAsync() + { + var address = new TextBox { Header = "Server address", PlaceholderText = "yourcompany.ghe.com", Text = _lastAddress }; + var token = new PasswordBox { Header = "Personal access token" }; + var error = new TextBlock + { + TextWrapping = TextWrapping.Wrap, + Visibility = Visibility.Collapsed, + Foreground = (Brush)Application.Current.Resources["SystemFillColorCriticalBrush"], + }; + + var hint = new TextBlock + { + TextWrapping = TextWrapping.Wrap, + Text = "Create a personal access token on your GitHub Enterprise server (Settings, Developer settings, Personal access tokens) with the repo scope, then paste it here. Kontext stores it in the Windows Credential Locker.", + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + }; + + var dialog = new ContentDialog + { + XamlRoot = root.Current, + Title = "Sign in with GitHub Enterprise", + Content = new StackPanel { Spacing = 12, Children = { hint, address, token, error } }, + PrimaryButtonText = "Sign in", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Primary, + }; + + GitHubServer? server = null; + dialog.PrimaryButtonClick += (_, args) => + { + var valid = GitHubServer.TryCreate(address.Text, out server, out var message); + if (valid && string.IsNullOrWhiteSpace(token.Password)) + { + valid = false; + message = "Paste your personal access token."; + } + + if (valid) + { + return; + } + + error.Text = message; + error.Visibility = Visibility.Visible; + args.Cancel = true; + }; + + if (await dialog.ShowAsync() != ContentDialogResult.Primary || server is null) + { + return null; + } + + _lastAddress = address.Text.Trim(); + return new EnterpriseCredentials(server, token.Password.Trim()); + } +} diff --git a/src/Kontext/Services/CredentialLockerTokenStore.cs b/src/Kontext/Services/CredentialLockerTokenStore.cs index 69d15c6..c4f4924 100644 --- a/src/Kontext/Services/CredentialLockerTokenStore.cs +++ b/src/Kontext/Services/CredentialLockerTokenStore.cs @@ -1,4 +1,5 @@ using Kontext.Contracts; +using Kontext.Kernel; using Windows.Security.Credentials; namespace Kontext.Services; @@ -8,48 +9,78 @@ internal sealed class CredentialLockerTokenStore : IGitHubTokenStore private const string Resource = "Kontext/GitHub"; private const string UserName = "github-oauth-token"; private const string LegacyUserName = "personal-access-token"; + private const string EnterpriseResource = "Kontext/GitHub-Enterprise"; private const int ElementNotFound = unchecked((int)0x80070490); private readonly PasswordVault _vault = new(); - public string? Load() + public string? Load(string host) { RemoveLegacy(); - var credential = Find(UserName); + var credential = GitHubHosts.IsDefault(host) ? Find(Resource, UserName) : Find(EnterpriseResource, host); credential?.RetrievePassword(); return credential?.Password; } - public void Save(string token) => _vault.Add(new PasswordCredential(Resource, UserName, token)); + public void Save(string host, string token) + { + if (GitHubHosts.IsDefault(host)) + { + _vault.Add(new PasswordCredential(Resource, UserName, token)); + return; + } - public void Clear() + // Only one enterprise account is kept, so a new one replaces whatever was saved before. + foreach (var existing in FindAll(EnterpriseResource)) + { + _vault.Remove(existing); + } + + _vault.Add(new PasswordCredential(EnterpriseResource, host, token)); + } + + public void Clear(string host) { - var credential = Find(UserName); + var credential = GitHubHosts.IsDefault(host) ? Find(Resource, UserName) : Find(EnterpriseResource, host); if (credential is not null) { _vault.Remove(credential); } } + public string? FindEnterpriseHost() => FindAll(EnterpriseResource).FirstOrDefault()?.UserName; + private void RemoveLegacy() { - var legacy = Find(LegacyUserName); + var legacy = Find(Resource, LegacyUserName); if (legacy is not null) { _vault.Remove(legacy); } } - private PasswordCredential? Find(string userName) + private PasswordCredential? Find(string resource, string userName) { try { - return _vault.Retrieve(Resource, userName); + return _vault.Retrieve(resource, userName); } catch (Exception ex) when (ex.HResult == ElementNotFound) { return null; } } + + private List FindAll(string resource) + { + try + { + return [.. _vault.FindAllByResource(resource)]; + } + catch (Exception ex) when (ex.HResult == ElementNotFound) + { + return []; + } + } } diff --git a/src/Kontext/Views/DeviceCodePanel.xaml b/src/Kontext/Views/DeviceCodePanel.xaml new file mode 100644 index 0000000..c6cdfdf --- /dev/null +++ b/src/Kontext/Views/DeviceCodePanel.xaml @@ -0,0 +1,29 @@ + + + + + + + + + + + diff --git a/src/Kontext/Views/DeviceCodePanel.xaml.cs b/src/Kontext/Views/DeviceCodePanel.xaml.cs new file mode 100644 index 0000000..c791ede --- /dev/null +++ b/src/Kontext/Views/DeviceCodePanel.xaml.cs @@ -0,0 +1,51 @@ +using System.Runtime.InteropServices; +using Kontext.Presentation; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Windows.ApplicationModel.DataTransfer; + +namespace Kontext.Views; + +public sealed partial class DeviceCodePanel : UserControl +{ + private const string CopyGlyph = ""; + private const string CopiedGlyph = ""; + + public static readonly DependencyProperty ViewModelProperty = DependencyProperty.Register( + nameof(ViewModel), + typeof(ShellViewModel), + typeof(DeviceCodePanel), + new PropertyMetadata(null, (panel, _) => ((DeviceCodePanel)panel).Bindings?.Update())); + + public DeviceCodePanel() => InitializeComponent(); + + public ShellViewModel? ViewModel + { + get => (ShellViewModel?)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + private async void CopyCodeButton_Click(object sender, RoutedEventArgs e) + { + if (string.IsNullOrEmpty(ViewModel?.UserCode)) + { + return; + } + + try + { + var package = new DataPackage(); + package.SetText(ViewModel.UserCode); + Clipboard.SetContent(package); + Clipboard.Flush(); + } + catch (Exception ex) when (ex is COMException or InvalidOperationException) + { + return; + } + + CopyCodeIcon.Glyph = CopiedGlyph; + await Task.Delay(TimeSpan.FromSeconds(1.5)); + CopyCodeIcon.Glyph = CopyGlyph; + } +} diff --git a/src/Kontext/Views/SettingsView.xaml b/src/Kontext/Views/SettingsView.xaml index 86bbd7f..45d72e7 100644 --- a/src/Kontext/Views/SettingsView.xaml +++ b/src/Kontext/Views/SettingsView.xaml @@ -1,7 +1,8 @@ + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:views="using:Kontext.Views">