diff --git a/.github/README.md b/.github/README.md index 4e8c019..9763d2c 100644 --- a/.github/README.md +++ b/.github/README.md @@ -12,9 +12,9 @@

Mission control for AI-assisted development on Windows: GitHub issues, isolated git worktrees and embedded Claude Code terminals in one app.
- Report Bug + Report Bug ยท - Request Feature + Request Feature

@@ -35,6 +35,7 @@
  • Requirements
  • Build and run
  • Test
  • +
  • Release
  • Contributing
  • @@ -54,7 +55,7 @@ Kontext lets you work on all your repositories' issues at once and leave Claude * **One worktree per issue.** Selecting an issue clones the repository when needed, creates a `features/{number}-{title}` branch in its own worktree, and opens an empty Claude Code session there. Nothing is asked of you, and nothing is shared between issues. * **Know when Claude needs you.** Claude Code hooks report whether a session is working or waiting for input; the circle in the sidebar changes accordingly. * **Resume where you left off.** Sessions are remembered per issue and continued with `claude --resume` after a restart. -* **Open it anywhere.** Open a worktree in File Explorer, Windows Terminal or Visual Studio from the issue's header. +* **Open it anywhere.** Open a worktree in File Explorer, Windows Terminal or its solution file (`.slnx` or `.sln`, in whatever app is registered for it) from the issue's header. * **Clean up when done.** For a closed issue, Clear session removes the worktree and its branch. * **Modern Architecture:** Built with SOLID principles and design patterns for maximum maintainability: a modular monolith with compiler-enforced module boundaries. * **Native Windows look.** WinUI 3 with Mica, light and dark themes, and a terminal with zoom and mouse support. @@ -81,7 +82,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. For local development, copy `src/Kontext/appsettings.local.example.json` to `src/Kontext/appsettings.local.json` (git-ignored) and set `GitHub:ClientId` to the app's client id (the client id is not a secret). CI writes the repository variable `KONTEXT_GITHUB_CLIENT_ID` into `appsettings.json` before building. Organisations that restrict OAuth apps must approve the app before their repositories and issues become available. ### Build and run @@ -98,7 +103,28 @@ Stop a running instance before building again, because it locks the build output dotnet test Kontext.slnx -c Release ``` -Warnings are treated as errors. The tests include integration tests that run real `git` against a temporary repository. +Warnings are treated as errors. Tests are split by kind, one project per kind and module: + +* `Kontext.*.UnitTests` are fast and in-memory: no filesystem, processes or network. +* `Kontext.*.IntegrationTests` touch the real world: the filesystem, a loopback HTTP server, or real `git` against a temporary repository. +* `Kontext.ArchitectureTests` and `Kontext.CompositionTests` check the module boundaries and the dependency injection wiring. + +To run only the unit tests: + +```powershell +Get-ChildItem tests -Recurse -Filter *.UnitTests.csproj | ForEach-Object { dotnet test $_.FullName -c Release } +``` + +### Release + +Publishing a GitHub release tagged like `v1.2.3` runs the tests, then builds a signed, self-contained x64 MSIX and attaches it to the release together with `Kontext.cer`, the public certificate. The tag sets the package version. ARM64 is not built because the embedded terminal control only ships x64 binaries. + +One-time setup: + +* Signing uses the `CERTIFICATE_BASE64` (the `.pfx`, base64 encoded) and `CERTIFICATE_PASSWORD` secrets. They are organization secrets of `buning-software`, shared with the other Buning Software apps, and this repository has to be allowed to use them (organization Settings, Secrets and variables, Actions). The certificate subject must equal `Publisher` in `src/Kontext/Package.appxmanifest` (`CN=BuningSoftware`). To create a new certificate, for example when it expires, use `generate_certificate.ps1` in the [Surveil repository](https://github.com/buning-software/surveil), update both organization secrets, and never commit the `.pfx`. +* Set the repository variable `KONTEXT_GITHUB_CLIENT_ID`. An installed MSIX is read-only, so the client id has to be built in; the release fails without it. + +To install a release, first trust `Kontext.cer` once (open it, choose Install Certificate, Local Machine, Trusted People), then open the `.msix`. Kontext still keeps its data under `%LOCALAPPDATA%\Kontext`: the package turns off file redirection so your clones and worktrees stay visible to Explorer, terminals and IDEs. ## Contributing @@ -128,13 +154,13 @@ Distributed under the GNU Affero General Public License v3.0 License. See `LICEN -[contributors-shield]: https://img.shields.io/github/contributors/jellebuning/kontext.svg?style=for-the-badge -[contributors-url]: https://github.com/jellebuning/kontext/graphs/contributors -[forks-shield]: https://img.shields.io/github/forks/jellebuning/kontext.svg?style=for-the-badge -[forks-url]: https://github.com/jellebuning/kontext/network/members -[stars-shield]: https://img.shields.io/github/stars/jellebuning/kontext.svg?style=for-the-badge -[stars-url]: https://github.com/jellebuning/kontext/stargazers -[issues-shield]: https://img.shields.io/github/issues/jellebuning/kontext.svg?style=for-the-badge -[issues-url]: https://github.com/jellebuning/kontext/issues -[license-shield]: https://img.shields.io/github/license/jellebuning/kontext.svg?style=for-the-badge -[license-url]: https://github.com/jellebuning/kontext/blob/main/LICENSE +[contributors-shield]: https://img.shields.io/github/contributors/buning-software/kontext.svg?style=for-the-badge +[contributors-url]: https://github.com/buning-software/kontext/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/buning-software/kontext.svg?style=for-the-badge +[forks-url]: https://github.com/buning-software/kontext/network/members +[stars-shield]: https://img.shields.io/github/stars/buning-software/kontext.svg?style=for-the-badge +[stars-url]: https://github.com/buning-software/kontext/stargazers +[issues-shield]: https://img.shields.io/github/issues/buning-software/kontext.svg?style=for-the-badge +[issues-url]: https://github.com/buning-software/kontext/issues +[license-shield]: https://img.shields.io/github/license/buning-software/kontext.svg?style=for-the-badge +[license-url]: https://github.com/buning-software/kontext/blob/main/LICENSE diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 23b414c..8d186b0 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -31,6 +31,17 @@ jobs: - name: Install dependencies run: dotnet restore + - name: Set GitHub OAuth client id + if: ${{ vars.KONTEXT_GITHUB_CLIENT_ID != '' }} + shell: pwsh + env: + CLIENT_ID: ${{ vars.KONTEXT_GITHUB_CLIENT_ID }} + run: | + $path = 'src/Kontext/appsettings.json' + $settings = Get-Content $path -Raw | ConvertFrom-Json + $settings.GitHub.ClientId = $env:CLIENT_ID + $settings | ConvertTo-Json -Depth 5 | Set-Content $path + - name: Build run: dotnet build --no-restore --configuration Release diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..b326a76 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,143 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + test: + uses: ./.github/workflows/build-and-test.yaml + + release: + needs: test + runs-on: windows-latest + strategy: + matrix: + # ARM64 is not built yet: EasyWindowsTerminalControl.WinUI only ships x64 binaries. + platform: [x64] + include: + - platform: x64 + rid: win-x64 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Extract version from tag + id: version + shell: pwsh + env: + TAG: ${{ github.ref_name }} + run: | + $displayVersion = $env:TAG.TrimStart('v', 'V') + if ($displayVersion -notmatch '^\d+\.\d+\.\d+$') { + Write-Error "Release tag '$env:TAG' must look like v1.2.3 (got '$displayVersion' after stripping the prefix)." + exit 1 + } + $manifestVersion = $displayVersion + '.0' + "DISPLAY_VERSION=$displayVersion" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "VERSION=$manifestVersion" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + + - name: Update Package.appxmanifest version + shell: pwsh + env: + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + # Only the package identity carries the app version; MinVersion and MaxVersionTested must stay as they are. + $manifest = "src\Kontext\Package.appxmanifest" + $pattern = '(]*?\sVersion=")\d+\.\d+\.\d+\.\d+(")' + $content = Get-Content $manifest -Raw + if ($content -notmatch $pattern) { + Write-Error "Could not find the version in $manifest." + exit 1 + } + [regex]::Replace($content, $pattern, "`${1}$env:VERSION`${2}") | Set-Content $manifest -NoNewline + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.0.x' + + - name: Load nuget cache + uses: actions/cache@v6 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Restore dependencies + run: dotnet restore + + - name: Set GitHub OAuth client id + shell: pwsh + env: + CLIENT_ID: ${{ vars.KONTEXT_GITHUB_CLIENT_ID }} + run: | + # An installed MSIX is read-only, so users cannot add appsettings.local.json: the id must be built in. + if ([string]::IsNullOrWhiteSpace($env:CLIENT_ID)) { + Write-Error "Set the KONTEXT_GITHUB_CLIENT_ID repository variable; without it the released app cannot sign in." + exit 1 + } + $path = 'src/Kontext/appsettings.json' + $settings = Get-Content $path -Raw | ConvertFrom-Json + $settings.GitHub.ClientId = $env:CLIENT_ID + $settings | ConvertTo-Json -Depth 5 | Set-Content $path + + - name: Decode signing certificate + shell: pwsh + env: + CERTIFICATE_BASE64: ${{ secrets.CERTIFICATE_BASE64 }} + run: | + $bytes = [Convert]::FromBase64String($env:CERTIFICATE_BASE64) + [IO.File]::WriteAllBytes("${{ github.workspace }}\cert.pfx", $bytes) + + - name: Build and Package MSIX + shell: pwsh + env: + CERT_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} + run: | + dotnet publish src\Kontext\Kontext.csproj ` + -c Release ` + -r ${{ matrix.rid }} ` + -p:Platform=${{ matrix.platform }} ` + -p:WindowsPackageType=MSIX ` + -p:GenerateAppxPackageOnBuild=true ` + -p:AppxPackageDir="${{ github.workspace }}\AppxPackages\" ` + -p:AppxPackageSigningEnabled=true ` + -p:PackageCertificateKeyFile="${{ github.workspace }}\cert.pfx" ` + -p:PackageCertificatePassword=$env:CERT_PASSWORD ` + -p:AppxBundle=Never + + - name: Export public certificate + if: matrix.platform == 'x64' + shell: pwsh + env: + CERT_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} + run: | + $securePassword = ConvertTo-SecureString -String $env:CERT_PASSWORD -AsPlainText -Force + $cert = Get-PfxCertificate -FilePath "${{ github.workspace }}\cert.pfx" -Password $securePassword + Export-Certificate -Cert $cert -FilePath "${{ github.workspace }}\Kontext.cer" | Out-Null + + - name: Remove signing certificate + if: always() + shell: pwsh + run: Remove-Item -Path "${{ github.workspace }}\cert.pfx" -Force -ErrorAction SilentlyContinue + + - name: Rename MSIX with platform suffix + shell: pwsh + run: | + $package = Get-ChildItem -Path "${{ github.workspace }}\AppxPackages" -Filter "*.msix" -Recurse | + Select-Object -First 1 + $newName = Join-Path $package.DirectoryName "Kontext_${{ steps.version.outputs.DISPLAY_VERSION }}_${{ matrix.platform }}.msix" + Rename-Item -Path $package.FullName -NewName $newName + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v3 + with: + files: | + AppxPackages/**/*.msix + *.cer diff --git a/.gitignore b/.gitignore index 7531c1d..0651ac1 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,17 @@ nunit-*.xml *.env.staging *.idea -.vs/ \ No newline at end of file +.vs/ +# Local developer settings (GitHub OAuth client id) +appsettings.local.json + +# MSIX app packages +**/AppPackages/ +AppxPackages/ +*.msix +*.msixbundle +*.appx +*.appxbundle + +# Code signing certificates +*.pfx diff --git a/Kontext.slnx b/Kontext.slnx index 046b1ea..cf5e297 100644 --- a/Kontext.slnx +++ b/Kontext.slnx @@ -14,14 +14,17 @@ - + + - - - - - - + + + + + + + + diff --git a/src/Kontext.Claude/Kontext.Claude.csproj b/src/Kontext.Claude/Kontext.Claude.csproj index a4106cb..8b652d7 100644 --- a/src/Kontext.Claude/Kontext.Claude.csproj +++ b/src/Kontext.Claude/Kontext.Claude.csproj @@ -15,7 +15,8 @@ - + + 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 new file mode 100644 index 0000000..5637b50 --- /dev/null +++ b/src/Kontext.Contracts/IGitHubDeviceLogin.cs @@ -0,0 +1,10 @@ +using Kontext.Domain; + +namespace Kontext.Contracts; + +public interface IGitHubDeviceLogin +{ + 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 new file mode 100644 index 0000000..c0897ba --- /dev/null +++ b/src/Kontext.Domain/DeviceCodeChallenge.cs @@ -0,0 +1,12 @@ +using Kontext.Kernel; + +namespace Kontext.Domain; + +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.Domain/WorkspaceTool.cs b/src/Kontext.Domain/WorkspaceTool.cs index 6ba80dd..ba223b5 100644 --- a/src/Kontext.Domain/WorkspaceTool.cs +++ b/src/Kontext.Domain/WorkspaceTool.cs @@ -6,5 +6,5 @@ public enum WorkspaceTool Terminal, - VisualStudio, + Solution, } diff --git a/src/Kontext.ExternalTools/IVisualStudioLocator.cs b/src/Kontext.ExternalTools/IVisualStudioLocator.cs deleted file mode 100644 index b772ead..0000000 --- a/src/Kontext.ExternalTools/IVisualStudioLocator.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Kontext.ExternalTools; - -internal interface IVisualStudioLocator -{ - string? FindDevenv(); -} diff --git a/src/Kontext.ExternalTools/Kontext.ExternalTools.csproj b/src/Kontext.ExternalTools/Kontext.ExternalTools.csproj index e72078d..31ffcda 100644 --- a/src/Kontext.ExternalTools/Kontext.ExternalTools.csproj +++ b/src/Kontext.ExternalTools/Kontext.ExternalTools.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Kontext.ExternalTools/ServiceCollectionExtensions.cs b/src/Kontext.ExternalTools/ServiceCollectionExtensions.cs index 3899a15..a75ad25 100644 --- a/src/Kontext.ExternalTools/ServiceCollectionExtensions.cs +++ b/src/Kontext.ExternalTools/ServiceCollectionExtensions.cs @@ -11,10 +11,9 @@ public static IServiceCollection AddKontextExternalTools(this IServiceCollection services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); return services; } diff --git a/src/Kontext.ExternalTools/SolutionTool.cs b/src/Kontext.ExternalTools/SolutionTool.cs new file mode 100644 index 0000000..3f301ee --- /dev/null +++ b/src/Kontext.ExternalTools/SolutionTool.cs @@ -0,0 +1,18 @@ +using Kontext.Domain; + +namespace Kontext.ExternalTools; + +internal sealed class SolutionTool(IFolderFiles files, IProcessStarter processes) : IWorkspaceTool +{ + private static readonly string[] SolutionPatterns = ["*.slnx", "*.sln"]; + + public WorkspaceTool Tool => WorkspaceTool.Solution; + + public void Open(string folder) + { + var solution = SolutionPatterns.SelectMany(pattern => files.Find(folder, pattern)).FirstOrDefault() + ?? throw new ExternalToolException("No solution file (.slnx or .sln) was found in this folder."); + + processes.Start(new ProcessLaunch(solution, [], UseShellExecute: true)); + } +} diff --git a/src/Kontext.ExternalTools/VisualStudioTool.cs b/src/Kontext.ExternalTools/VisualStudioTool.cs deleted file mode 100644 index 1e6b25c..0000000 --- a/src/Kontext.ExternalTools/VisualStudioTool.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Kontext.Domain; - -namespace Kontext.ExternalTools; - -internal sealed class VisualStudioTool(IFolderFiles files, IVisualStudioLocator locator, IProcessStarter processes) : IWorkspaceTool -{ - private static readonly string[] SolutionPatterns = ["*.slnx", "*.sln"]; - - public WorkspaceTool Tool => WorkspaceTool.VisualStudio; - - public void Open(string folder) - { - var solution = SolutionPatterns.SelectMany(pattern => files.Find(folder, pattern)).FirstOrDefault(); - if (solution is not null) - { - processes.Start(new ProcessLaunch(solution, [], UseShellExecute: true)); - return; - } - - var devenv = locator.FindDevenv() - ?? throw new ExternalToolException("Visual Studio was not found on this computer."); - - processes.Start(new ProcessLaunch(devenv, [folder])); - } -} diff --git a/src/Kontext.ExternalTools/VswhereVisualStudioLocator.cs b/src/Kontext.ExternalTools/VswhereVisualStudioLocator.cs deleted file mode 100644 index 3aa5f41..0000000 --- a/src/Kontext.ExternalTools/VswhereVisualStudioLocator.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Diagnostics; - -namespace Kontext.ExternalTools; - -internal sealed class VswhereVisualStudioLocator : IVisualStudioLocator -{ - private static readonly string[] Arguments = ["-latest", "-prerelease", "-property", "productPath"]; - - private static readonly string VswherePath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), - "Microsoft Visual Studio", - "Installer", - "vswhere.exe"); - - public string? FindDevenv() - { - if (!File.Exists(VswherePath)) - { - return null; - } - - var startInfo = new ProcessStartInfo(VswherePath) - { - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - foreach (var argument in Arguments) - { - startInfo.ArgumentList.Add(argument); - } - - using var process = Process.Start(startInfo); - if (process is null) - { - return null; - } - - var path = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(); - - return path.Length > 0 && File.Exists(path) ? path : null; - } -} 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/Kontext.GitAutomation.csproj b/src/Kontext.GitAutomation/Kontext.GitAutomation.csproj index eb37bb2..d3a67c8 100644 --- a/src/Kontext.GitAutomation/Kontext.GitAutomation.csproj +++ b/src/Kontext.GitAutomation/Kontext.GitAutomation.csproj @@ -15,7 +15,8 @@ - + + 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/DeviceFlowResponses.cs b/src/Kontext.GitHub/DeviceFlowResponses.cs new file mode 100644 index 0000000..908876b --- /dev/null +++ b/src/Kontext.GitHub/DeviceFlowResponses.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using Kontext.Domain; + +namespace Kontext.GitHub; + +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, Message: Describe(error, description)), + }; + } + + private 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/DeviceTokenResult.cs b/src/Kontext.GitHub/DeviceTokenResult.cs new file mode 100644 index 0000000..0189621 --- /dev/null +++ b/src/Kontext.GitHub/DeviceTokenResult.cs @@ -0,0 +1,17 @@ +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? Message = null); diff --git a/src/Kontext.GitHub/GitHubDeviceLogin.cs b/src/Kontext.GitHub/GitHubDeviceLogin.cs new file mode 100644 index 0000000..65c79a5 --- /dev/null +++ b/src/Kontext.GitHub/GitHubDeviceLogin.cs @@ -0,0 +1,79 @@ +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 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(GitHubServer? server = null, CancellationToken cancellationToken = default) + { + 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.Authority, host, StringComparison.OrdinalIgnoreCase)) + { + throw new GitHubException($"{Label(host)} sent an unexpected verification address, so the sign-in was stopped."); + } + + return challenge with { Host = host, ClientId = clientId }; + } + + public async Task WaitForAccessTokenAsync(DeviceCodeChallenge challenge, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(challenge); + var clientId = RequireClientId(challenge.ClientId); + var accessTokenUrl = AccessTokenUrl(challenge.Host); + + var interval = challenge.PollInterval; + while (true) + { + await Task.Delay(interval, time, cancellationToken); + if (time.GetUtcNow() >= challenge.ExpiresAt) + { + throw Expired(); + } + + var form = new Dictionary { ["client_id"] = clientId, ["device_code"] = challenge.DeviceCode, ["grant_type"] = DeviceGrantType }; + var result = DeviceFlowResponses.ParseTokenPoll(await poster.PostAsync(accessTokenUrl, form, cancellationToken)); + switch (result.Status) + { + case DeviceTokenStatus.Granted: + return result.Token!; + case DeviceTokenStatus.Pending: + break; + case DeviceTokenStatus.SlowDown: + interval = result.IntervalSeconds is > 0 and var seconds ? TimeSpan.FromSeconds(seconds) : interval + SlowDownStep; + break; + case DeviceTokenStatus.Expired: + throw Expired(); + case DeviceTokenStatus.Denied: + 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? explicitClientId) + { + var clientId = string.IsNullOrWhiteSpace(explicitClientId) ? options.ClientId : explicitClientId; + return string.IsNullOrWhiteSpace(clientId) ? throw new GitHubException(NotConfigured) : clientId; + } +} diff --git a/src/Kontext.GitHub/GitHubFormPoster.cs b/src/Kontext.GitHub/GitHubFormPoster.cs new file mode 100644 index 0000000..527c971 --- /dev/null +++ b/src/Kontext.GitHub/GitHubFormPoster.cs @@ -0,0 +1,29 @@ +using System.Net.Http.Headers; + +namespace Kontext.GitHub; + +internal sealed class GitHubFormPoster(HttpClient http) +{ + public async Task PostAsync(string url, IReadOnlyDictionary 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/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.GitHub/Kontext.GitHub.csproj b/src/Kontext.GitHub/Kontext.GitHub.csproj index 9de96aa..e297934 100644 --- a/src/Kontext.GitHub/Kontext.GitHub.csproj +++ b/src/Kontext.GitHub/Kontext.GitHub.csproj @@ -16,7 +16,7 @@ - + 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..7311bba 100644 --- a/src/Kontext.GitHub/ServiceCollectionExtensions.cs +++ b/src/Kontext.GitHub/ServiceCollectionExtensions.cs @@ -5,8 +5,12 @@ 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(); services.AddSingleton(); return services; } 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/Kontext.Presentation.csproj b/src/Kontext.Presentation/Kontext.Presentation.csproj index 35af4b4..48b5015 100644 --- a/src/Kontext.Presentation/Kontext.Presentation.csproj +++ b/src/Kontext.Presentation/Kontext.Presentation.csproj @@ -16,7 +16,7 @@ - + diff --git a/src/Kontext.Presentation/ShellViewModel.cs b/src/Kontext.Presentation/ShellViewModel.cs index 2ad2b96..6295f1a 100644 --- a/src/Kontext.Presentation/ShellViewModel.cs +++ b/src/Kontext.Presentation/ShellViewModel.cs @@ -6,52 +6,91 @@ 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, + 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(ShowSignIn))] - public partial bool IsStarting { get; set; } = true; + [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] - [NotifyCanExecuteChangedFor(nameof(SignInCommand))] - public partial string Token { get; set; } = string.Empty; + [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] [NotifyPropertyChangedFor(nameof(HasError))] public partial string? ErrorMessage { get; set; } - public bool IsSignedIn => User is not null; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasDeviceCode))] + 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 IsSignedOut => User is 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 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 { @@ -59,18 +98,53 @@ 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 token 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) + { + AddError(ex.Message); + return false; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand(IncludeCancelCommand = true, CanExecute = nameof(CanSignIn))] + private async Task SignInAsync(CancellationToken cancellationToken) + { + ErrorMessage = null; + IsBusy = true; + try + { + var challenge = await deviceLogin.StartAsync(GitHubServer.GitHub, cancellationToken); + UserCode = challenge.UserCode; + VerificationUrl = challenge.VerificationUri; + + var token = await deviceLogin.WaitForAccessTokenAsync(challenge, cancellationToken); + await CompleteSignInAsync(GitHubHosts.Default, token, cancellationToken); + } + catch (OperationCanceledException) + { } catch (KontextException ex) { @@ -78,22 +152,29 @@ private async Task SignInWithSavedTokenAsync(string savedToken) } finally { + UserCode = null; + VerificationUrl = null; IsBusy = false; } } - [RelayCommand(CanExecute = nameof(CanSignIn))] - private async Task SignInAsync() + [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 { - var token = Token.Trim(); - User = await gitHub.AuthenticateAsync(token); - tokenStore.Save(token); - Token = string.Empty; - Workspace.Sidebar.RefreshCommand.Execute(null); + await CompleteSignInAsync(credentials.Server.Host, credentials.Token, cancellationToken); + } + catch (OperationCanceledException) + { } catch (KontextException ex) { @@ -105,16 +186,79 @@ private async Task SignInAsync() } } + 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) { - gitHub.SignOut(); - tokenStore.Clear(); + cancelSignIn.Execute(null); + UserCode = null; + VerificationUrl = null; + 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 && !string.IsNullOrWhiteSpace(Token); + 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.Sessions/Kontext.Sessions.csproj b/src/Kontext.Sessions/Kontext.Sessions.csproj index ee3b057..b27dae1 100644 --- a/src/Kontext.Sessions/Kontext.Sessions.csproj +++ b/src/Kontext.Sessions/Kontext.Sessions.csproj @@ -15,7 +15,8 @@ - + + diff --git a/src/Kontext.TerminalHost/Kontext.TerminalHost.csproj b/src/Kontext.TerminalHost/Kontext.TerminalHost.csproj index d3a2a85..888006c 100644 --- a/src/Kontext.TerminalHost/Kontext.TerminalHost.csproj +++ b/src/Kontext.TerminalHost/Kontext.TerminalHost.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/Kontext/App.xaml.cs b/src/Kontext/App.xaml.cs index 5104b1f..2e374c7 100644 --- a/src/Kontext/App.xaml.cs +++ b/src/Kontext/App.xaml.cs @@ -1,5 +1,6 @@ using Kontext.Claude; using Kontext.Tray; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.UI.Xaml; @@ -15,7 +16,9 @@ public App() InitializeComponent(); _host = Host.CreateDefaultBuilder() - .ConfigureServices(services => services.AddKontext()) + .UseContentRoot(AppContext.BaseDirectory) + .ConfigureAppConfiguration(configuration => configuration.AddJsonFile("appsettings.local.json", optional: true)) + .ConfigureServices((context, services) => services.AddKontext(context.Configuration)) .Build(); } diff --git a/src/Kontext/Assets/LargeTile.scale-100.png b/src/Kontext/Assets/LargeTile.scale-100.png new file mode 100644 index 0000000..2766cb2 Binary files /dev/null and b/src/Kontext/Assets/LargeTile.scale-100.png differ diff --git a/src/Kontext/Assets/LargeTile.scale-200.png b/src/Kontext/Assets/LargeTile.scale-200.png new file mode 100644 index 0000000..4a6e736 Binary files /dev/null and b/src/Kontext/Assets/LargeTile.scale-200.png differ diff --git a/src/Kontext/Assets/LargeTile.scale-400.png b/src/Kontext/Assets/LargeTile.scale-400.png new file mode 100644 index 0000000..116b002 Binary files /dev/null and b/src/Kontext/Assets/LargeTile.scale-400.png differ diff --git a/src/Kontext/Assets/SmallTile.scale-100.png b/src/Kontext/Assets/SmallTile.scale-100.png new file mode 100644 index 0000000..d0fb8d5 Binary files /dev/null and b/src/Kontext/Assets/SmallTile.scale-100.png differ diff --git a/src/Kontext/Assets/SmallTile.scale-200.png b/src/Kontext/Assets/SmallTile.scale-200.png new file mode 100644 index 0000000..1f760a8 Binary files /dev/null and b/src/Kontext/Assets/SmallTile.scale-200.png differ diff --git a/src/Kontext/Assets/SmallTile.scale-400.png b/src/Kontext/Assets/SmallTile.scale-400.png new file mode 100644 index 0000000..524545c Binary files /dev/null and b/src/Kontext/Assets/SmallTile.scale-400.png differ diff --git a/src/Kontext/Assets/SplashScreen.scale-100.png b/src/Kontext/Assets/SplashScreen.scale-100.png new file mode 100644 index 0000000..11fed75 Binary files /dev/null and b/src/Kontext/Assets/SplashScreen.scale-100.png differ diff --git a/src/Kontext/Assets/SplashScreen.scale-200.png b/src/Kontext/Assets/SplashScreen.scale-200.png new file mode 100644 index 0000000..490900c Binary files /dev/null and b/src/Kontext/Assets/SplashScreen.scale-200.png differ diff --git a/src/Kontext/Assets/SplashScreen.scale-400.png b/src/Kontext/Assets/SplashScreen.scale-400.png new file mode 100644 index 0000000..12dd876 Binary files /dev/null and b/src/Kontext/Assets/SplashScreen.scale-400.png differ diff --git a/src/Kontext/Assets/Square150x150Logo.scale-100.png b/src/Kontext/Assets/Square150x150Logo.scale-100.png new file mode 100644 index 0000000..2870f6c Binary files /dev/null and b/src/Kontext/Assets/Square150x150Logo.scale-100.png differ diff --git a/src/Kontext/Assets/Square150x150Logo.scale-200.png b/src/Kontext/Assets/Square150x150Logo.scale-200.png new file mode 100644 index 0000000..0cb707d Binary files /dev/null and b/src/Kontext/Assets/Square150x150Logo.scale-200.png differ diff --git a/src/Kontext/Assets/Square150x150Logo.scale-400.png b/src/Kontext/Assets/Square150x150Logo.scale-400.png new file mode 100644 index 0000000..55a024d Binary files /dev/null and b/src/Kontext/Assets/Square150x150Logo.scale-400.png differ diff --git a/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-16.png b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-16.png new file mode 100644 index 0000000..77dccc1 Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-16.png differ diff --git a/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-24.png b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-24.png new file mode 100644 index 0000000..a87c7c0 Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-24.png differ diff --git a/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-256.png b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-256.png new file mode 100644 index 0000000..fc75584 Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-256.png differ diff --git a/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-32.png b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-32.png new file mode 100644 index 0000000..121f174 Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-32.png differ diff --git a/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-48.png b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-48.png new file mode 100644 index 0000000..b6ae140 Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.altform-unplated_targetsize-48.png differ diff --git a/src/Kontext/Assets/Square44x44Logo.scale-100.png b/src/Kontext/Assets/Square44x44Logo.scale-100.png new file mode 100644 index 0000000..6ff7e61 Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.scale-100.png differ diff --git a/src/Kontext/Assets/Square44x44Logo.scale-200.png b/src/Kontext/Assets/Square44x44Logo.scale-200.png new file mode 100644 index 0000000..b06081c Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.scale-200.png differ diff --git a/src/Kontext/Assets/Square44x44Logo.scale-400.png b/src/Kontext/Assets/Square44x44Logo.scale-400.png new file mode 100644 index 0000000..2939867 Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.scale-400.png differ diff --git a/src/Kontext/Assets/Square44x44Logo.targetsize-16.png b/src/Kontext/Assets/Square44x44Logo.targetsize-16.png new file mode 100644 index 0000000..77dccc1 Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.targetsize-16.png differ diff --git a/src/Kontext/Assets/Square44x44Logo.targetsize-24.png b/src/Kontext/Assets/Square44x44Logo.targetsize-24.png new file mode 100644 index 0000000..a87c7c0 Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.targetsize-24.png differ diff --git a/src/Kontext/Assets/Square44x44Logo.targetsize-256.png b/src/Kontext/Assets/Square44x44Logo.targetsize-256.png new file mode 100644 index 0000000..fc75584 Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.targetsize-256.png differ diff --git a/src/Kontext/Assets/Square44x44Logo.targetsize-32.png b/src/Kontext/Assets/Square44x44Logo.targetsize-32.png new file mode 100644 index 0000000..121f174 Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.targetsize-32.png differ diff --git a/src/Kontext/Assets/Square44x44Logo.targetsize-48.png b/src/Kontext/Assets/Square44x44Logo.targetsize-48.png new file mode 100644 index 0000000..b6ae140 Binary files /dev/null and b/src/Kontext/Assets/Square44x44Logo.targetsize-48.png differ diff --git a/src/Kontext/Assets/StoreLogo.scale-100.png b/src/Kontext/Assets/StoreLogo.scale-100.png new file mode 100644 index 0000000..68d786a Binary files /dev/null and b/src/Kontext/Assets/StoreLogo.scale-100.png differ diff --git a/src/Kontext/Assets/StoreLogo.scale-200.png b/src/Kontext/Assets/StoreLogo.scale-200.png new file mode 100644 index 0000000..6d9d4cd Binary files /dev/null and b/src/Kontext/Assets/StoreLogo.scale-200.png differ diff --git a/src/Kontext/Assets/StoreLogo.scale-400.png b/src/Kontext/Assets/StoreLogo.scale-400.png new file mode 100644 index 0000000..ca1c974 Binary files /dev/null and b/src/Kontext/Assets/StoreLogo.scale-400.png differ diff --git a/src/Kontext/Assets/Wide310x150Logo.scale-100.png b/src/Kontext/Assets/Wide310x150Logo.scale-100.png new file mode 100644 index 0000000..7cfb319 Binary files /dev/null and b/src/Kontext/Assets/Wide310x150Logo.scale-100.png differ diff --git a/src/Kontext/Assets/Wide310x150Logo.scale-200.png b/src/Kontext/Assets/Wide310x150Logo.scale-200.png new file mode 100644 index 0000000..30bddd0 Binary files /dev/null and b/src/Kontext/Assets/Wide310x150Logo.scale-200.png differ diff --git a/src/Kontext/Assets/Wide310x150Logo.scale-400.png b/src/Kontext/Assets/Wide310x150Logo.scale-400.png new file mode 100644 index 0000000..3a1f96c Binary files /dev/null and b/src/Kontext/Assets/Wide310x150Logo.scale-400.png differ diff --git a/src/Kontext/Kontext.csproj b/src/Kontext/Kontext.csproj index dbebf99..8b71a5a 100644 --- a/src/Kontext/Kontext.csproj +++ b/src/Kontext/Kontext.csproj @@ -11,13 +11,17 @@ $(NETCoreSdkRuntimeIdentifier) true true + None true + win-$(Platform).pubxml - + + + diff --git a/src/Kontext/Package.appxmanifest b/src/Kontext/Package.appxmanifest new file mode 100644 index 0000000..50353b3 --- /dev/null +++ b/src/Kontext/Package.appxmanifest @@ -0,0 +1,58 @@ + + + + + + + + Kontext + Buning Software + Assets\StoreLogo.png + + disabled + disabled + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Kontext/Properties/PublishProfiles/win-x64.pubxml b/src/Kontext/Properties/PublishProfiles/win-x64.pubxml new file mode 100644 index 0000000..2cdf43f --- /dev/null +++ b/src/Kontext/Properties/PublishProfiles/win-x64.pubxml @@ -0,0 +1,13 @@ + + + + x64 + win-x64 + bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ + true + false + true + false + Never + + diff --git a/src/Kontext/ServiceCollectionExtensions.cs b/src/Kontext/ServiceCollectionExtensions.cs index 325e43c..bdf40c7 100644 --- a/src/Kontext/ServiceCollectionExtensions.cs +++ b/src/Kontext/ServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ using Kontext.TerminalHost; using Kontext.Tray; using Kontext.Views; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Dispatching; @@ -18,7 +19,7 @@ namespace Kontext; public static class ServiceCollectionExtensions { - public static IServiceCollection AddKontext(this IServiceCollection services) + public static IServiceCollection AddKontext(this IServiceCollection services, IConfiguration configuration) { var overridden = Environment.GetEnvironmentVariable("KONTEXT_DATA_DIR"); var dataRoot = string.IsNullOrWhiteSpace(overridden) @@ -26,7 +27,7 @@ public static IServiceCollection AddKontext(this IServiceCollection services) : overridden; services - .AddKontextGitHub() + .AddKontextGitHub(new GitHubOptions(configuration["GitHub:ClientId"])) .AddKontextGitAutomation(new GitAutomationOptions( StateDirectory: dataRoot, WorktreesRoot: Path.Combine(dataRoot, "worktrees"), @@ -41,6 +42,7 @@ public static IServiceCollection AddKontext(this IServiceCollection services) 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 848f85d..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; @@ -6,38 +7,80 @@ 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 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) { - var credential = Find(); + RemoveLegacy(); + + 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; + } + + // 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() + public void Clear(string host) { - var credential = Find(); + var credential = GitHubHosts.IsDefault(host) ? Find(Resource, UserName) : Find(EnterpriseResource, host); if (credential is not null) { _vault.Remove(credential); } } - private PasswordCredential? Find() + public string? FindEnterpriseHost() => FindAll(EnterpriseResource).FirstOrDefault()?.UserName; + + private void RemoveLegacy() + { + var legacy = Find(Resource, LegacyUserName); + if (legacy is not null) + { + _vault.Remove(legacy); + } + } + + 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/IssueDetailView.xaml b/src/Kontext/Views/IssueDetailView.xaml index 0216268..6652bf4 100644 --- a/src/Kontext/Views/IssueDetailView.xaml +++ b/src/Kontext/Views/IssueDetailView.xaml @@ -114,7 +114,7 @@ - + diff --git a/src/Kontext/Views/IssueDetailView.xaml.cs b/src/Kontext/Views/IssueDetailView.xaml.cs index 5391929..6f5f390 100644 --- a/src/Kontext/Views/IssueDetailView.xaml.cs +++ b/src/Kontext/Views/IssueDetailView.xaml.cs @@ -38,7 +38,7 @@ public TerminalControlHost? ControlHost private void OpenInTerminal_Click(object sender, RoutedEventArgs e) => OpenIn(WorkspaceTool.Terminal); - private void OpenInVisualStudio_Click(object sender, RoutedEventArgs e) => OpenIn(WorkspaceTool.VisualStudio); + private void OpenSolution_Click(object sender, RoutedEventArgs e) => OpenIn(WorkspaceTool.Solution); private void OpenIn(WorkspaceTool tool) => ViewModel?.OpenInCommand.Execute(tool); 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">