diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dfdff0..5729917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,12 @@ Versioning follows [Semantic Versioning](https://semver.org/): `MAJOR.MINOR.PATC ## [Unreleased] -(none.) +### Fixed + +- Ctrl+C during a command printed `Error: The operation was canceled.` and + exited 1. A cancelled run is not an error, so it now exits 130, the shell + convention for SIGINT, and prints nothing. An HttpClient timeout still + reports as a failure with exit 1. ## [1.0.1] diff --git a/CLAUDE.md b/CLAUDE.md index 4a8605a..6a32729 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,12 @@ tests/Bbx.Tests/ FakeHttpMessageHandler, InMemoryCredentialStore, CaptureCons so `Main` keeps a non-zero invocation result and otherwise returns what the handler set. System.CommandLine 2.0 has no built-in exception handler, so `Main` wraps the invocation and prints the message rather than a stack trace. + Ctrl+C is the library's job, not ours: `InvocationPipeline` links the token + passed to `InvokeAsync` to a source of its own and cancels it from + `ProcessTerminationHandler`, which registers for SIGINT and SIGTERM. Passing + `default` still gives handlers a cancelable token. Do not add a + `Console.CancelKeyPress` hook; it only competes with that registration. + A cancelled run exits 130 and says nothing. 8. **Errors carry context.** `EnsureSuccessAsync` appends the HTTP status, names missing scopes from `error.detail.required`, and prints `error.data.announcement_url` for deprecations. `BitbucketErrorDetail.Detail` diff --git a/src/Bbx/Api/BitbucketClient.cs b/src/Bbx/Api/BitbucketClient.cs index 21170e0..4ffaa96 100644 --- a/src/Bbx/Api/BitbucketClient.cs +++ b/src/Bbx/Api/BitbucketClient.cs @@ -46,6 +46,14 @@ public async Task GetByteArrayAsync(string endpoint, CancellationToken c return await response.Content.ReadAsByteArrayAsync(ct); } + public async Task CopyToAsync(string endpoint, Stream destination, CancellationToken ct = default) + { + using var response = await SendAsync( + HttpMethod.Get, endpoint, null, ct, AnyMediaType, HttpCompletionOption.ResponseHeadersRead); + await EnsureSuccessAsync(response); + await response.Content.CopyToAsync(destination, ct); + } + public async Task PostAsync(string endpoint, object? body = null, CancellationToken ct = default) { var content = body != null @@ -123,10 +131,12 @@ private async Task SendAsync( string endpoint, HttpContent? content, CancellationToken ct, - string? accept = null) + string? accept = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead) { var current = new Uri(_client.BaseAddress!, NormalizeEndpoint(endpoint)); - var response = await SendOnceAsync(method, current.AbsoluteUri, content, ct, accept); + var response = await SendOnceAsync( + method, current.AbsoluteUri, content, ct, accept, completionOption, IsApiOrigin(current)); // Redirects are followed here rather than by HttpClient because // HttpClient drops the Authorization header when it follows one, which @@ -146,11 +156,11 @@ private async Task SendAsync( // Re-apply credentials only when staying on the same origin, so a // redirect out to storage (downloads) cannot leak them. - var sameOrigin = Uri.Compare(target, current, UriComponents.SchemeAndServer, - UriFormat.UriEscaped, StringComparison.OrdinalIgnoreCase) == 0; + var sameOrigin = IsApiOrigin(target); current = target; - response = await SendOnceAsync(HttpMethod.Get, target.AbsoluteUri, null, ct, accept, applyAuth: sameOrigin); + response = await SendOnceAsync( + HttpMethod.Get, target.AbsoluteUri, null, ct, accept, completionOption, sameOrigin); } return response; @@ -162,6 +172,7 @@ private async Task SendOnceAsync( HttpContent? content, CancellationToken ct, string? accept, + HttpCompletionOption completionOption, bool applyAuth = true) { var request = new HttpRequestMessage(method, url) @@ -176,9 +187,16 @@ private async Task SendOnceAsync( { await _auth.ApplyAsync(request, ct); } - return await _client.SendAsync(request, ct); + return await _client.SendAsync(request, completionOption, ct); } + private bool IsApiOrigin(Uri target) => Uri.Compare( + target, + _client.BaseAddress!, + UriComponents.SchemeAndServer, + UriFormat.UriEscaped, + StringComparison.OrdinalIgnoreCase) == 0; + private static bool IsRedirect(HttpResponseMessage response) => (int)response.StatusCode switch { 301 or 302 or 303 or 307 or 308 => true, diff --git a/src/Bbx/Auth/FileCredentialStore.cs b/src/Bbx/Auth/FileCredentialStore.cs index 569634d..2ea8027 100644 --- a/src/Bbx/Auth/FileCredentialStore.cs +++ b/src/Bbx/Auth/FileCredentialStore.cs @@ -37,27 +37,51 @@ public BbxConfig Load() var json = File.ReadAllText(_configFile); return JsonSerializer.Deserialize(json) ?? new BbxConfig(); } - catch + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) { - return new BbxConfig(); + throw new BbxUserException($"Error: Could not read bbx config '{_configFile}': {ex.Message}"); } } public void Save(BbxConfig config) { Directory.CreateDirectory(_configDir); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(_configDir, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + var json = JsonSerializer.Serialize(config, WriteOptions); - File.WriteAllText(_configFile, json); + var temporaryFile = Path.Combine(_configDir, $".config.{Guid.NewGuid():N}.tmp"); - if (!OperatingSystem.IsWindows()) + try { - try + var options = new FileStreamOptions { - File.SetUnixFileMode(_configFile, UnixFileMode.UserRead | UnixFileMode.UserWrite); + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.WriteThrough, + }; + if (!OperatingSystem.IsWindows()) + { + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; } - catch + + using (var stream = new FileStream(temporaryFile, options)) + using (var writer = new StreamWriter(stream)) { + writer.Write(json); + writer.Flush(); + stream.Flush(flushToDisk: true); } + + File.Move(temporaryFile, _configFile, overwrite: true); + } + finally + { + if (File.Exists(temporaryFile)) File.Delete(temporaryFile); } } diff --git a/src/Bbx/Commands/AuthCommand.cs b/src/Bbx/Commands/AuthCommand.cs index 386651c..b9e2d98 100644 --- a/src/Bbx/Commands/AuthCommand.cs +++ b/src/Bbx/Commands/AuthCommand.cs @@ -56,14 +56,12 @@ private static Command BuildLoginCommand(IServiceProvider services) if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(token)) { - Console.Error.WriteLine("Error: Email and API token required"); - Environment.ExitCode = 1; - return; + throw new BbxUserException("Error: Email and API token required"); } await CommandRunner.RunActionNoGateAsync(() => services.GetRequiredService() - .HandleAsync(new LoginApiTokenRequest(email, token), CancellationToken.None)); + .HandleAsync(new LoginApiTokenRequest(email, token), CommandBinding.CancellationToken)); }, emailOption, tokenOption); return loginCommand; @@ -72,11 +70,9 @@ await CommandRunner.RunActionNoGateAsync(() => private static Command BuildStatusCommand(IServiceProvider services) { var statusCommand = new Command("status", "Show authentication status"); - statusCommand.SetHandler(async () => - { - await services.GetRequiredService() - .HandleAsync(new AuthStatusRequest(), CancellationToken.None); - }); + statusCommand.SetHandler(() => CommandRunner.RunActionNoGateAsync(() => + services.GetRequiredService() + .HandleAsync(new AuthStatusRequest(), CommandBinding.CancellationToken))); return statusCommand; } @@ -87,7 +83,7 @@ private static Command BuildLogoutCommand(IServiceProvider services) { await CommandRunner.RunActionNoGateAsync(() => services.GetRequiredService() - .HandleAsync(new LogoutRequest(), CancellationToken.None)); + .HandleAsync(new LogoutRequest(), CommandBinding.CancellationToken)); }); return logoutCommand; } @@ -95,11 +91,9 @@ await CommandRunner.RunActionNoGateAsync(() => private static Command BuildTokenCommand(IServiceProvider services) { var tokenCommand = new Command("token", "Display current access token"); - tokenCommand.SetHandler(async () => - { - await services.GetRequiredService() - .HandleAsync(new AuthTokenRequest(), CancellationToken.None); - }); + tokenCommand.SetHandler(() => CommandRunner.RunActionNoGateAsync(() => + services.GetRequiredService() + .HandleAsync(new AuthTokenRequest(), CommandBinding.CancellationToken))); return tokenCommand; } @@ -112,7 +106,7 @@ private static Command BuildSetWorkspaceCommand(IServiceProvider services) { await CommandRunner.RunActionNoGateAsync(() => services.GetRequiredService() - .HandleAsync(new SetWorkspaceRequest(workspace), CancellationToken.None)); + .HandleAsync(new SetWorkspaceRequest(workspace), CommandBinding.CancellationToken)); }, workspaceArg); return setWorkspaceCommand; } diff --git a/src/Bbx/Commands/BranchCommand.cs b/src/Bbx/Commands/BranchCommand.cs index 0147bf6..b77e9ce 100644 --- a/src/Bbx/Commands/BranchCommand.cs +++ b/src/Bbx/Commands/BranchCommand.cs @@ -3,8 +3,8 @@ using Bbx.Features.Branches.CreateBranch; using Bbx.Features.Branches.DeleteBranch; using Bbx.Features.Branches.DeleteBranchRestriction; -using Bbx.Features.Branches.ListBranchRestrictions; using Bbx.Features.Branches.ListBranches; +using Bbx.Features.Branches.ListBranchRestrictions; using Bbx.Features.Branches.ViewBranch; using Bbx.Features.Tags.CreateTag; using Bbx.Features.Tags.DeleteTag; @@ -34,7 +34,7 @@ public static Command Create(IServiceProvider services) listCommand.SetHandler((string? workspace, string? repo, int limit, string? sort, string? query) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListBranchesRequest(workspace, repo, limit, sort, query), CancellationToken.None)), + .HandleAsync(new ListBranchesRequest(workspace, repo, limit, sort, query), CommandBinding.CancellationToken)), workspaceOption, repoOption, limitOption, sortOption, queryOption); command.Subcommands.Add(listCommand); @@ -44,19 +44,19 @@ public static Command Create(IServiceProvider services) viewCommand.SetHandler((string? workspace, string? repo, string name) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewBranchRequest(workspace, repo, name), CancellationToken.None)), + .HandleAsync(new ViewBranchRequest(workspace, repo, name), CommandBinding.CancellationToken)), workspaceOption, repoOption, nameArg); command.Subcommands.Add(viewCommand); var createCommand = new Command("create", "Create a new branch"); var createNameArg = new Argument("name") { Description = "Branch name" }; - var targetOption = new Option("--target") { Description = "Target commit hash or branch name" , Required = true }; + var targetOption = new Option("--target") { Description = "Target commit hash or branch name", Required = true }; createCommand.Arguments.Add(createNameArg); createCommand.Options.Add(targetOption); createCommand.SetHandler((string? workspace, string? repo, string name, string target) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new CreateBranchRequest(workspace, repo, name, target), CancellationToken.None)), + .HandleAsync(new CreateBranchRequest(workspace, repo, name, target), CommandBinding.CancellationToken)), workspaceOption, repoOption, createNameArg, targetOption); command.Subcommands.Add(createCommand); @@ -67,18 +67,11 @@ public static Command Create(IServiceProvider services) deleteCommand.Options.Add(yesOption); deleteCommand.SetHandler(async (string? workspace, string? repo, string name, bool yes) => { - if (!yes) - { - Console.Write($"Delete branch '{name}'? (y/N): "); - if (Console.ReadLine()?.Trim().ToLower() != "y") - { - Console.WriteLine("Cancelled."); - return; - } - } + if (!yes && !CommandRunner.ConfirmOrCancelStderr($"Delete branch '{name}'? [y/N]: ")) + return; await CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new DeleteBranchRequest(workspace, repo, name), CancellationToken.None)); + .HandleAsync(new DeleteBranchRequest(workspace, repo, name), CommandBinding.CancellationToken)); }, workspaceOption, repoOption, deleteNameArg, yesOption); command.Subcommands.Add(deleteCommand); @@ -88,19 +81,19 @@ await CommandRunner.RunActionAsync(() => restrictionsListCommand.SetHandler((string? workspace, string? repo) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListBranchRestrictionsRequest(workspace, repo), CancellationToken.None)), + .HandleAsync(new ListBranchRestrictionsRequest(workspace, repo), CommandBinding.CancellationToken)), workspaceOption, repoOption); restrictionsCommand.Subcommands.Add(restrictionsListCommand); var restrictionsAddCommand = new Command("add", "Add a branch restriction"); - var kindOption = new Option("--kind") { Description = "Restriction kind (push, force, delete, require_passing_builds_to_merge, require_approvals_to_merge, etc.)" , Required = true }; - var patternOption = new Option("--pattern") { Description = "Branch pattern (glob or exact match)" , Required = true }; + var kindOption = new Option("--kind") { Description = "Restriction kind (push, force, delete, require_passing_builds_to_merge, require_approvals_to_merge, etc.)", Required = true }; + var patternOption = new Option("--pattern") { Description = "Branch pattern (glob or exact match)", Required = true }; restrictionsAddCommand.Options.Add(kindOption); restrictionsAddCommand.Options.Add(patternOption); restrictionsAddCommand.SetHandler((string? workspace, string? repo, string kind, string pattern) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new AddBranchRestrictionRequest(workspace, repo, kind, pattern), CancellationToken.None)), + .HandleAsync(new AddBranchRestrictionRequest(workspace, repo, kind, pattern), CommandBinding.CancellationToken)), workspaceOption, repoOption, kindOption, patternOption); restrictionsCommand.Subcommands.Add(restrictionsAddCommand); @@ -117,7 +110,7 @@ await CommandRunner.RunActionAsync(() => return; await CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new DeleteBranchRestrictionRequest(workspace, repo, id), CancellationToken.None)); + .HandleAsync(new DeleteBranchRestrictionRequest(workspace, repo, id), CommandBinding.CancellationToken)); }, workspaceOption, repoOption, restrictionIdArg, restrictionYesOption); restrictionsCommand.Subcommands.Add(restrictionsDeleteCommand); @@ -142,7 +135,7 @@ private static Command CreateTagCommand(IServiceProvider services, Option CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListTagsRequest(workspace, repo, limit, sort, query), CancellationToken.None)), + .HandleAsync(new ListTagsRequest(workspace, repo, limit, sort, query), CommandBinding.CancellationToken)), workspaceOption, repoOption, listLimitOption, listSortOption, listQueryOption); tagCommand.Subcommands.Add(listCommand); @@ -152,13 +145,13 @@ private static Command CreateTagCommand(IServiceProvider services, Option CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewTagRequest(workspace, repo, name), CancellationToken.None)), + .HandleAsync(new ViewTagRequest(workspace, repo, name), CommandBinding.CancellationToken)), workspaceOption, repoOption, viewNameArg); tagCommand.Subcommands.Add(viewCommand); var createCommand = new Command("create", "Create a new tag"); var createNameArg = new Argument("name") { Description = "Tag name" }; - var createTargetOption = new Option("--target") { Description = "Target commit hash or branch name" , Required = true }; + var createTargetOption = new Option("--target") { Description = "Target commit hash or branch name", Required = true }; var createMessageOption = new Option("--message") { Description = "Annotation message (creates an annotated tag)" }; createCommand.Arguments.Add(createNameArg); createCommand.Options.Add(createTargetOption); @@ -166,7 +159,7 @@ private static Command CreateTagCommand(IServiceProvider services, Option CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new CreateTagRequest(workspace, repo, name, target, message), CancellationToken.None)), + .HandleAsync(new CreateTagRequest(workspace, repo, name, target, message), CommandBinding.CancellationToken)), workspaceOption, repoOption, createNameArg, createTargetOption, createMessageOption); tagCommand.Subcommands.Add(createCommand); @@ -181,7 +174,7 @@ private static Command CreateTagCommand(IServiceProvider services, Option services.GetRequiredService() - .HandleAsync(new DeleteTagRequest(workspace, repo, name), CancellationToken.None)); + .HandleAsync(new DeleteTagRequest(workspace, repo, name), CommandBinding.CancellationToken)); }, workspaceOption, repoOption, deleteNameArg, yesOption); tagCommand.Subcommands.Add(deleteCommand); diff --git a/src/Bbx/Commands/CommandBinding.cs b/src/Bbx/Commands/CommandBinding.cs index b0ed829..b6f8f83 100644 --- a/src/Bbx/Commands/CommandBinding.cs +++ b/src/Bbx/Commands/CommandBinding.cs @@ -43,6 +43,10 @@ internal readonly struct Bound /// internal static class CommandBinding { + private static readonly AsyncLocal ActiveCancellation = new(); + + public static CancellationToken CancellationToken => ActiveCancellation.Value; + /// /// Add an option that applies to this command and everything under it. /// @@ -53,52 +57,67 @@ public static void AddRecursiveOption(this Command command, Option option) } public static void SetHandler(this Command command, Func handler) - => command.SetAction((_, _) => handler()); + => command.SetAction((_, ct) => InvokeAsync(handler, ct)); public static void SetHandler( this Command command, Func handler, Bound b1) - => command.SetAction((pr, _) => handler(b1.From(pr))); + => command.SetAction((pr, ct) => InvokeAsync(() => handler(b1.From(pr)), ct)); public static void SetHandler( this Command command, Func handler, Bound b1, Bound b2) - => command.SetAction((pr, _) => handler(b1.From(pr), b2.From(pr))); + => command.SetAction((pr, ct) => InvokeAsync(() => handler(b1.From(pr), b2.From(pr)), ct)); public static void SetHandler( this Command command, Func handler, Bound b1, Bound b2, Bound b3) - => command.SetAction((pr, _) => handler(b1.From(pr), b2.From(pr), b3.From(pr))); + => command.SetAction((pr, ct) => InvokeAsync( + () => handler(b1.From(pr), b2.From(pr), b3.From(pr)), ct)); public static void SetHandler( this Command command, Func handler, Bound b1, Bound b2, Bound b3, Bound b4) - => command.SetAction((pr, _) => handler( - b1.From(pr), b2.From(pr), b3.From(pr), b4.From(pr))); + => command.SetAction((pr, ct) => InvokeAsync(() => handler( + b1.From(pr), b2.From(pr), b3.From(pr), b4.From(pr)), ct)); public static void SetHandler( this Command command, Func handler, Bound b1, Bound b2, Bound b3, Bound b4, Bound b5) - => command.SetAction((pr, _) => handler( - b1.From(pr), b2.From(pr), b3.From(pr), b4.From(pr), b5.From(pr))); + => command.SetAction((pr, ct) => InvokeAsync(() => handler( + b1.From(pr), b2.From(pr), b3.From(pr), b4.From(pr), b5.From(pr)), ct)); public static void SetHandler( this Command command, Func handler, Bound b1, Bound b2, Bound b3, Bound b4, Bound b5, Bound b6) - => command.SetAction((pr, _) => handler( - b1.From(pr), b2.From(pr), b3.From(pr), b4.From(pr), b5.From(pr), b6.From(pr))); + => command.SetAction((pr, ct) => InvokeAsync(() => handler( + b1.From(pr), b2.From(pr), b3.From(pr), b4.From(pr), b5.From(pr), b6.From(pr)), ct)); public static void SetHandler( this Command command, Func handler, Bound b1, Bound b2, Bound b3, Bound b4, Bound b5, Bound b6, Bound b7) - => command.SetAction((pr, _) => handler( + => command.SetAction((pr, ct) => InvokeAsync(() => handler( b1.From(pr), b2.From(pr), b3.From(pr), b4.From(pr), b5.From(pr), b6.From(pr), - b7.From(pr))); + b7.From(pr)), ct)); public static void SetHandler( this Command command, Func handler, Bound b1, Bound b2, Bound b3, Bound b4, Bound b5, Bound b6, Bound b7, Bound b8) - => command.SetAction((pr, _) => handler( + => command.SetAction((pr, ct) => InvokeAsync(() => handler( b1.From(pr), b2.From(pr), b3.From(pr), b4.From(pr), b5.From(pr), b6.From(pr), - b7.From(pr), b8.From(pr))); + b7.From(pr), b8.From(pr)), ct)); + + private static async Task InvokeAsync(Func handler, CancellationToken cancellationToken) + { + var previous = ActiveCancellation.Value; + ActiveCancellation.Value = cancellationToken; + try + { + await handler(); + } + finally + { + ActiveCancellation.Value = previous; + } + } } diff --git a/src/Bbx/Commands/CommandRunner.cs b/src/Bbx/Commands/CommandRunner.cs index f3a3a74..ef8d306 100644 --- a/src/Bbx/Commands/CommandRunner.cs +++ b/src/Bbx/Commands/CommandRunner.cs @@ -6,120 +6,41 @@ namespace Bbx.Commands; internal static class CommandRunner { - public static async Task RunJsonAsync(Func> handler) - { - try - { - await AuthGate.EnsureAuthenticatedAsync(Program.Services, CancellationToken.None); - var result = await handler(); - Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions.Current)); - } - catch (BbxUserException ex) - { - Console.Error.WriteLine(ex.Message); - Environment.ExitCode = 1; - } - catch (HttpRequestException ex) - { - Console.Error.WriteLine($"Error: {ex.Message}"); - Environment.ExitCode = 1; - } - } + public static Task RunJsonAsync(Func> handler) => RunAsync( + requireAuth: true, + async () => Console.WriteLine(JsonSerializer.Serialize(await handler(), JsonOptions.Current))); - public static async Task RunRawAsync(Func> handler) - { - try - { - await AuthGate.EnsureAuthenticatedAsync(Program.Services, CancellationToken.None); - var output = await handler(); - Console.WriteLine(output); - } - catch (BbxUserException ex) - { - Console.Error.WriteLine(ex.Message); - Environment.ExitCode = 1; - } - catch (HttpRequestException ex) - { - Console.Error.WriteLine($"Error: {ex.Message}"); - Environment.ExitCode = 1; - } - } + public static Task RunRawAsync(Func> handler) => RunAsync( + requireAuth: true, + async () => Console.WriteLine(await handler())); - public static async Task RunBinaryAsync(Func> handler) - { - try + public static Task RunStreamAsync(Func handler) => RunAsync( + requireAuth: true, + async () => { - await AuthGate.EnsureAuthenticatedAsync(Program.Services, CancellationToken.None); - var bytes = await handler(); await using var stdout = Console.OpenStandardOutput(); - await stdout.WriteAsync(bytes, CancellationToken.None); - await stdout.FlushAsync(CancellationToken.None); - } - catch (BbxUserException ex) - { - Console.Error.WriteLine(ex.Message); - Environment.ExitCode = 1; - } - catch (HttpRequestException ex) - { - Console.Error.WriteLine($"Error: {ex.Message}"); - Environment.ExitCode = 1; - } - } + await handler(stdout); + await stdout.FlushAsync(CommandBinding.CancellationToken); + }); - public static async Task RunActionAsync(Func> handler) - { - try - { - await AuthGate.EnsureAuthenticatedAsync(Program.Services, CancellationToken.None); - var message = await handler(); - Console.WriteLine(message); - } - catch (BbxUserException ex) - { - Console.Error.WriteLine(ex.Message); - Environment.ExitCode = 1; - } - catch (HttpRequestException ex) - { - Console.Error.WriteLine($"Error: {ex.Message}"); - Environment.ExitCode = 1; - } - } + public static Task RunDirectAsync(Func handler) => RunAsync(requireAuth: true, handler); + + public static Task RunActionAsync(Func> handler) => RunAsync( + requireAuth: true, + async () => Console.WriteLine(await handler())); // Auth subcommands (login, logout, status, token, refresh, set-workspace, // setup-oauth) opt out of the gate: triggering OAuth login while the user // is mid-`bbx auth …` would be circular and surprising. - public static async Task RunActionNoGateAsync(Func> handler) - { - try - { - var message = await handler(); - Console.WriteLine(message); - } - catch (BbxUserException ex) - { - Console.Error.WriteLine(ex.Message); - Environment.ExitCode = 1; - } - catch (HttpRequestException ex) - { - Console.Error.WriteLine($"Error: {ex.Message}"); - Environment.ExitCode = 1; - } - } + public static Task RunActionNoGateAsync(Func> handler) => RunAsync( + requireAuth: false, + async () => Console.WriteLine(await handler())); - public static bool ConfirmOrCancel(string prompt) + private static async Task RunAsync(bool requireAuth, Func action) { - Console.Write(prompt); - var response = Console.ReadLine()?.Trim().ToLower(); - if (response != "y" && response != "yes") - { - Console.WriteLine("Cancelled."); - return false; - } - return true; + if (requireAuth) + await AuthGate.EnsureAuthenticatedAsync(Program.Services, CommandBinding.CancellationToken); + await action(); } public static bool ConfirmOrCancelStderr(string prompt) diff --git a/src/Bbx/Commands/CommitCommand.cs b/src/Bbx/Commands/CommitCommand.cs index 04a4804..77857d2 100644 --- a/src/Bbx/Commands/CommitCommand.cs +++ b/src/Bbx/Commands/CommitCommand.cs @@ -7,8 +7,8 @@ using Bbx.Features.Commits.FileHistory; using Bbx.Features.Commits.ListCommitComments; using Bbx.Features.Commits.ListCommitPullRequests; -using Bbx.Features.Commits.ListCommitStatuses; using Bbx.Features.Commits.ListCommits; +using Bbx.Features.Commits.ListCommitStatuses; using Bbx.Features.Commits.MergeBase; using Bbx.Features.Commits.UnapproveCommit; using Bbx.Features.Commits.UpdateCommitStatus; @@ -37,7 +37,7 @@ public static Command Create(IServiceProvider services) listCommand.SetHandler((string? workspace, string? repo, string? branch, string? path, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListCommitsRequest(workspace, repo, branch, path, limit), CancellationToken.None)), + .HandleAsync(new ListCommitsRequest(workspace, repo, branch, path, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, branchOption, pathOption, limitOption); command.Subcommands.Add(listCommand); @@ -47,7 +47,7 @@ public static Command Create(IServiceProvider services) viewCommand.SetHandler((string? workspace, string? repo, string hash) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewCommitRequest(workspace, repo, hash), CancellationToken.None)), + .HandleAsync(new ViewCommitRequest(workspace, repo, hash), CommandBinding.CancellationToken)), workspaceOption, repoOption, hashArg); command.Subcommands.Add(viewCommand); @@ -57,7 +57,7 @@ public static Command Create(IServiceProvider services) diffCommand.SetHandler((string? workspace, string? repo, string hash) => CommandRunner.RunRawAsync(() => services.GetRequiredService() - .HandleAsync(new CommitDiffRequest(workspace, repo, hash), CancellationToken.None)), + .HandleAsync(new CommitDiffRequest(workspace, repo, hash), CommandBinding.CancellationToken)), workspaceOption, repoOption, diffHashArg); command.Subcommands.Add(diffCommand); @@ -67,7 +67,7 @@ public static Command Create(IServiceProvider services) patchCommand.SetHandler((string? workspace, string? repo, string hash) => CommandRunner.RunRawAsync(() => services.GetRequiredService() - .HandleAsync(new CommitPatchRequest(workspace, repo, hash), CancellationToken.None)), + .HandleAsync(new CommitPatchRequest(workspace, repo, hash), CommandBinding.CancellationToken)), workspaceOption, repoOption, patchHashArg); command.Subcommands.Add(patchCommand); @@ -77,7 +77,7 @@ public static Command Create(IServiceProvider services) commentsCommand.SetHandler((string? workspace, string? repo, string hash) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListCommitCommentsRequest(workspace, repo, hash), CancellationToken.None)), + .HandleAsync(new ListCommitCommentsRequest(workspace, repo, hash), CommandBinding.CancellationToken)), workspaceOption, repoOption, commentsHashArg); command.Subcommands.Add(commentsCommand); @@ -87,7 +87,7 @@ public static Command Create(IServiceProvider services) statusesCommand.SetHandler((string? workspace, string? repo, string hash) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListCommitStatusesRequest(workspace, repo, hash), CancellationToken.None)), + .HandleAsync(new ListCommitStatusesRequest(workspace, repo, hash), CommandBinding.CancellationToken)), workspaceOption, repoOption, statusesHashArg); command.Subcommands.Add(statusesCommand); @@ -104,7 +104,7 @@ public static Command Create(IServiceProvider services) filehistoryCommand.SetHandler((string? workspace, string? repo, string hash, string path, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new FileHistoryRequest(workspace, repo, hash, path, limit), CancellationToken.None)), + .HandleAsync(new FileHistoryRequest(workspace, repo, hash, path, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, fhHashArg, fhPathArg, fhLimitOption); command.Subcommands.Add(filehistoryCommand); @@ -115,7 +115,7 @@ public static Command Create(IServiceProvider services) mergeBaseCommand.SetHandler((string? workspace, string? repo, string spec) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new MergeBaseRequest(workspace, repo, spec), CancellationToken.None)), + .HandleAsync(new MergeBaseRequest(workspace, repo, spec), CommandBinding.CancellationToken)), workspaceOption, repoOption, mbSpecArg); command.Subcommands.Add(mergeBaseCommand); @@ -125,7 +125,7 @@ public static Command Create(IServiceProvider services) approveCommand.SetHandler((string? workspace, string? repo, string hash) => CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new ApproveCommitRequest(workspace, repo, hash), CancellationToken.None)), + .HandleAsync(new ApproveCommitRequest(workspace, repo, hash), CommandBinding.CancellationToken)), workspaceOption, repoOption, approveHashArg); command.Subcommands.Add(approveCommand); @@ -135,7 +135,7 @@ public static Command Create(IServiceProvider services) unapproveCommand.SetHandler((string? workspace, string? repo, string hash) => CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new UnapproveCommitRequest(workspace, repo, hash), CancellationToken.None)), + .HandleAsync(new UnapproveCommitRequest(workspace, repo, hash), CommandBinding.CancellationToken)), workspaceOption, repoOption, unapproveHashArg); command.Subcommands.Add(unapproveCommand); @@ -148,7 +148,7 @@ public static Command Create(IServiceProvider services) diffstatCommand.SetHandler((string? workspace, string? repo, string spec, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new CommitDiffstatRequest(workspace, repo, spec, limit), CancellationToken.None)), + .HandleAsync(new CommitDiffstatRequest(workspace, repo, spec, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, dsSpecArg, dsLimitOption); command.Subcommands.Add(diffstatCommand); @@ -158,7 +158,7 @@ public static Command Create(IServiceProvider services) prsCommand.SetHandler((string? workspace, string? repo, string hash) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListCommitPullRequestsRequest(workspace, repo, hash), CancellationToken.None)), + .HandleAsync(new ListCommitPullRequestsRequest(workspace, repo, hash), CommandBinding.CancellationToken)), workspaceOption, repoOption, prsHashArg); command.Subcommands.Add(prsCommand); @@ -171,10 +171,10 @@ private static Command CreateStatusCommand(IServiceProvider services, Option("hash") { Description = "Commit hash" }; - var createKeyOption = new Option("--key") { Description = "Build status key" , Required = true }; + var createKeyOption = new Option("--key") { Description = "Build status key", Required = true }; var createStateOption = new Option("--state") - { Description = "Build state (SUCCESSFUL, FAILED, INPROGRESS, STOPPED)" , Required = true }; - var createUrlOption = new Option("--url") { Description = "URL to the build (e.g., CI run)" , Required = true }; + { Description = "Build state (SUCCESSFUL, FAILED, INPROGRESS, STOPPED)", Required = true }; + var createUrlOption = new Option("--url") { Description = "URL to the build (e.g., CI run)", Required = true }; var createNameOption = new Option("--name") { Description = "Human-readable name" }; var createDescriptionOption = new Option("--description") { Description = "Description" }; createCommand.Arguments.Add(createHashArg); @@ -186,13 +186,13 @@ private static Command CreateStatusCommand(IServiceProvider services, Option CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new CreateCommitStatusRequest(workspace, repo, hash, key, state, url, name, description), CancellationToken.None)), + .HandleAsync(new CreateCommitStatusRequest(workspace, repo, hash, key, state, url, name, description), CommandBinding.CancellationToken)), workspaceOption, repoOption, createHashArg, createKeyOption, createStateOption, createUrlOption, createNameOption, createDescriptionOption); statusCommand.Subcommands.Add(createCommand); var updateCommand = new Command("update", "Update an existing build status on a commit"); var updateHashArg = new Argument("hash") { Description = "Commit hash" }; - var updateKeyOption = new Option("--key") { Description = "Build status key" , Required = true }; + var updateKeyOption = new Option("--key") { Description = "Build status key", Required = true }; var updateStateOption = new Option("--state") { Description = "Build state (SUCCESSFUL, FAILED, INPROGRESS, STOPPED)" }; var updateUrlOption = new Option("--url") { Description = "URL to the build" }; @@ -207,7 +207,7 @@ private static Command CreateStatusCommand(IServiceProvider services, Option CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new UpdateCommitStatusRequest(workspace, repo, hash, key, state, url, name, description), CancellationToken.None)), + .HandleAsync(new UpdateCommitStatusRequest(workspace, repo, hash, key, state, url, name, description), CommandBinding.CancellationToken)), workspaceOption, repoOption, updateHashArg, updateKeyOption, updateStateOption, updateUrlOption, updateNameOption, updateDescriptionOption); statusCommand.Subcommands.Add(updateCommand); diff --git a/src/Bbx/Commands/DownloadCommand.cs b/src/Bbx/Commands/DownloadCommand.cs index 22648a7..59649c2 100644 --- a/src/Bbx/Commands/DownloadCommand.cs +++ b/src/Bbx/Commands/DownloadCommand.cs @@ -23,19 +23,19 @@ public static Command Create(IServiceProvider services) listCommand.SetHandler((string? workspace, string? repo, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListDownloadsRequest(workspace, repo, limit), CancellationToken.None)), + .HandleAsync(new ListDownloadsRequest(workspace, repo, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, listLimitOption); command.Subcommands.Add(listCommand); var uploadCommand = new Command("upload", "Upload a new download artifact"); - var uploadFileOption = new Option("--file") { Description = "Path to the local file to upload" , Required = true }; + var uploadFileOption = new Option("--file") { Description = "Path to the local file to upload", Required = true }; var uploadNameOption = new Option("--name") { Description = "Name on Bitbucket (defaults to local filename)" }; uploadCommand.Options.Add(uploadFileOption); uploadCommand.Options.Add(uploadNameOption); uploadCommand.SetHandler((string? workspace, string? repo, string filePath, string? name) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new UploadDownloadRequest(workspace, repo, filePath, name), CancellationToken.None)), + .HandleAsync(new UploadDownloadRequest(workspace, repo, filePath, name), CommandBinding.CancellationToken)), workspaceOption, repoOption, uploadFileOption, uploadNameOption); command.Subcommands.Add(uploadCommand); @@ -51,24 +51,43 @@ public static Command Create(IServiceProvider services) { await CommandRunner.RunJsonAsync(async () => { - var bytes = await services.GetRequiredService() - .HandleAsync(new GetDownloadRequest(workspace, repo, filename, output), CancellationToken.None); - var dir = Path.GetDirectoryName(output); - if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - await File.WriteAllBytesAsync(output, bytes, CancellationToken.None); - return new + var fullOutput = Path.GetFullPath(output); + var dir = Path.GetDirectoryName(fullOutput)!; + Directory.CreateDirectory(dir); + var temporaryOutput = Path.Combine(dir, $".bbx-download-{Guid.NewGuid():N}.tmp"); + try { - filename, - output, - size = bytes.LongLength, - }; + long size; + await using (var destination = new FileStream( + temporaryOutput, FileMode.CreateNew, FileAccess.Write, FileShare.None, + bufferSize: 81920, useAsync: true)) + { + await services.GetRequiredService() + .HandleAsync( + new GetDownloadRequest(workspace, repo, filename, output), + destination, + CommandBinding.CancellationToken); + await destination.FlushAsync(CommandBinding.CancellationToken); + size = destination.Length; + } + + File.Move(temporaryOutput, fullOutput, overwrite: true); + return new { filename, output, size }; + } + finally + { + if (File.Exists(temporaryOutput)) File.Delete(temporaryOutput); + } }); return; } - await CommandRunner.RunBinaryAsync(() => + await CommandRunner.RunStreamAsync(stdout => services.GetRequiredService() - .HandleAsync(new GetDownloadRequest(workspace, repo, filename, null), CancellationToken.None)); + .HandleAsync( + new GetDownloadRequest(workspace, repo, filename, null), + stdout, + CommandBinding.CancellationToken)); }, workspaceOption, repoOption, getFilenameArg, getOutputOption); command.Subcommands.Add(getCommand); @@ -83,7 +102,7 @@ await CommandRunner.RunBinaryAsync(() => return; await CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new DeleteDownloadRequest(workspace, repo, filename), CancellationToken.None)); + .HandleAsync(new DeleteDownloadRequest(workspace, repo, filename), CommandBinding.CancellationToken)); }, workspaceOption, repoOption, deleteFilenameArg, yesOption); command.Subcommands.Add(deleteCommand); diff --git a/src/Bbx/Commands/IssueCommand.cs b/src/Bbx/Commands/IssueCommand.cs index 4b22f4a..08cce44 100644 --- a/src/Bbx/Commands/IssueCommand.cs +++ b/src/Bbx/Commands/IssueCommand.cs @@ -32,7 +32,7 @@ public static Command Create(IServiceProvider services) listCommand.SetHandler((string? workspace, string? repo, string? state, string? priority, string? assignee, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListIssuesRequest(workspace, repo, state, priority, assignee, limit), CancellationToken.None)), + .HandleAsync(new ListIssuesRequest(workspace, repo, state, priority, assignee, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, stateOption, priorityOption, assigneeOption, limitOption); command.Subcommands.Add(listCommand); @@ -42,12 +42,12 @@ public static Command Create(IServiceProvider services) viewCommand.SetHandler((string? workspace, string? repo, int id) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewIssueRequest(workspace, repo, id), CancellationToken.None)), + .HandleAsync(new ViewIssueRequest(workspace, repo, id), CommandBinding.CancellationToken)), workspaceOption, repoOption, idArg); command.Subcommands.Add(viewCommand); var createCommand = new Command("create", "Create a new issue"); - var titleOption = new Option("--title") { Description = "Issue title" , Required = true }; + var titleOption = new Option("--title") { Description = "Issue title", Required = true }; var contentOption = new Option("--content") { Description = "Issue description" }; var kindOption = new Option("--kind") { Description = "Issue kind (bug, enhancement, proposal, task)" }; var priorityCreateOption = new Option("--priority") { Description = "Priority (trivial, minor, major, critical, blocker)" }; @@ -58,7 +58,7 @@ public static Command Create(IServiceProvider services) createCommand.SetHandler((string? workspace, string? repo, string title, string? content, string? kind, string? priority) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new CreateIssueRequest(workspace, repo, title, content, kind, priority), CancellationToken.None)), + .HandleAsync(new CreateIssueRequest(workspace, repo, title, content, kind, priority), CommandBinding.CancellationToken)), workspaceOption, repoOption, titleOption, contentOption, kindOption, priorityCreateOption); command.Subcommands.Add(createCommand); @@ -76,7 +76,7 @@ public static Command Create(IServiceProvider services) updateCommand.SetHandler((string? workspace, string? repo, int id, string? title, string? state, string? priority, string? assignee) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new UpdateIssueRequest(workspace, repo, id, title, state, priority, assignee), CancellationToken.None)), + .HandleAsync(new UpdateIssueRequest(workspace, repo, id, title, state, priority, assignee), CommandBinding.CancellationToken)), workspaceOption, repoOption, updateIdArg, updateTitleOption, updateStateOption, updatePriorityOption, updateAssigneeOption); command.Subcommands.Add(updateCommand); @@ -87,18 +87,11 @@ public static Command Create(IServiceProvider services) deleteCommand.Options.Add(yesOption); deleteCommand.SetHandler(async (string? workspace, string? repo, int id, bool yes) => { - if (!yes) - { - Console.Write($"Delete issue #{id}? (y/N): "); - if (Console.ReadLine()?.Trim().ToLower() != "y") - { - Console.WriteLine("Cancelled."); - return; - } - } + if (!yes && !CommandRunner.ConfirmOrCancelStderr($"Delete issue #{id}? [y/N]: ")) + return; await CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new DeleteIssueRequest(workspace, repo, id), CancellationToken.None)); + .HandleAsync(new DeleteIssueRequest(workspace, repo, id), CommandBinding.CancellationToken)); }, workspaceOption, repoOption, deleteIdArg, yesOption); command.Subcommands.Add(deleteCommand); @@ -108,19 +101,19 @@ await CommandRunner.RunActionAsync(() => commentsCommand.SetHandler((string? workspace, string? repo, int id) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListIssueCommentsRequest(workspace, repo, id), CancellationToken.None)), + .HandleAsync(new ListIssueCommentsRequest(workspace, repo, id), CommandBinding.CancellationToken)), workspaceOption, repoOption, commentsIdArg); command.Subcommands.Add(commentsCommand); var commentCommand = new Command("comment", "Add a comment to an issue"); var commentIdArg = new Argument("id") { Description = "Issue ID" }; - var commentBodyOption = new Option("--body") { Description = "Comment text" , Required = true }; + var commentBodyOption = new Option("--body") { Description = "Comment text", Required = true }; commentCommand.Arguments.Add(commentIdArg); commentCommand.Options.Add(commentBodyOption); commentCommand.SetHandler((string? workspace, string? repo, int id, string commentBody) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new AddIssueCommentRequest(workspace, repo, id, commentBody), CancellationToken.None)), + .HandleAsync(new AddIssueCommentRequest(workspace, repo, id, commentBody), CommandBinding.CancellationToken)), workspaceOption, repoOption, commentIdArg, commentBodyOption); command.Subcommands.Add(commentCommand); diff --git a/src/Bbx/Commands/PipelineCommand.cs b/src/Bbx/Commands/PipelineCommand.cs index 46d8a3c..b087b1d 100644 --- a/src/Bbx/Commands/PipelineCommand.cs +++ b/src/Bbx/Commands/PipelineCommand.cs @@ -7,10 +7,10 @@ using Bbx.Features.Pipelines.ListDeploymentEnvironments; using Bbx.Features.Pipelines.ListPipelineCaches; using Bbx.Features.Pipelines.ListPipelineReports; +using Bbx.Features.Pipelines.ListPipelines; using Bbx.Features.Pipelines.ListPipelineSchedules; using Bbx.Features.Pipelines.ListPipelineSteps; using Bbx.Features.Pipelines.ListPipelineVariables; -using Bbx.Features.Pipelines.ListPipelines; using Bbx.Features.Pipelines.ListReportAnnotations; using Bbx.Features.Pipelines.ListTestCases; using Bbx.Features.Pipelines.ListTestReports; @@ -66,7 +66,7 @@ private static Command CreateReportsCommand(IServiceProvider services) listCommand.SetHandler((string? workspace, string? repo, string hash, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListPipelineReportsRequest(workspace, repo, hash, limit), CancellationToken.None)), + .HandleAsync(new ListPipelineReportsRequest(workspace, repo, hash, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, listHashArg, listLimitOption); reportsCommand.Subcommands.Add(listCommand); @@ -78,7 +78,7 @@ private static Command CreateReportsCommand(IServiceProvider services) viewCommand.SetHandler((string? workspace, string? repo, string hash, string reportId) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewPipelineReportRequest(workspace, repo, hash, reportId), CancellationToken.None)), + .HandleAsync(new ViewPipelineReportRequest(workspace, repo, hash, reportId), CommandBinding.CancellationToken)), workspaceOption, repoOption, viewHashArg, viewReportIdArg); reportsCommand.Subcommands.Add(viewCommand); @@ -92,7 +92,7 @@ private static Command CreateReportsCommand(IServiceProvider services) annotationsCommand.SetHandler((string? workspace, string? repo, string hash, string reportId, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListReportAnnotationsRequest(workspace, repo, hash, reportId, limit), CancellationToken.None)), + .HandleAsync(new ListReportAnnotationsRequest(workspace, repo, hash, reportId, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, annHashArg, annReportIdArg, annLimitOption); reportsCommand.Subcommands.Add(annotationsCommand); @@ -114,7 +114,7 @@ private static Command CreateTestReportsCommand(IServiceProvider services) command.SetHandler((string? workspace, string? repo, string pipelineUuid, string stepUuid) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListTestReportsRequest(workspace, repo, pipelineUuid, stepUuid), CancellationToken.None)), + .HandleAsync(new ListTestReportsRequest(workspace, repo, pipelineUuid, stepUuid), CommandBinding.CancellationToken)), workspaceOption, repoOption, pipelineUuidArg, stepUuidArg); return command; } @@ -136,7 +136,7 @@ private static Command CreateTestCasesCommand(IServiceProvider services) command.SetHandler((string? workspace, string? repo, string pipelineUuid, string stepUuid, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListTestCasesRequest(workspace, repo, pipelineUuid, stepUuid, limit), CancellationToken.None)), + .HandleAsync(new ListTestCasesRequest(workspace, repo, pipelineUuid, stepUuid, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, pipelineUuidArg, stepUuidArg, limitOption); return command; } @@ -153,7 +153,7 @@ private static Command CreateOidcCommand(IServiceProvider services) configCommand.SetHandler((string? workspace, string? repo) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new OidcConfigRequest(workspace, repo), CancellationToken.None)), + .HandleAsync(new OidcConfigRequest(workspace, repo), CommandBinding.CancellationToken)), workspaceOption, repoOption); oidcCommand.Subcommands.Add(configCommand); @@ -161,7 +161,7 @@ private static Command CreateOidcCommand(IServiceProvider services) keysCommand.SetHandler((string? workspace, string? repo) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new OidcKeysRequest(workspace, repo), CancellationToken.None)), + .HandleAsync(new OidcKeysRequest(workspace, repo), CommandBinding.CancellationToken)), workspaceOption, repoOption); oidcCommand.Subcommands.Add(keysCommand); @@ -188,7 +188,7 @@ private static Command CreateListCommand(IServiceProvider services) command.SetHandler((string? workspace, string? repo, string? status, string sort, int limit, string? branch) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListPipelinesRequest(workspace, repo, status, sort, limit, branch), CancellationToken.None)), + .HandleAsync(new ListPipelinesRequest(workspace, repo, status, sort, limit, branch), CommandBinding.CancellationToken)), workspaceOption, repoOption, statusOption, sortOption, limitOption, targetBranchOption); return command; } @@ -207,7 +207,7 @@ private static Command CreateViewCommand(IServiceProvider services) command.SetHandler((string? workspace, string? repo, string pipelineUuid) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewPipelineRequest(workspace, repo, pipelineUuid), CancellationToken.None)), + .HandleAsync(new ViewPipelineRequest(workspace, repo, pipelineUuid), CommandBinding.CancellationToken)), workspaceOption, repoOption, pipelineArg); return command; } @@ -221,7 +221,7 @@ private static Command CreateTriggerCommand(IServiceProvider services) var commitOption = new Option("--commit") { Description = "Specific commit hash to run on (branch trigger only)" }; var patternOption = new Option("--pattern") { Description = "Custom pipeline pattern (selector) to run" }; var pullRequestOption = new Option("--pull-request") { Description = "Trigger the pull-request pipeline for this PR id (uses --branch as the PR's source branch)" }; - var variablesOption = new Option("--variable") { Description = "Pipeline variables in key=value format" , AllowMultipleArgumentsPerToken = true }; + var variablesOption = new Option("--variable") { Description = "Pipeline variables in key=value format", AllowMultipleArgumentsPerToken = true }; command.Options.Add(workspaceOption); command.Options.Add(repoOption); @@ -234,7 +234,7 @@ private static Command CreateTriggerCommand(IServiceProvider services) command.SetHandler((string? workspace, string? repo, string branch, string? commit, string? pattern, string? pullRequest, string[] variables) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new TriggerPipelineRequest(workspace, repo, branch, commit, pattern, pullRequest, variables), CancellationToken.None)), + .HandleAsync(new TriggerPipelineRequest(workspace, repo, branch, commit, pattern, pullRequest, variables), CommandBinding.CancellationToken)), workspaceOption, repoOption, branchOption, commitOption, patternOption, pullRequestOption, variablesOption); return command; } @@ -258,7 +258,7 @@ private static Command CreateStopCommand(IServiceProvider services) return; await CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new StopPipelineRequest(workspace, repo, pipelineUuid), CancellationToken.None)); + .HandleAsync(new StopPipelineRequest(workspace, repo, pipelineUuid), CommandBinding.CancellationToken)); }, workspaceOption, repoOption, pipelineArg, yesOption); return command; } @@ -281,7 +281,7 @@ private static Command CreateLogsCommand(IServiceProvider services) command.SetHandler((string? workspace, string? repo, string pipelineUuid, string stepUuid, bool follow) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new PipelineLogsRequest(workspace, repo, pipelineUuid, stepUuid), CancellationToken.None)), + .HandleAsync(new PipelineLogsRequest(workspace, repo, pipelineUuid, stepUuid), CommandBinding.CancellationToken)), workspaceOption, repoOption, pipelineArg, stepArg, followOption); return command; } @@ -300,7 +300,7 @@ private static Command CreateStepsCommand(IServiceProvider services) command.SetHandler((string? workspace, string? repo, string pipelineUuid) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListPipelineStepsRequest(workspace, repo, pipelineUuid), CancellationToken.None)), + .HandleAsync(new ListPipelineStepsRequest(workspace, repo, pipelineUuid), CommandBinding.CancellationToken)), workspaceOption, repoOption, pipelineArg); return command; } @@ -324,7 +324,7 @@ private static Command CreateVariablesListCommand(IServiceProvider services) command.SetHandler((string? workspace, string? repo) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListPipelineVariablesRequest(workspace, repo), CancellationToken.None)), + .HandleAsync(new ListPipelineVariablesRequest(workspace, repo), CommandBinding.CancellationToken)), workspaceOption, repoOption); return command; } @@ -334,8 +334,8 @@ private static Command CreateVariablesAddCommand(IServiceProvider services) var command = new Command("add", "Add a pipeline variable"); var workspaceOption = new Option("--workspace", "-w") { Description = "Workspace slug" }; var repoOption = new Option("--repo", "-r") { Description = "Repository slug" }; - var keyOption = new Option("--key") { Description = "Variable key" , Required = true }; - var valueOption = new Option("--value") { Description = "Variable value" , Required = true }; + var keyOption = new Option("--key") { Description = "Variable key", Required = true }; + var valueOption = new Option("--value") { Description = "Variable value", Required = true }; var securedOption = new Option("--secured") { Description = "Mark as secured (value hidden)" }; command.Options.Add(workspaceOption); @@ -347,7 +347,7 @@ private static Command CreateVariablesAddCommand(IServiceProvider services) command.SetHandler((string? workspace, string? repo, string key, string value, bool secured) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new AddPipelineVariableRequest(workspace, repo, key, value, secured), CancellationToken.None)), + .HandleAsync(new AddPipelineVariableRequest(workspace, repo, key, value, secured), CommandBinding.CancellationToken)), workspaceOption, repoOption, keyOption, valueOption, securedOption); return command; } @@ -371,7 +371,7 @@ private static Command CreateVariablesDeleteCommand(IServiceProvider services) return; await CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new DeletePipelineVariableRequest(workspace, repo, uuid), CancellationToken.None)); + .HandleAsync(new DeletePipelineVariableRequest(workspace, repo, uuid), CommandBinding.CancellationToken)); }, workspaceOption, repoOption, uuidArg, yesOption); return command; } @@ -395,7 +395,7 @@ private static Command CreateSchedulesListCommand(IServiceProvider services) command.SetHandler((string? workspace, string? repo) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListPipelineSchedulesRequest(workspace, repo), CancellationToken.None)), + .HandleAsync(new ListPipelineSchedulesRequest(workspace, repo), CommandBinding.CancellationToken)), workspaceOption, repoOption); return command; } @@ -405,7 +405,7 @@ private static Command CreateSchedulesCreateCommand(IServiceProvider services) var command = new Command("create", "Create a pipeline schedule"); var workspaceOption = new Option("--workspace", "-w") { Description = "Workspace slug" }; var repoOption = new Option("--repo", "-r") { Description = "Repository slug" }; - var cronOption = new Option("--cron") { Description = "Cron expression (e.g., '0 0 * * *')" , Required = true }; + var cronOption = new Option("--cron") { Description = "Cron expression (e.g., '0 0 * * *')", Required = true }; var branchOption = new Option("--branch") { Description = "Target branch", DefaultValueFactory = _ => "main" }; var patternOption = new Option("--pattern") { Description = "Custom pipeline pattern" }; var enabledOption = new Option("--enabled") { Description = "Enable the schedule", DefaultValueFactory = _ => true }; @@ -420,7 +420,7 @@ private static Command CreateSchedulesCreateCommand(IServiceProvider services) command.SetHandler((string? workspace, string? repo, string cron, string branch, string? pattern, bool enabled) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new CreatePipelineScheduleRequest(workspace, repo, cron, branch, pattern, enabled), CancellationToken.None)), + .HandleAsync(new CreatePipelineScheduleRequest(workspace, repo, cron, branch, pattern, enabled), CommandBinding.CancellationToken)), workspaceOption, repoOption, cronOption, branchOption, patternOption, enabledOption); return command; } @@ -444,7 +444,7 @@ private static Command CreateSchedulesDeleteCommand(IServiceProvider services) return; await CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new DeletePipelineScheduleRequest(workspace, repo, uuid), CancellationToken.None)); + .HandleAsync(new DeletePipelineScheduleRequest(workspace, repo, uuid), CommandBinding.CancellationToken)); }, workspaceOption, repoOption, uuidArg, yesOption); return command; } @@ -467,7 +467,7 @@ private static Command CreateCachesListCommand(IServiceProvider services) command.SetHandler((string? workspace, string? repo) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListPipelineCachesRequest(workspace, repo), CancellationToken.None)), + .HandleAsync(new ListPipelineCachesRequest(workspace, repo), CommandBinding.CancellationToken)), workspaceOption, repoOption); return command; } @@ -491,7 +491,7 @@ private static Command CreateCachesClearCommand(IServiceProvider services) return; await CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ClearPipelineCacheRequest(workspace, repo, name), CancellationToken.None)); + .HandleAsync(new ClearPipelineCacheRequest(workspace, repo, name), CommandBinding.CancellationToken)); }, workspaceOption, repoOption, nameArg, yesOption); return command; } @@ -514,7 +514,7 @@ private static Command CreateDeploymentsListCommand(IServiceProvider services) command.SetHandler((string? workspace, string? repo) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListDeploymentEnvironmentsRequest(workspace, repo), CancellationToken.None)), + .HandleAsync(new ListDeploymentEnvironmentsRequest(workspace, repo), CommandBinding.CancellationToken)), workspaceOption, repoOption); return command; } @@ -531,7 +531,7 @@ private static Command CreateDeploymentsViewCommand(IServiceProvider services) command.SetHandler((string? workspace, string? repo, string environment) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewDeploymentEnvironmentRequest(workspace, repo, environment), CancellationToken.None)), + .HandleAsync(new ViewDeploymentEnvironmentRequest(workspace, repo, environment), CommandBinding.CancellationToken)), workspaceOption, repoOption, envArg); return command; } diff --git a/src/Bbx/Commands/PrCommand.cs b/src/Bbx/Commands/PrCommand.cs index 438fb62..fbfbe11 100644 --- a/src/Bbx/Commands/PrCommand.cs +++ b/src/Bbx/Commands/PrCommand.cs @@ -3,8 +3,8 @@ using Bbx.Features.PullRequests.ApprovePullRequest; using Bbx.Features.PullRequests.CreatePullRequest; using Bbx.Features.PullRequests.DeclinePullRequest; -using Bbx.Features.PullRequests.ListPullRequestCommits; using Bbx.Features.PullRequests.ListPullRequestComments; +using Bbx.Features.PullRequests.ListPullRequestCommits; using Bbx.Features.PullRequests.ListPullRequests; using Bbx.Features.PullRequests.MergePullRequest; using Bbx.Features.PullRequests.PullRequestActivity; @@ -44,7 +44,7 @@ public static Command Create(IServiceProvider services) listCommand.SetHandler((string? workspace, string? repo, string? state, string? author, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListPullRequestsRequest(workspace, repo, state, author, limit), CancellationToken.None)), + .HandleAsync(new ListPullRequestsRequest(workspace, repo, state, author, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, stateOption, authorOption, limitOption); command.Subcommands.Add(listCommand); @@ -54,14 +54,14 @@ public static Command Create(IServiceProvider services) viewCommand.SetHandler((string? workspace, string? repo, int id) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewPullRequestRequest(workspace, repo, id), CancellationToken.None)), + .HandleAsync(new ViewPullRequestRequest(workspace, repo, id), CommandBinding.CancellationToken)), workspaceOption, repoOption, idArg); command.Subcommands.Add(viewCommand); var createCommand = new Command("create", "Create a new pull request"); - var titleOption = new Option("--title") { Description = "Pull request title" , Required = true }; - var sourceOption = new Option("--source") { Description = "Source branch" , Required = true }; - var destOption = new Option("--dest") { Description = "Destination branch" , Required = true }; + var titleOption = new Option("--title") { Description = "Pull request title", Required = true }; + var sourceOption = new Option("--source") { Description = "Source branch", Required = true }; + var destOption = new Option("--dest") { Description = "Destination branch", Required = true }; var bodyOption = new Option("--body") { Description = "Pull request description" }; var reviewersOption = new Option("--reviewers") { Description = "Reviewer account IDs (UUID format)" }; var closeSourceOption = new Option("--close-source-branch") { Description = "Close source branch after merge" }; @@ -74,7 +74,7 @@ public static Command Create(IServiceProvider services) createCommand.SetHandler((string? workspace, string? repo, string title, string source, string dest, string? body, string[]? reviewers, bool closeSource) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new CreatePullRequestRequest(workspace, repo, title, source, dest, body, reviewers, closeSource), CancellationToken.None)), + .HandleAsync(new CreatePullRequestRequest(workspace, repo, title, source, dest, body, reviewers, closeSource), CommandBinding.CancellationToken)), workspaceOption, repoOption, titleOption, sourceOption, destOption, bodyOption, reviewersOption, closeSourceOption); command.Subcommands.Add(createCommand); @@ -101,7 +101,7 @@ public static Command Create(IServiceProvider services) return; await CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new MergePullRequestRequest(workspace, repo, id, strategy, message, closeSource), CancellationToken.None)); + .HandleAsync(new MergePullRequestRequest(workspace, repo, id, strategy, message, closeSource), CommandBinding.CancellationToken)); }, workspaceOption, repoOption, mergeIdArg, strategyOption, messageOption, closeSourceMergeOption, mergeYesOption); command.Subcommands.Add(mergeCommand); @@ -111,7 +111,7 @@ await CommandRunner.RunJsonAsync(() => approveCommand.SetHandler((string? workspace, string? repo, int id) => CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new ApprovePullRequestRequest(workspace, repo, id), CancellationToken.None)), + .HandleAsync(new ApprovePullRequestRequest(workspace, repo, id), CommandBinding.CancellationToken)), workspaceOption, repoOption, approveIdArg); command.Subcommands.Add(approveCommand); @@ -121,7 +121,7 @@ await CommandRunner.RunJsonAsync(() => unapproveCommand.SetHandler((string? workspace, string? repo, int id) => CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new UnapprovePullRequestRequest(workspace, repo, id), CancellationToken.None)), + .HandleAsync(new UnapprovePullRequestRequest(workspace, repo, id), CommandBinding.CancellationToken)), workspaceOption, repoOption, unapproveIdArg); command.Subcommands.Add(unapproveCommand); @@ -133,7 +133,7 @@ await CommandRunner.RunJsonAsync(() => declineCommand.SetHandler((string? workspace, string? repo, int id, string? reason) => CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new DeclinePullRequestRequest(workspace, repo, id, reason), CancellationToken.None)), + .HandleAsync(new DeclinePullRequestRequest(workspace, repo, id, reason), CommandBinding.CancellationToken)), workspaceOption, repoOption, declineIdArg, declineReasonOption); command.Subcommands.Add(declineCommand); @@ -143,19 +143,19 @@ await CommandRunner.RunJsonAsync(() => commentsCommand.SetHandler((string? workspace, string? repo, int id) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListPullRequestCommentsRequest(workspace, repo, id), CancellationToken.None)), + .HandleAsync(new ListPullRequestCommentsRequest(workspace, repo, id), CommandBinding.CancellationToken)), workspaceOption, repoOption, commentsIdArg); command.Subcommands.Add(commentsCommand); var commentCommand = new Command("comment", "Add a comment to a pull request"); var commentIdArg = new Argument("id") { Description = "Pull request ID" }; - var commentBodyOption = new Option("--body") { Description = "Comment text" , Required = true }; + var commentBodyOption = new Option("--body") { Description = "Comment text", Required = true }; commentCommand.Arguments.Add(commentIdArg); commentCommand.Options.Add(commentBodyOption); commentCommand.SetHandler((string? workspace, string? repo, int id, string commentBody) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new AddPullRequestCommentRequest(workspace, repo, id, commentBody), CancellationToken.None)), + .HandleAsync(new AddPullRequestCommentRequest(workspace, repo, id, commentBody), CommandBinding.CancellationToken)), workspaceOption, repoOption, commentIdArg, commentBodyOption); command.Subcommands.Add(commentCommand); @@ -165,7 +165,7 @@ await CommandRunner.RunJsonAsync(() => diffCommand.SetHandler((string? workspace, string? repo, int id) => CommandRunner.RunRawAsync(() => services.GetRequiredService() - .HandleAsync(new PullRequestDiffRequest(workspace, repo, id), CancellationToken.None)), + .HandleAsync(new PullRequestDiffRequest(workspace, repo, id), CommandBinding.CancellationToken)), workspaceOption, repoOption, diffIdArg); command.Subcommands.Add(diffCommand); @@ -175,7 +175,7 @@ await CommandRunner.RunJsonAsync(() => activityCommand.SetHandler((string? workspace, string? repo, int id) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new PullRequestActivityRequest(workspace, repo, id), CancellationToken.None)), + .HandleAsync(new PullRequestActivityRequest(workspace, repo, id), CommandBinding.CancellationToken)), workspaceOption, repoOption, activityIdArg); command.Subcommands.Add(activityCommand); @@ -185,7 +185,7 @@ await CommandRunner.RunJsonAsync(() => statusesCommand.SetHandler((string? workspace, string? repo, int id) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new PullRequestStatusesRequest(workspace, repo, id), CancellationToken.None)), + .HandleAsync(new PullRequestStatusesRequest(workspace, repo, id), CommandBinding.CancellationToken)), workspaceOption, repoOption, statusesIdArg); command.Subcommands.Add(statusesCommand); @@ -200,7 +200,7 @@ await CommandRunner.RunJsonAsync(() => defaultReviewersCommand.SetHandler((string? workspace, string? repo, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new EffectiveDefaultReviewersRequest(workspace, repo, limit), CancellationToken.None)), + .HandleAsync(new EffectiveDefaultReviewersRequest(workspace, repo, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, drLimitOption); command.Subcommands.Add(defaultReviewersCommand); @@ -212,7 +212,7 @@ await CommandRunner.RunJsonAsync(() => requestChangesCommand.SetHandler((string? workspace, string? repo, int id) => CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new RequestChangesRequest(workspace, repo, id), CancellationToken.None)), + .HandleAsync(new RequestChangesRequest(workspace, repo, id), CommandBinding.CancellationToken)), workspaceOption, repoOption, rcIdArg); command.Subcommands.Add(requestChangesCommand); @@ -222,7 +222,7 @@ await CommandRunner.RunJsonAsync(() => unrequestChangesCommand.SetHandler((string? workspace, string? repo, int id) => CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new UnrequestChangesRequest(workspace, repo, id), CancellationToken.None)), + .HandleAsync(new UnrequestChangesRequest(workspace, repo, id), CommandBinding.CancellationToken)), workspaceOption, repoOption, urcIdArg); command.Subcommands.Add(unrequestChangesCommand); @@ -234,7 +234,7 @@ await CommandRunner.RunJsonAsync(() => commitsCommand.SetHandler((string? workspace, string? repo, int id, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListPullRequestCommitsRequest(workspace, repo, id, limit), CancellationToken.None)), + .HandleAsync(new ListPullRequestCommitsRequest(workspace, repo, id, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, commitsIdArg, commitsLimitOption); command.Subcommands.Add(commitsCommand); @@ -244,7 +244,7 @@ await CommandRunner.RunJsonAsync(() => patchCommand.SetHandler((string? workspace, string? repo, int id) => CommandRunner.RunRawAsync(() => services.GetRequiredService() - .HandleAsync(new PullRequestPatchRequest(workspace, repo, id), CancellationToken.None)), + .HandleAsync(new PullRequestPatchRequest(workspace, repo, id), CommandBinding.CancellationToken)), workspaceOption, repoOption, patchIdArg); command.Subcommands.Add(patchCommand); @@ -263,25 +263,25 @@ private static Command CreateTasksCommand(IServiceProvider services, Option CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListPullRequestTasksRequest(workspace, repo, id, limit), CancellationToken.None)), + .HandleAsync(new ListPullRequestTasksRequest(workspace, repo, id, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, listIdArg, listLimitOption); tasksCommand.Subcommands.Add(listCommand); var addCommand = new Command("add", "Add a task to a PR"); var addIdArg = new Argument("id") { Description = "Pull request ID" }; - var addContentOption = new Option("--content") { Description = "Task body (markdown)" , Required = true }; + var addContentOption = new Option("--content") { Description = "Task body (markdown)", Required = true }; addCommand.Arguments.Add(addIdArg); addCommand.Options.Add(addContentOption); addCommand.SetHandler((string? workspace, string? repo, int id, string content) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new AddPullRequestTaskRequest(workspace, repo, id, content), CancellationToken.None)), + .HandleAsync(new AddPullRequestTaskRequest(workspace, repo, id, content), CommandBinding.CancellationToken)), workspaceOption, repoOption, addIdArg, addContentOption); tasksCommand.Subcommands.Add(addCommand); var updateCommand = new Command("update", "Update a PR task (content and/or state)"); var updateIdArg = new Argument("id") { Description = "Pull request ID" }; - var updateTaskIdOption = new Option("--task-id") { Description = "Task ID" , Required = true }; + var updateTaskIdOption = new Option("--task-id") { Description = "Task ID", Required = true }; var updateContentOption = new Option("--content") { Description = "New task body" }; var updateStateOption = new Option("--state") { Description = "Task state (RESOLVED or UNRESOLVED)" }; updateCommand.Arguments.Add(updateIdArg); @@ -291,25 +291,25 @@ private static Command CreateTasksCommand(IServiceProvider services, Option CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new UpdatePullRequestTaskRequest(workspace, repo, id, taskId, content, state), CancellationToken.None)), + .HandleAsync(new UpdatePullRequestTaskRequest(workspace, repo, id, taskId, content, state), CommandBinding.CancellationToken)), workspaceOption, repoOption, updateIdArg, updateTaskIdOption, updateContentOption, updateStateOption); tasksCommand.Subcommands.Add(updateCommand); var completeCommand = new Command("complete", "Mark a PR task as RESOLVED"); var completeIdArg = new Argument("id") { Description = "Pull request ID" }; - var completeTaskIdOption = new Option("--task-id") { Description = "Task ID" , Required = true }; + var completeTaskIdOption = new Option("--task-id") { Description = "Task ID", Required = true }; completeCommand.Arguments.Add(completeIdArg); completeCommand.Options.Add(completeTaskIdOption); completeCommand.SetHandler((string? workspace, string? repo, int id, int taskId) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new UpdatePullRequestTaskRequest(workspace, repo, id, taskId, null, "RESOLVED"), CancellationToken.None)), + .HandleAsync(new UpdatePullRequestTaskRequest(workspace, repo, id, taskId, null, "RESOLVED"), CommandBinding.CancellationToken)), workspaceOption, repoOption, completeIdArg, completeTaskIdOption); tasksCommand.Subcommands.Add(completeCommand); var deleteCommand = new Command("delete", "Delete a PR task"); var deleteIdArg = new Argument("id") { Description = "Pull request ID" }; - var deleteTaskIdOption = new Option("--task-id") { Description = "Task ID" , Required = true }; + var deleteTaskIdOption = new Option("--task-id") { Description = "Task ID", Required = true }; var yesOption = new Option("--yes") { Description = "Skip confirmation" }; deleteCommand.Arguments.Add(deleteIdArg); deleteCommand.Options.Add(deleteTaskIdOption); @@ -320,7 +320,7 @@ private static Command CreateTasksCommand(IServiceProvider services, Option services.GetRequiredService() - .HandleAsync(new DeletePullRequestTaskRequest(workspace, repo, id, taskId), CancellationToken.None)); + .HandleAsync(new DeletePullRequestTaskRequest(workspace, repo, id, taskId), CommandBinding.CancellationToken)); }, workspaceOption, repoOption, deleteIdArg, deleteTaskIdOption, yesOption); tasksCommand.Subcommands.Add(deleteCommand); diff --git a/src/Bbx/Commands/RepoCommand.cs b/src/Bbx/Commands/RepoCommand.cs index b9715ff..65d75c1 100644 --- a/src/Bbx/Commands/RepoCommand.cs +++ b/src/Bbx/Commands/RepoCommand.cs @@ -44,7 +44,7 @@ public static Command Create(IServiceProvider services) listCommand.SetHandler((string? workspace, int limit, string? query) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListReposRequest(workspace, limit, query), CancellationToken.None)), + .HandleAsync(new ListReposRequest(workspace, limit, query), CommandBinding.CancellationToken)), workspaceOption, limitOption, queryOption); command.Subcommands.Add(listCommand); @@ -54,7 +54,7 @@ public static Command Create(IServiceProvider services) viewCommand.SetHandler((string? workspace, string repository) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewRepoRequest(workspace, repository), CancellationToken.None)), + .HandleAsync(new ViewRepoRequest(workspace, repository), CommandBinding.CancellationToken)), workspaceOption, repoArg); command.Subcommands.Add(viewCommand); @@ -72,7 +72,7 @@ public static Command Create(IServiceProvider services) createCommand.SetHandler((string? workspace, string name, bool isPrivate, string? project, string? description, string? forkPolicy) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new CreateRepoRequest(workspace, name, isPrivate, project, description, forkPolicy), CancellationToken.None)), + .HandleAsync(new CreateRepoRequest(workspace, name, isPrivate, project, description, forkPolicy), CommandBinding.CancellationToken)), workspaceOption, nameArg, privateOption, projectOption, descOption, forkPolicyOption); command.Subcommands.Add(createCommand); @@ -84,18 +84,12 @@ public static Command Create(IServiceProvider services) deleteCommand.SetHandler(async (string? workspace, string repository, bool yes) => { var (resolvedWs, resolvedRepo) = ParseRepoPath(workspace, repository); - if (!yes) - { - Console.Write($"Delete {resolvedWs}/{resolvedRepo}? This cannot be undone. (y/N): "); - if (Console.ReadLine()?.Trim().ToLower() != "y") - { - Console.WriteLine("Cancelled."); - return; - } - } + if (!yes && !CommandRunner.ConfirmOrCancelStderr( + $"Delete {resolvedWs}/{resolvedRepo}? This cannot be undone. [y/N]: ")) + return; await CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new DeleteRepoRequest(workspace, repository), CancellationToken.None)); + .HandleAsync(new DeleteRepoRequest(workspace, repository), CommandBinding.CancellationToken)); }, workspaceOption, deleteRepoArg, yesOption); command.Subcommands.Add(deleteCommand); @@ -109,7 +103,7 @@ await CommandRunner.RunActionAsync(() => forkCommand.SetHandler((string? workspace, string repository, string? name, string? toWorkspace) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ForkRepoRequest(workspace, repository, name, toWorkspace), CancellationToken.None)), + .HandleAsync(new ForkRepoRequest(workspace, repository, name, toWorkspace), CommandBinding.CancellationToken)), workspaceOption, forkRepoArg, forkNameOption, forkWorkspaceOption); command.Subcommands.Add(forkCommand); @@ -121,7 +115,7 @@ await CommandRunner.RunActionAsync(() => cloneCommand.SetHandler((string? workspace, string repository, bool ssh) => CommandRunner.RunRawAsync(() => services.GetRequiredService() - .HandleAsync(new CloneRepoRequest(workspace, repository, ssh), CancellationToken.None)), + .HandleAsync(new CloneRepoRequest(workspace, repository, ssh), CommandBinding.CancellationToken)), workspaceOption, cloneRepoArg, sshOption); command.Subcommands.Add(cloneCommand); @@ -131,7 +125,7 @@ await CommandRunner.RunActionAsync(() => permissionsCommand.SetHandler((string? workspace, string repository) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new RepoPermissionsRequest(workspace, repository), CancellationToken.None)), + .HandleAsync(new RepoPermissionsRequest(workspace, repository), CommandBinding.CancellationToken)), workspaceOption, permRepoArg); command.Subcommands.Add(permissionsCommand); @@ -159,7 +153,7 @@ private static Command CreateForksCommand(IServiceProvider services, Option CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListForksRequest(workspace, repo, limit), CancellationToken.None)), + .HandleAsync(new ListForksRequest(workspace, repo, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, limitOption); forksCommand.Subcommands.Add(listCommand); @@ -176,7 +170,7 @@ private static Command CreateWatchersCommand(IServiceProvider services, Option CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListWatchersRequest(workspace, repo, limit), CancellationToken.None)), + .HandleAsync(new ListWatchersRequest(workspace, repo, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, limitOption); return watchersCommand; @@ -192,7 +186,7 @@ private static Command CreateBranchingModelCommand(IServiceProvider services, Op viewCommand.SetHandler((string? workspace, string? repo) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewBranchingModelRequest(workspace, repo), CancellationToken.None)), + .HandleAsync(new ViewBranchingModelRequest(workspace, repo), CommandBinding.CancellationToken)), workspaceOption, repoOption); bmCommand.Subcommands.Add(viewCommand); @@ -200,19 +194,19 @@ private static Command CreateBranchingModelCommand(IServiceProvider services, Op settingsCommand.SetHandler((string? workspace, string? repo) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewBranchingModelSettingsRequest(workspace, repo), CancellationToken.None)), + .HandleAsync(new ViewBranchingModelSettingsRequest(workspace, repo), CommandBinding.CancellationToken)), workspaceOption, repoOption); bmCommand.Subcommands.Add(settingsCommand); var updateCommand = new Command("update", "Replace branching-model settings (PUT raw JSON payload)"); var settingsJsonOption = new Option("--settings") - { Description = "JSON payload (e.g., '{\"development\":{\"name\":\"main\",\"use_mainbranch\":true}}')" , Required = true }; + { Description = "JSON payload (e.g., '{\"development\":{\"name\":\"main\",\"use_mainbranch\":true}}')", Required = true }; updateCommand.Options.Add(settingsJsonOption); updateCommand.SetHandler((string? workspace, string? repo, string settingsJson) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new UpdateBranchingModelSettingsRequest(workspace, repo, settingsJson), CancellationToken.None)), + .HandleAsync(new UpdateBranchingModelSettingsRequest(workspace, repo, settingsJson), CommandBinding.CancellationToken)), workspaceOption, repoOption, settingsJsonOption); bmCommand.Subcommands.Add(updateCommand); @@ -231,7 +225,7 @@ private static Command CreateDeployKeysCommand(IServiceProvider services, Option listCommand.SetHandler((string? workspace, string? repo, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListRepoDeployKeysRequest(workspace, repo, limit), CancellationToken.None)), + .HandleAsync(new ListRepoDeployKeysRequest(workspace, repo, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, limitOption); dkCommand.Subcommands.Add(listCommand); @@ -241,19 +235,19 @@ private static Command CreateDeployKeysCommand(IServiceProvider services, Option viewCommand.SetHandler((string? workspace, string? repo, int keyId) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewRepoDeployKeyRequest(workspace, repo, keyId), CancellationToken.None)), + .HandleAsync(new ViewRepoDeployKeyRequest(workspace, repo, keyId), CommandBinding.CancellationToken)), workspaceOption, repoOption, viewIdArg); dkCommand.Subcommands.Add(viewCommand); var addCommand = new Command("add", "Add a deploy key"); - var addKeyOption = new Option("--key") { Description = "Public SSH key body" , Required = true }; + var addKeyOption = new Option("--key") { Description = "Public SSH key body", Required = true }; var addLabelOption = new Option("--label") { Description = "Friendly label" }; addCommand.Options.Add(addKeyOption); addCommand.Options.Add(addLabelOption); addCommand.SetHandler((string? workspace, string? repo, string key, string? label) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new AddRepoDeployKeyRequest(workspace, repo, key, label), CancellationToken.None)), + .HandleAsync(new AddRepoDeployKeyRequest(workspace, repo, key, label), CommandBinding.CancellationToken)), workspaceOption, repoOption, addKeyOption, addLabelOption); dkCommand.Subcommands.Add(addCommand); @@ -268,7 +262,7 @@ private static Command CreateDeployKeysCommand(IServiceProvider services, Option return; await CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new DeleteRepoDeployKeyRequest(workspace, repo, keyId), CancellationToken.None)); + .HandleAsync(new DeleteRepoDeployKeyRequest(workspace, repo, keyId), CommandBinding.CancellationToken)); }, workspaceOption, repoOption, deleteIdArg, yesOption); dkCommand.Subcommands.Add(deleteCommand); @@ -287,22 +281,22 @@ private static Command CreateDefaultReviewersCommand(IServiceProvider services, listCommand.SetHandler((string? workspace, string? repo, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListDefaultReviewersRequest(workspace, repo, limit), CancellationToken.None)), + .HandleAsync(new ListDefaultReviewersRequest(workspace, repo, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, listLimitOption); drCommand.Subcommands.Add(listCommand); var addCommand = new Command("add", "Add a default reviewer"); - var addTargetOption = new Option("--target") { Description = "Account ID or UUID of the user" , Required = true }; + var addTargetOption = new Option("--target") { Description = "Account ID or UUID of the user", Required = true }; addCommand.Options.Add(addTargetOption); addCommand.SetHandler((string? workspace, string? repo, string target) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new AddDefaultReviewerRequest(workspace, repo, target), CancellationToken.None)), + .HandleAsync(new AddDefaultReviewerRequest(workspace, repo, target), CommandBinding.CancellationToken)), workspaceOption, repoOption, addTargetOption); drCommand.Subcommands.Add(addCommand); var removeCommand = new Command("remove", "Remove a default reviewer"); - var removeTargetOption = new Option("--target") { Description = "Account ID or UUID of the user" , Required = true }; + var removeTargetOption = new Option("--target") { Description = "Account ID or UUID of the user", Required = true }; var yesOption = new Option("--yes") { Description = "Skip confirmation" }; removeCommand.Options.Add(removeTargetOption); removeCommand.Options.Add(yesOption); @@ -312,7 +306,7 @@ private static Command CreateDefaultReviewersCommand(IServiceProvider services, return; await CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new RemoveDefaultReviewerRequest(workspace, repo, target), CancellationToken.None)); + .HandleAsync(new RemoveDefaultReviewerRequest(workspace, repo, target), CommandBinding.CancellationToken)); }, workspaceOption, repoOption, removeTargetOption, yesOption); drCommand.Subcommands.Add(removeCommand); @@ -323,7 +317,7 @@ await CommandRunner.RunActionAsync(() => effectiveCommand.SetHandler((string? workspace, string? repo, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new EffectiveDefaultReviewersRequest(workspace, repo, limit), CancellationToken.None)), + .HandleAsync(new EffectiveDefaultReviewersRequest(workspace, repo, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, effectiveLimitOption); drCommand.Subcommands.Add(effectiveCommand); @@ -342,7 +336,7 @@ private static Command CreateHooksCommand(IServiceProvider services, Option CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListRepoHooksRequest(workspace, repo, limit), CancellationToken.None)), + .HandleAsync(new ListRepoHooksRequest(workspace, repo, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, limitOption); hooksCommand.Subcommands.Add(listCommand); @@ -352,12 +346,12 @@ private static Command CreateHooksCommand(IServiceProvider services, Option CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewRepoHookRequest(workspace, repo, uid), CancellationToken.None)), + .HandleAsync(new ViewRepoHookRequest(workspace, repo, uid), CommandBinding.CancellationToken)), workspaceOption, repoOption, viewUidArg); hooksCommand.Subcommands.Add(viewCommand); var createCommand = new Command("create", "Create a repository webhook"); - var urlOption = new Option("--url") { Description = "Webhook target URL" , Required = true }; + var urlOption = new Option("--url") { Description = "Webhook target URL", Required = true }; var descriptionOption = new Option("--description") { Description = "Webhook description" }; var eventsOption = new Option("--events") { Description = "Events to trigger webhook (default: repo:push)" }; var activeOption = new Option("--active") { Description = "Whether the webhook is active", DefaultValueFactory = _ => true }; @@ -368,7 +362,7 @@ private static Command CreateHooksCommand(IServiceProvider services, Option CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new CreateRepoHookRequest(workspace, repo, url, description, events, active), CancellationToken.None)), + .HandleAsync(new CreateRepoHookRequest(workspace, repo, url, description, events, active), CommandBinding.CancellationToken)), workspaceOption, repoOption, urlOption, descriptionOption, eventsOption, activeOption); hooksCommand.Subcommands.Add(createCommand); @@ -386,7 +380,7 @@ private static Command CreateHooksCommand(IServiceProvider services, Option CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new UpdateRepoHookRequest(workspace, repo, uid, url, description, events, active), CancellationToken.None)), + .HandleAsync(new UpdateRepoHookRequest(workspace, repo, uid, url, description, events, active), CommandBinding.CancellationToken)), workspaceOption, repoOption, updateUidArg, updateUrlOption, updateDescriptionOption, updateEventsOption, updateActiveOption); hooksCommand.Subcommands.Add(updateCommand); @@ -401,7 +395,7 @@ private static Command CreateHooksCommand(IServiceProvider services, Option services.GetRequiredService() - .HandleAsync(new DeleteRepoHookRequest(workspace, repo, uid), CancellationToken.None)); + .HandleAsync(new DeleteRepoHookRequest(workspace, repo, uid), CommandBinding.CancellationToken)); }, workspaceOption, repoOption, deleteUidArg, yesOption); hooksCommand.Subcommands.Add(deleteCommand); diff --git a/src/Bbx/Commands/SnippetCommand.cs b/src/Bbx/Commands/SnippetCommand.cs index ab43b03..7a58d77 100644 --- a/src/Bbx/Commands/SnippetCommand.cs +++ b/src/Bbx/Commands/SnippetCommand.cs @@ -43,7 +43,7 @@ private static Command CreateListCommand(IServiceProvider services) command.SetHandler((string? workspace, string? role, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListSnippetsRequest(workspace, role, limit), CancellationToken.None)), + .HandleAsync(new ListSnippetsRequest(workspace, role, limit), CommandBinding.CancellationToken)), workspaceOption, roleOption, limitOption); return command; } @@ -60,7 +60,7 @@ private static Command CreateViewCommand(IServiceProvider services) command.SetHandler((string snippetId, string? workspace) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewSnippetRequest(snippetId, workspace), CancellationToken.None)), + .HandleAsync(new ViewSnippetRequest(snippetId, workspace), CommandBinding.CancellationToken)), snippetIdArg, workspaceOption); return command; } @@ -68,8 +68,8 @@ private static Command CreateViewCommand(IServiceProvider services) private static Command CreateCreateCommand(IServiceProvider services) { var command = new Command("create", "Create a new snippet"); - var titleOption = new Option("--title", "-t") { Description = "Snippet title" , Required = true }; - var fileOption = new Option("--file", "-f") { Description = "File(s) to include in snippet (can be specified multiple times)" , Required = true, AllowMultipleArgumentsPerToken = true }; + var titleOption = new Option("--title", "-t") { Description = "Snippet title", Required = true }; + var fileOption = new Option("--file", "-f") { Description = "File(s) to include in snippet (can be specified multiple times)", Required = true, AllowMultipleArgumentsPerToken = true }; var privateOption = new Option("--private", "-p") { Description = "Make snippet private", DefaultValueFactory = _ => false }; var workspaceOption = new Option("--workspace", "-w") { Description = "Workspace slug (uses default if not specified)" }; @@ -81,7 +81,7 @@ private static Command CreateCreateCommand(IServiceProvider services) command.SetHandler((string title, string[] files, bool isPrivate, string? workspace) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new CreateSnippetRequest(title, files, isPrivate, workspace), CancellationToken.None)), + .HandleAsync(new CreateSnippetRequest(title, files, isPrivate, workspace), CommandBinding.CancellationToken)), titleOption, fileOption, privateOption, workspaceOption); return command; } @@ -104,7 +104,7 @@ private static Command CreateUpdateCommand(IServiceProvider services) command.SetHandler((string snippetId, string? title, string[]? files, bool? isPrivate, string? workspace) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new UpdateSnippetRequest(snippetId, title, files, isPrivate, workspace), CancellationToken.None)), + .HandleAsync(new UpdateSnippetRequest(snippetId, title, files, isPrivate, workspace), CommandBinding.CancellationToken)), snippetIdArg, titleOption, fileOption, privateOption, workspaceOption); return command; } @@ -126,7 +126,7 @@ private static Command CreateDeleteCommand(IServiceProvider services) return; await CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new DeleteSnippetRequest(snippetId, workspace), CancellationToken.None)); + .HandleAsync(new DeleteSnippetRequest(snippetId, workspace), CommandBinding.CancellationToken)); }, snippetIdArg, workspaceOption, yesOption); return command; } @@ -146,21 +146,9 @@ private static Command CreateFilesCommand(IServiceProvider services) command.SetHandler(async (string snippetId, string? fileName, string? workspace, bool raw) => { - try - { - await services.GetRequiredService() - .HandleAsync(new SnippetFilesRequest(snippetId, fileName, workspace, raw), CancellationToken.None); - } - catch (BbxUserException ex) - { - Console.Error.WriteLine(ex.Message); - Environment.ExitCode = 1; - } - catch (HttpRequestException ex) - { - Console.Error.WriteLine($"Error: {ex.Message}"); - Environment.ExitCode = 1; - } + await CommandRunner.RunDirectAsync(() => + services.GetRequiredService() + .HandleAsync(new SnippetFilesRequest(snippetId, fileName, workspace, raw), CommandBinding.CancellationToken)); }, snippetIdArg, fileNameArg, workspaceOption, rawOption); return command; } @@ -181,7 +169,7 @@ private static Command CreateWatchCommand(IServiceProvider services) command.SetHandler((string snippetId, string? workspace, bool list, bool unwatch) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new SnippetWatchRequest(snippetId, workspace, list, unwatch), CancellationToken.None)), + .HandleAsync(new SnippetWatchRequest(snippetId, workspace, list, unwatch), CommandBinding.CancellationToken)), snippetIdArg, workspaceOption, listOption, unwatchOption); return command; } @@ -202,7 +190,7 @@ private static Command CreateCommentsCommand(IServiceProvider services) command.SetHandler((string snippetId, string? workspace, string? addContent, int? deleteId) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new SnippetCommentsRequest(snippetId, workspace, addContent, deleteId), CancellationToken.None)), + .HandleAsync(new SnippetCommentsRequest(snippetId, workspace, addContent, deleteId), CommandBinding.CancellationToken)), snippetIdArg, workspaceOption, addOption, deleteOption); return command; } diff --git a/src/Bbx/Commands/SrcCommand.cs b/src/Bbx/Commands/SrcCommand.cs index de9d312..4997e0f 100644 --- a/src/Bbx/Commands/SrcCommand.cs +++ b/src/Bbx/Commands/SrcCommand.cs @@ -17,7 +17,7 @@ public static Command Create(IServiceProvider services) command.AddRecursiveOption(repoOption); var lsCommand = new Command("ls", "List entries at a path"); - var lsRefOption = new Option("--ref") { Description = "Commit hash or branch name" , Required = true }; + var lsRefOption = new Option("--ref") { Description = "Commit hash or branch name", Required = true }; var lsPathArg = new Argument("path") { Description = "Directory path (defaults to repo root)", DefaultValueFactory = _ => null }; var lsLimitOption = new Option("--limit") { Description = "Maximum entries to list", DefaultValueFactory = _ => 100 }; lsCommand.Options.Add(lsRefOption); @@ -26,27 +26,28 @@ public static Command Create(IServiceProvider services) lsCommand.SetHandler((string? workspace, string? repo, string @ref, string? path, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new LsSourceRequest(workspace, repo, @ref, path, limit), CancellationToken.None)), + .HandleAsync(new LsSourceRequest(workspace, repo, @ref, path, limit), CommandBinding.CancellationToken)), workspaceOption, repoOption, lsRefOption, lsPathArg, lsLimitOption); command.Subcommands.Add(lsCommand); var catCommand = new Command("cat", "Print file contents at a ref"); - var catRefOption = new Option("--ref") { Description = "Commit hash or branch name" , Required = true }; + var catRefOption = new Option("--ref") { Description = "Commit hash or branch name", Required = true }; var catPathArg = new Argument("path") { Description = "File path" }; catCommand.Options.Add(catRefOption); catCommand.Arguments.Add(catPathArg); catCommand.SetHandler((string? workspace, string? repo, string @ref, string path) => CommandRunner.RunRawAsync(() => services.GetRequiredService() - .HandleAsync(new CatSourceRequest(workspace, repo, @ref, path), CancellationToken.None)), + .HandleAsync(new CatSourceRequest(workspace, repo, @ref, path), CommandBinding.CancellationToken)), workspaceOption, repoOption, catRefOption, catPathArg); command.Subcommands.Add(catCommand); var writeCommand = new Command("write", "Commit one or more files to a branch"); - var writeBranchOption = new Option("--branch") { Description = "Target branch" , Required = true }; - var writeMessageOption = new Option("--message") { Description = "Commit message" , Required = true }; + var writeBranchOption = new Option("--branch") { Description = "Target branch", Required = true }; + var writeMessageOption = new Option("--message") { Description = "Commit message", Required = true }; var writeFileOption = new Option("--file") - { Description = "File mapping: =. Repeat for multiple files." , + { + Description = "File mapping: =. Repeat for multiple files.", Required = true, AllowMultipleArgumentsPerToken = false, }; @@ -58,7 +59,7 @@ public static Command Create(IServiceProvider services) writeCommand.SetHandler((string? workspace, string? repo, string branch, string message, string[] files, string? author) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new WriteSourceRequest(workspace, repo, branch, message, files, author), CancellationToken.None)), + .HandleAsync(new WriteSourceRequest(workspace, repo, branch, message, files, author), CommandBinding.CancellationToken)), workspaceOption, repoOption, writeBranchOption, writeMessageOption, writeFileOption, writeAuthorOption); command.Subcommands.Add(writeCommand); diff --git a/src/Bbx/Commands/UserCommand.cs b/src/Bbx/Commands/UserCommand.cs index 10125cc..a796816 100644 --- a/src/Bbx/Commands/UserCommand.cs +++ b/src/Bbx/Commands/UserCommand.cs @@ -23,7 +23,7 @@ public static Command Create(IServiceProvider services) emailsCommand.SetHandler((int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListUserEmailsRequest(limit), CancellationToken.None)), + .HandleAsync(new ListUserEmailsRequest(limit), CommandBinding.CancellationToken)), emailsLimitOption); command.Subcommands.Add(emailsCommand); @@ -36,7 +36,7 @@ public static Command Create(IServiceProvider services) permsWsCommand.SetHandler((int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListUserWorkspacePermissionsRequest(limit), CancellationToken.None)), + .HandleAsync(new ListUserWorkspacePermissionsRequest(limit), CommandBinding.CancellationToken)), permsWsLimitOption); permissionsCommand.Subcommands.Add(permsWsCommand); @@ -46,7 +46,7 @@ public static Command Create(IServiceProvider services) permsRepoCommand.SetHandler((int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListUserRepositoryPermissionsRequest(limit), CancellationToken.None)), + .HandleAsync(new ListUserRepositoryPermissionsRequest(limit), CommandBinding.CancellationToken)), permsRepoLimitOption); permissionsCommand.Subcommands.Add(permsRepoCommand); @@ -62,7 +62,7 @@ public static Command Create(IServiceProvider services) viewCommand.SetHandler((string selectedUser) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewUserRequest(selectedUser), CancellationToken.None)), + .HandleAsync(new ViewUserRequest(selectedUser), CommandBinding.CancellationToken)), viewUserArg); command.Subcommands.Add(viewCommand); @@ -86,7 +86,7 @@ private static Command CreateSshKeysCommand(IServiceProvider services) listCommand.SetHandler((string? user, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListSshKeysRequest(user ?? "me", limit), CancellationToken.None)), + .HandleAsync(new ListSshKeysRequest(user ?? "me", limit), CommandBinding.CancellationToken)), userOption, listLimitOption); sshCommand.Subcommands.Add(listCommand); @@ -96,19 +96,19 @@ private static Command CreateSshKeysCommand(IServiceProvider services) viewCommand.SetHandler((string? user, string keyId) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewSshKeyRequest(user ?? "me", keyId), CancellationToken.None)), + .HandleAsync(new ViewSshKeyRequest(user ?? "me", keyId), CommandBinding.CancellationToken)), userOption, viewIdArg); sshCommand.Subcommands.Add(viewCommand); var addCommand = new Command("add", "Add an SSH key"); - var addKeyOption = new Option("--key") { Description = "Public SSH key body" , Required = true }; + var addKeyOption = new Option("--key") { Description = "Public SSH key body", Required = true }; var addLabelOption = new Option("--label") { Description = "Friendly label" }; addCommand.Options.Add(addKeyOption); addCommand.Options.Add(addLabelOption); addCommand.SetHandler((string? user, string key, string? label) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new AddSshKeyRequest(user ?? "me", key, label), CancellationToken.None)), + .HandleAsync(new AddSshKeyRequest(user ?? "me", key, label), CommandBinding.CancellationToken)), userOption, addKeyOption, addLabelOption); sshCommand.Subcommands.Add(addCommand); @@ -123,7 +123,7 @@ private static Command CreateSshKeysCommand(IServiceProvider services) return; await CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new DeleteSshKeyRequest(user ?? "me", keyId), CancellationToken.None)); + .HandleAsync(new DeleteSshKeyRequest(user ?? "me", keyId), CommandBinding.CancellationToken)); }, userOption, deleteIdArg, yesOption); sshCommand.Subcommands.Add(deleteCommand); diff --git a/src/Bbx/Commands/WorkspaceCommand.cs b/src/Bbx/Commands/WorkspaceCommand.cs index fccc1ca..87c6678 100644 --- a/src/Bbx/Commands/WorkspaceCommand.cs +++ b/src/Bbx/Commands/WorkspaceCommand.cs @@ -1,4 +1,9 @@ using System.CommandLine; +using Bbx.Features.Workspaces.Hooks.CreateWorkspaceHook; +using Bbx.Features.Workspaces.Hooks.DeleteWorkspaceHook; +using Bbx.Features.Workspaces.Hooks.ListWorkspaceHooks; +using Bbx.Features.Workspaces.Hooks.UpdateWorkspaceHook; +using Bbx.Features.Workspaces.Hooks.ViewWorkspaceHook; using Bbx.Features.Workspaces.ListWorkspaceMembers; using Bbx.Features.Workspaces.ListWorkspacePermissions; using Bbx.Features.Workspaces.ListWorkspaces; @@ -15,11 +20,6 @@ using Bbx.Features.Workspaces.Projects.DeployKeys.ViewProjectDeployKey; using Bbx.Features.Workspaces.Projects.ListProjects; using Bbx.Features.Workspaces.Projects.ViewProject; -using Bbx.Features.Workspaces.Hooks.CreateWorkspaceHook; -using Bbx.Features.Workspaces.Hooks.DeleteWorkspaceHook; -using Bbx.Features.Workspaces.Hooks.ListWorkspaceHooks; -using Bbx.Features.Workspaces.Hooks.UpdateWorkspaceHook; -using Bbx.Features.Workspaces.Hooks.ViewWorkspaceHook; using Bbx.Features.Workspaces.ViewWorkspace; using Microsoft.Extensions.DependencyInjection; @@ -62,7 +62,7 @@ private static Command CreateProjectCommand(IServiceProvider services) projectCommand.Subcommands.Add(CreateProjectCreateCommand(services, workspaceOption)); projectCommand.Subcommands.Add(CreateProjectDeleteCommand(services, workspaceOption)); - var projectKeyOption = new Option("--project-key") { Description = "Project key" , Required = true }; + var projectKeyOption = new Option("--project-key") { Description = "Project key", Required = true }; projectCommand.Subcommands.Add(CreateProjectDefaultReviewersCommand(services, workspaceOption, projectKeyOption)); projectCommand.Subcommands.Add(CreateProjectBranchingModelCommand(services, workspaceOption, projectKeyOption)); projectCommand.Subcommands.Add(CreateProjectDeployKeysCommand(services, workspaceOption, projectKeyOption)); @@ -78,7 +78,7 @@ private static Command CreateProjectListCommand(IServiceProvider services, Optio listCommand.SetHandler((string? workspace, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListProjectsRequest(workspace, limit), CancellationToken.None)), + .HandleAsync(new ListProjectsRequest(workspace, limit), CommandBinding.CancellationToken)), workspaceOption, limitOption); return listCommand; } @@ -91,7 +91,7 @@ private static Command CreateProjectViewCommand(IServiceProvider services, Optio viewCommand.SetHandler((string? workspace, string key) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewProjectRequest(workspace, key), CancellationToken.None)), + .HandleAsync(new ViewProjectRequest(workspace, key), CommandBinding.CancellationToken)), workspaceOption, keyArg); return viewCommand; } @@ -99,8 +99,8 @@ private static Command CreateProjectViewCommand(IServiceProvider services, Optio private static Command CreateProjectCreateCommand(IServiceProvider services, Option workspaceOption) { var createCommand = new Command("create", "Create a workspace project"); - var keyOption = new Option("--key", "-k") { Description = "Project key" , Required = true }; - var nameOption = new Option("--name", "-n") { Description = "Project name" , Required = true }; + var keyOption = new Option("--key", "-k") { Description = "Project key", Required = true }; + var nameOption = new Option("--name", "-n") { Description = "Project name", Required = true }; var descriptionOption = new Option("--description", "-d") { Description = "Project description" }; var privateOption = new Option("--private", "-p") { Description = "Make project private", DefaultValueFactory = _ => true }; createCommand.Options.Add(keyOption); @@ -110,7 +110,7 @@ private static Command CreateProjectCreateCommand(IServiceProvider services, Opt createCommand.SetHandler((string? workspace, string key, string name, string? description, bool isPrivate) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new CreateProjectRequest(workspace, key, name, description, isPrivate), CancellationToken.None)), + .HandleAsync(new CreateProjectRequest(workspace, key, name, description, isPrivate), CommandBinding.CancellationToken)), workspaceOption, keyOption, nameOption, descriptionOption, privateOption); return createCommand; } @@ -128,7 +128,7 @@ private static Command CreateProjectDeleteCommand(IServiceProvider services, Opt return; await CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new DeleteProjectRequest(workspace, key), CancellationToken.None)); + .HandleAsync(new DeleteProjectRequest(workspace, key), CommandBinding.CancellationToken)); }, workspaceOption, keyArg, yesOption); return deleteCommand; } @@ -147,22 +147,22 @@ private static Command CreateProjectDefaultReviewersCommand(IServiceProvider ser listCommand.SetHandler((string? workspace, string projectKey, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListProjectDefaultReviewersRequest(workspace, projectKey, limit), CancellationToken.None)), + .HandleAsync(new ListProjectDefaultReviewersRequest(workspace, projectKey, limit), CommandBinding.CancellationToken)), workspaceOption, projectKeyOption, listLimitOption); drCommand.Subcommands.Add(listCommand); var addCommand = new Command("add", "Add a project default reviewer"); - var addTargetOption = new Option("--target") { Description = "Account ID or UUID of the user" , Required = true }; + var addTargetOption = new Option("--target") { Description = "Account ID or UUID of the user", Required = true }; addCommand.Options.Add(addTargetOption); addCommand.SetHandler((string? workspace, string projectKey, string target) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new AddProjectDefaultReviewerRequest(workspace, projectKey, target), CancellationToken.None)), + .HandleAsync(new AddProjectDefaultReviewerRequest(workspace, projectKey, target), CommandBinding.CancellationToken)), workspaceOption, projectKeyOption, addTargetOption); drCommand.Subcommands.Add(addCommand); var removeCommand = new Command("remove", "Remove a project default reviewer"); - var removeTargetOption = new Option("--target") { Description = "Account ID or UUID of the user" , Required = true }; + var removeTargetOption = new Option("--target") { Description = "Account ID or UUID of the user", Required = true }; var yesOption = new Option("--yes") { Description = "Skip confirmation" }; removeCommand.Options.Add(removeTargetOption); removeCommand.Options.Add(yesOption); @@ -172,7 +172,7 @@ private static Command CreateProjectDefaultReviewersCommand(IServiceProvider ser return; await CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new RemoveProjectDefaultReviewerRequest(workspace, projectKey, target), CancellationToken.None)); + .HandleAsync(new RemoveProjectDefaultReviewerRequest(workspace, projectKey, target), CommandBinding.CancellationToken)); }, workspaceOption, projectKeyOption, removeTargetOption, yesOption); drCommand.Subcommands.Add(removeCommand); @@ -189,18 +189,18 @@ private static Command CreateProjectBranchingModelCommand(IServiceProvider servi viewCommand.SetHandler((string? workspace, string projectKey) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewProjectBranchingModelRequest(workspace, projectKey), CancellationToken.None)), + .HandleAsync(new ViewProjectBranchingModelRequest(workspace, projectKey), CommandBinding.CancellationToken)), workspaceOption, projectKeyOption); bmCommand.Subcommands.Add(viewCommand); var updateCommand = new Command("update", "Replace project branching-model settings (PUT raw JSON payload to /branching-model/settings)"); - var settingsJsonOption = new Option("--settings") { Description = "JSON payload" , Required = true }; + var settingsJsonOption = new Option("--settings") { Description = "JSON payload", Required = true }; updateCommand.Options.Add(settingsJsonOption); updateCommand.SetHandler((string? workspace, string projectKey, string settingsJson) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new UpdateProjectBranchingModelSettingsRequest(workspace, projectKey, settingsJson), CancellationToken.None)), + .HandleAsync(new UpdateProjectBranchingModelSettingsRequest(workspace, projectKey, settingsJson), CommandBinding.CancellationToken)), workspaceOption, projectKeyOption, settingsJsonOption); bmCommand.Subcommands.Add(updateCommand); @@ -218,7 +218,7 @@ private static Command CreateProjectDeployKeysCommand(IServiceProvider services, listCommand.SetHandler((string? workspace, string projectKey, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListProjectDeployKeysRequest(workspace, projectKey, limit), CancellationToken.None)), + .HandleAsync(new ListProjectDeployKeysRequest(workspace, projectKey, limit), CommandBinding.CancellationToken)), workspaceOption, projectKeyOption, listLimitOption); dkCommand.Subcommands.Add(listCommand); @@ -228,19 +228,19 @@ private static Command CreateProjectDeployKeysCommand(IServiceProvider services, viewCommand.SetHandler((string? workspace, string projectKey, int keyId) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewProjectDeployKeyRequest(workspace, projectKey, keyId), CancellationToken.None)), + .HandleAsync(new ViewProjectDeployKeyRequest(workspace, projectKey, keyId), CommandBinding.CancellationToken)), workspaceOption, projectKeyOption, viewIdArg); dkCommand.Subcommands.Add(viewCommand); var addCommand = new Command("add", "Add a project deploy key"); - var addKeyOption = new Option("--key") { Description = "Public SSH key body" , Required = true }; + var addKeyOption = new Option("--key") { Description = "Public SSH key body", Required = true }; var addLabelOption = new Option("--label") { Description = "Friendly label" }; addCommand.Options.Add(addKeyOption); addCommand.Options.Add(addLabelOption); addCommand.SetHandler((string? workspace, string projectKey, string key, string? label) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new AddProjectDeployKeyRequest(workspace, projectKey, key, label), CancellationToken.None)), + .HandleAsync(new AddProjectDeployKeyRequest(workspace, projectKey, key, label), CommandBinding.CancellationToken)), workspaceOption, projectKeyOption, addKeyOption, addLabelOption); dkCommand.Subcommands.Add(addCommand); @@ -255,7 +255,7 @@ private static Command CreateProjectDeployKeysCommand(IServiceProvider services, return; await CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new DeleteProjectDeployKeyRequest(workspace, projectKey, keyId), CancellationToken.None)); + .HandleAsync(new DeleteProjectDeployKeyRequest(workspace, projectKey, keyId), CommandBinding.CancellationToken)); }, workspaceOption, projectKeyOption, deleteIdArg, yesOption); dkCommand.Subcommands.Add(deleteCommand); @@ -274,7 +274,7 @@ private static Command CreateListCommand(IServiceProvider services) command.SetHandler((string? role, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListWorkspacesRequest(role, limit), CancellationToken.None)), + .HandleAsync(new ListWorkspacesRequest(role, limit), CommandBinding.CancellationToken)), roleOption, limitOption); return command; } @@ -288,7 +288,7 @@ private static Command CreateViewCommand(IServiceProvider services) command.SetHandler((string? workspace) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewWorkspaceRequest(workspace), CancellationToken.None)), + .HandleAsync(new ViewWorkspaceRequest(workspace), CommandBinding.CancellationToken)), workspaceArg); return command; } @@ -305,7 +305,7 @@ private static Command CreateMembersCommand(IServiceProvider services) command.SetHandler((string? workspace, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListWorkspaceMembersRequest(workspace, limit), CancellationToken.None)), + .HandleAsync(new ListWorkspaceMembersRequest(workspace, limit), CommandBinding.CancellationToken)), workspaceOption, limitOption); return command; } @@ -322,7 +322,7 @@ private static Command CreatePermissionsCommand(IServiceProvider services) command.SetHandler((string? workspace, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListWorkspacePermissionsRequest(workspace, limit), CancellationToken.None)), + .HandleAsync(new ListWorkspacePermissionsRequest(workspace, limit), CommandBinding.CancellationToken)), workspaceOption, limitOption); return command; } @@ -339,7 +339,7 @@ private static Command CreateHooksCommand(IServiceProvider services) listCommand.SetHandler((string? workspace, int limit) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ListWorkspaceHooksRequest(workspace, limit), CancellationToken.None)), + .HandleAsync(new ListWorkspaceHooksRequest(workspace, limit), CommandBinding.CancellationToken)), workspaceOption, limitOption); hooksCommand.Subcommands.Add(listCommand); @@ -349,12 +349,12 @@ private static Command CreateHooksCommand(IServiceProvider services) viewCommand.SetHandler((string? workspace, string uid) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new ViewWorkspaceHookRequest(workspace, uid), CancellationToken.None)), + .HandleAsync(new ViewWorkspaceHookRequest(workspace, uid), CommandBinding.CancellationToken)), workspaceOption, viewUidArg); hooksCommand.Subcommands.Add(viewCommand); var createCommand = new Command("create", "Create a workspace webhook"); - var urlOption = new Option("--url") { Description = "Webhook target URL" , Required = true }; + var urlOption = new Option("--url") { Description = "Webhook target URL", Required = true }; var descriptionOption = new Option("--description") { Description = "Webhook description" }; var eventsOption = new Option("--events") { Description = "Events to trigger webhook (default: repo:push)" }; var activeOption = new Option("--active") { Description = "Whether the webhook is active", DefaultValueFactory = _ => true }; @@ -365,7 +365,7 @@ private static Command CreateHooksCommand(IServiceProvider services) createCommand.SetHandler((string? workspace, string url, string? description, string[]? events, bool active) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new CreateWorkspaceHookRequest(workspace, url, description, events, active), CancellationToken.None)), + .HandleAsync(new CreateWorkspaceHookRequest(workspace, url, description, events, active), CommandBinding.CancellationToken)), workspaceOption, urlOption, descriptionOption, eventsOption, activeOption); hooksCommand.Subcommands.Add(createCommand); @@ -383,7 +383,7 @@ private static Command CreateHooksCommand(IServiceProvider services) updateCommand.SetHandler((string? workspace, string uid, string? url, string? description, string[]? events, bool? active) => CommandRunner.RunJsonAsync(() => services.GetRequiredService() - .HandleAsync(new UpdateWorkspaceHookRequest(workspace, uid, url, description, events, active), CancellationToken.None)), + .HandleAsync(new UpdateWorkspaceHookRequest(workspace, uid, url, description, events, active), CommandBinding.CancellationToken)), workspaceOption, updateUidArg, updateUrlOption, updateDescriptionOption, updateEventsOption, updateActiveOption); hooksCommand.Subcommands.Add(updateCommand); @@ -398,7 +398,7 @@ private static Command CreateHooksCommand(IServiceProvider services) return; await CommandRunner.RunActionAsync(() => services.GetRequiredService() - .HandleAsync(new DeleteWorkspaceHookRequest(workspace, uid), CancellationToken.None)); + .HandleAsync(new DeleteWorkspaceHookRequest(workspace, uid), CommandBinding.CancellationToken)); }, workspaceOption, deleteUidArg, yesOption); hooksCommand.Subcommands.Add(deleteCommand); diff --git a/src/Bbx/Features/Auth/Status/AuthStatusHandler.cs b/src/Bbx/Features/Auth/Status/AuthStatusHandler.cs index 95aa723..91da730 100644 --- a/src/Bbx/Features/Auth/Status/AuthStatusHandler.cs +++ b/src/Bbx/Features/Auth/Status/AuthStatusHandler.cs @@ -6,33 +6,25 @@ namespace Bbx.Features.Auth.Status; public sealed class AuthStatusHandler(BitbucketClient client, CredentialManager credentials) { - public async Task HandleAsync(AuthStatusRequest request, CancellationToken ct) + public async Task HandleAsync(AuthStatusRequest request, CancellationToken ct) { var config = credentials.LoadConfig(); if (!credentials.HasCredentials()) { - Console.WriteLine("Not authenticated. Run: bbx auth login"); - return; + throw new BbxUserException("Not authenticated. Run: bbx auth login"); } var method = ResolveMethod(config); + var user = await client.GetAsync("/user", ct); + var displayName = user.GetStringOrNull("display_name") ?? "Unknown"; + var username = user.GetStringOrNull("username") ?? config.Username; + var workspace = config.DefaultWorkspace is null + ? string.Empty + : $"{Environment.NewLine} Default workspace: {config.DefaultWorkspace}"; - try - { - var user = await client.GetAsync("/user", ct); - var displayName = user.TryGetProperty("display_name", out var dn) ? dn.GetString() : "Unknown"; - var username = user.TryGetProperty("username", out var un) ? un.GetString() : config.Username; - - Console.WriteLine($"✓ Authenticated as: {displayName}"); - Console.WriteLine($" Username: {username}"); - Console.WriteLine($" Auth method: {method}"); - if (config.DefaultWorkspace != null) - Console.WriteLine($" Default workspace: {config.DefaultWorkspace}"); - } - catch (Exception ex) - { - Console.Error.WriteLine($"Error checking status: {ex.Message}"); - } + return $"✓ Authenticated as: {displayName}{Environment.NewLine}" + + $" Username: {username}{Environment.NewLine}" + + $" Auth method: {method}{workspace}"; } private static string ResolveMethod(BbxConfig config) diff --git a/src/Bbx/Features/Auth/Token/AuthTokenHandler.cs b/src/Bbx/Features/Auth/Token/AuthTokenHandler.cs index e37538b..cdf8021 100644 --- a/src/Bbx/Features/Auth/Token/AuthTokenHandler.cs +++ b/src/Bbx/Features/Auth/Token/AuthTokenHandler.cs @@ -4,21 +4,14 @@ namespace Bbx.Features.Auth.Token; public sealed class AuthTokenHandler(CredentialManager credentials) { - public Task HandleAsync(AuthTokenRequest request, CancellationToken ct) + public Task HandleAsync(AuthTokenRequest request, CancellationToken ct) { var config = credentials.LoadConfig(); // Printed in the form curl and friends expect for Basic auth. - if (!string.IsNullOrEmpty(config.ApiToken)) - { - Console.WriteLine($"{config.Username}:{config.ApiToken}"); - } - else - { - Console.Error.WriteLine("Not authenticated"); - Environment.ExitCode = 1; - } + if (string.IsNullOrEmpty(config.ApiToken)) + throw new BbxUserException("Not authenticated"); - return Task.CompletedTask; + return Task.FromResult($"{config.Username}:{config.ApiToken}"); } } diff --git a/src/Bbx/Features/Commits/FileHistory/FileHistoryHandler.cs b/src/Bbx/Features/Commits/FileHistory/FileHistoryHandler.cs index 09cc8e3..6f77e07 100644 --- a/src/Bbx/Features/Commits/FileHistory/FileHistoryHandler.cs +++ b/src/Bbx/Features/Commits/FileHistory/FileHistoryHandler.cs @@ -14,7 +14,7 @@ public async Task HandleAsync(FileHistoryRequest request, CancellationTo if (string.IsNullOrEmpty(request.Path)) throw new BbxUserException("Error: is required."); - var path = NormalizePath(request.Path); + var path = EndpointPath.EscapeSegments(request.Path); var endpoint = $"/repositories/{ws}/{repo}/filehistory/{Uri.EscapeDataString(request.Hash)}/{path}"; var entries = new List(); @@ -46,14 +46,4 @@ public async Task HandleAsync(FileHistoryRequest request, CancellationTo }; } - private static string NormalizePath(string path) - { - var trimmed = path.TrimStart('/'); - var parts = trimmed.Split('/'); - for (var i = 0; i < parts.Length; i++) - { - parts[i] = Uri.EscapeDataString(parts[i]); - } - return string.Join('/', parts); - } } diff --git a/src/Bbx/Features/Common/EndpointPath.cs b/src/Bbx/Features/Common/EndpointPath.cs new file mode 100644 index 0000000..5596d14 --- /dev/null +++ b/src/Bbx/Features/Common/EndpointPath.cs @@ -0,0 +1,11 @@ +namespace Bbx.Features.Common; + +internal static class EndpointPath +{ + public static string EscapeSegments(string? path) + { + if (string.IsNullOrEmpty(path)) return string.Empty; + + return string.Join('/', path.TrimStart('/').Split('/').Select(Uri.EscapeDataString)); + } +} diff --git a/src/Bbx/Features/Downloads/GetDownload/GetDownloadHandler.cs b/src/Bbx/Features/Downloads/GetDownload/GetDownloadHandler.cs index b32ab6b..5a24a20 100644 --- a/src/Bbx/Features/Downloads/GetDownload/GetDownloadHandler.cs +++ b/src/Bbx/Features/Downloads/GetDownload/GetDownloadHandler.cs @@ -6,14 +6,14 @@ namespace Bbx.Features.Downloads.GetDownload; public sealed class GetDownloadHandler(BitbucketClient client, CredentialManager credentials) { - public async Task HandleAsync(GetDownloadRequest request, CancellationToken ct) + public async Task HandleAsync(GetDownloadRequest request, Stream destination, CancellationToken ct) { var (ws, repo) = Resolve.WorkspaceAndRepo(credentials, request.Workspace, request.Repository, "Error: Workspace and repository required."); if (string.IsNullOrEmpty(request.Filename)) throw new BbxUserException("Error: is required."); - return await client.GetByteArrayAsync( - $"/repositories/{ws}/{repo}/downloads/{Uri.EscapeDataString(request.Filename)}", ct); + await client.CopyToAsync( + $"/repositories/{ws}/{repo}/downloads/{Uri.EscapeDataString(request.Filename)}", destination, ct); } } diff --git a/src/Bbx/Features/Pipelines/ListDeploymentEnvironments/ListDeploymentEnvironmentsHandler.cs b/src/Bbx/Features/Pipelines/ListDeploymentEnvironments/ListDeploymentEnvironmentsHandler.cs index 4e3554d..37f5517 100644 --- a/src/Bbx/Features/Pipelines/ListDeploymentEnvironments/ListDeploymentEnvironmentsHandler.cs +++ b/src/Bbx/Features/Pipelines/ListDeploymentEnvironments/ListDeploymentEnvironmentsHandler.cs @@ -28,7 +28,9 @@ public async Task HandleAsync(ListDeploymentEnvironmentsRequest request, { uuid = PipelineFormat.GetString(e, "uuid"), name = PipelineFormat.GetString(e, "name"), - environment_type = e.TryGetProperty("environment_type", out var et) ? PipelineFormat.GetString(et, "name") : null, + environment_type = e.TryGetObject("environment_type", out var et) + ? PipelineFormat.GetString(et, "name") + : null, rank = e.TryGetProperty("rank", out var r) ? r.GetInt32() : 0, deployment_gate_enabled = e.TryGetProperty("deployment_gate_enabled", out var dg) && dg.GetBoolean(), }), diff --git a/src/Bbx/Features/Pipelines/ListPipelineSchedules/ListPipelineSchedulesHandler.cs b/src/Bbx/Features/Pipelines/ListPipelineSchedules/ListPipelineSchedulesHandler.cs index 1c9fbba..a0c8675 100644 --- a/src/Bbx/Features/Pipelines/ListPipelineSchedules/ListPipelineSchedulesHandler.cs +++ b/src/Bbx/Features/Pipelines/ListPipelineSchedules/ListPipelineSchedulesHandler.cs @@ -29,7 +29,7 @@ public async Task HandleAsync(ListPipelineSchedulesRequest request, Canc uuid = PipelineFormat.GetString(s, "uuid"), enabled = s.TryGetProperty("enabled", out var en) && en.GetBoolean(), cron_pattern = PipelineFormat.GetString(s, "cron_pattern"), - target = s.TryGetProperty("target", out var t) ? (object)new + target = s.TryGetObject("target", out var t) ? (object)new { ref_name = PipelineFormat.GetString(t, "ref_name"), ref_type = PipelineFormat.GetString(t, "ref_type"), diff --git a/src/Bbx/Features/Pipelines/ListPipelineVariables/ListPipelineVariablesHandler.cs b/src/Bbx/Features/Pipelines/ListPipelineVariables/ListPipelineVariablesHandler.cs index c411839..d122e26 100644 --- a/src/Bbx/Features/Pipelines/ListPipelineVariables/ListPipelineVariablesHandler.cs +++ b/src/Bbx/Features/Pipelines/ListPipelineVariables/ListPipelineVariablesHandler.cs @@ -28,9 +28,13 @@ public async Task HandleAsync(ListPipelineVariablesRequest request, Canc { uuid = PipelineFormat.GetString(v, "uuid"), key = PipelineFormat.GetString(v, "key"), - secured = v.TryGetProperty("secured", out var sec) && sec.GetBoolean(), - value = v.TryGetProperty("secured", out var s) && s.GetBoolean() ? "***" : PipelineFormat.GetString(v, "value"), + secured = IsTrue(v, "secured"), + value = IsTrue(v, "secured") ? "***" : PipelineFormat.GetString(v, "value"), }), }; } + + private static bool IsTrue(JsonElement element, string propertyName) => + element.TryGetProperty(propertyName, out var value) + && value.ValueKind is JsonValueKind.True; } diff --git a/src/Bbx/Features/Pipelines/PipelineFormat.cs b/src/Bbx/Features/Pipelines/PipelineFormat.cs index 78acbf6..b745475 100644 --- a/src/Bbx/Features/Pipelines/PipelineFormat.cs +++ b/src/Bbx/Features/Pipelines/PipelineFormat.cs @@ -5,9 +5,7 @@ namespace Bbx.Features.Pipelines; internal static class PipelineFormat { public static string? GetString(JsonElement element, string propertyName) => - element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String - ? prop.GetString() - : null; + element.GetStringOrNull(propertyName); public static string BuildQuery(string? status, string? branch) { @@ -19,68 +17,133 @@ public static string BuildQuery(string? status, string? branch) return string.Join(" AND ", conditions); } - public static object Pipeline(JsonElement p) => new + public static PipelineSummary Pipeline(JsonElement pipeline) { - uuid = GetString(p, "uuid"), - build_number = p.TryGetProperty("build_number", out var bn) ? bn.GetInt32() : 0, - state = p.TryGetProperty("state", out var state) ? (object)new - { - name = GetString(state, "name"), - result = state.TryGetProperty("result", out var r) ? GetString(r, "name") : null, - } : null!, - target = p.TryGetProperty("target", out var target) ? (object)new - { - ref_type = GetString(target, "ref_type"), - ref_name = GetString(target, "ref_name"), - commit = target.TryGetProperty("commit", out var c) ? GetString(c, "hash") : null, - } : null!, - trigger = p.TryGetProperty("trigger", out var trigger) ? GetString(trigger, "name") : null, - created_on = GetString(p, "created_on"), - completed_on = GetString(p, "completed_on"), - duration_in_seconds = p.TryGetProperty("duration_in_seconds", out var dur) ? dur.GetInt32() : (int?)null, - }; - - public static object PipelineDetailed(JsonElement p) + var state = State(pipeline); + var target = Target(pipeline); + + return new PipelineSummary( + GetString(pipeline, "uuid"), + GetInt32(pipeline, "build_number") ?? 0, + state, + target, + pipeline.TryGetObject("trigger", out var trigger) ? GetString(trigger, "name") : null, + GetString(pipeline, "created_on"), + GetString(pipeline, "completed_on"), + GetInt32(pipeline, "duration_in_seconds")); + } + + public static PipelineDetails PipelineDetailed(JsonElement pipeline) { - var basic = Pipeline(p); - return new - { - ((dynamic)basic).uuid, - ((dynamic)basic).build_number, - ((dynamic)basic).state, - ((dynamic)basic).target, - ((dynamic)basic).trigger, - ((dynamic)basic).created_on, - ((dynamic)basic).completed_on, - ((dynamic)basic).duration_in_seconds, - creator = p.TryGetProperty("creator", out var creator) ? (object)new - { - display_name = GetString(creator, "display_name"), - account_id = GetString(creator, "account_id"), - } : null!, - repository = p.TryGetProperty("repository", out var repo) ? (object)new - { - name = GetString(repo, "name"), - full_name = GetString(repo, "full_name"), - } : null!, - links = p.TryGetObject("links", out var links) && links.TryGetProperty("html", out var html) - ? GetString(html, "href") : null, - }; + var summary = Pipeline(pipeline); + PipelineActor? creator = pipeline.TryGetObject("creator", out var creatorElement) + ? new PipelineActor( + GetString(creatorElement, "display_name"), + GetString(creatorElement, "account_id")) + : null; + PipelineRepository? repository = pipeline.TryGetObject("repository", out var repositoryElement) + ? new PipelineRepository( + GetString(repositoryElement, "name"), + GetString(repositoryElement, "full_name")) + : null; + var link = pipeline.TryGetObject("links", out var links) + && links.TryGetObject("html", out var html) + ? GetString(html, "href") + : null; + + return new PipelineDetails( + summary.Uuid, + summary.BuildNumber, + summary.State, + summary.Target, + summary.Trigger, + summary.CreatedOn, + summary.CompletedOn, + summary.DurationInSeconds, + creator, + repository, + link); + } + + public static PipelineStep Step(JsonElement step) => new( + GetString(step, "uuid"), + GetString(step, "name"), + State(step), + GetString(step, "started_on"), + GetString(step, "completed_on"), + GetInt32(step, "duration_in_seconds"), + GetInt32(step, "run_number"), + GetInt32(step, "max_time")); + + private static PipelineState? State(JsonElement element) + { + if (!element.TryGetObject("state", out var state)) return null; + + var result = state.TryGetObject("result", out var resultElement) + ? GetString(resultElement, "name") + : null; + return new PipelineState(GetString(state, "name"), result); } - public static object Step(JsonElement s) => new + private static PipelineTarget? Target(JsonElement element) { - uuid = GetString(s, "uuid"), - name = GetString(s, "name"), - state = s.TryGetProperty("state", out var state) ? (object)new - { - name = GetString(state, "name"), - result = state.TryGetProperty("result", out var r) ? GetString(r, "name") : null, - } : null!, - started_on = GetString(s, "started_on"), - completed_on = GetString(s, "completed_on"), - duration_in_seconds = s.TryGetProperty("duration_in_seconds", out var dur) ? dur.GetInt32() : (int?)null, - run_number = s.TryGetProperty("run_number", out var rn) ? rn.GetInt32() : (int?)null, - max_time = s.TryGetProperty("max_time", out var mt) ? mt.GetInt32() : (int?)null, - }; + if (!element.TryGetObject("target", out var target)) return null; + + var commit = target.TryGetObject("commit", out var commitElement) + ? GetString(commitElement, "hash") + : null; + return new PipelineTarget( + GetString(target, "ref_type"), + GetString(target, "ref_name"), + commit); + } + + private static int? GetInt32(JsonElement element, string propertyName) => + element.ValueKind == JsonValueKind.Object + && element.TryGetProperty(propertyName, out var value) + && value.ValueKind == JsonValueKind.Number + && value.TryGetInt32(out var number) + ? number + : null; } + +internal sealed record PipelineState(string? Name, string? Result); + +internal sealed record PipelineTarget(string? RefType, string? RefName, string? Commit); + +internal sealed record PipelineSummary( + string? Uuid, + int BuildNumber, + PipelineState? State, + PipelineTarget? Target, + string? Trigger, + string? CreatedOn, + string? CompletedOn, + int? DurationInSeconds); + +internal sealed record PipelineActor(string? DisplayName, string? AccountId); + +internal sealed record PipelineRepository(string? Name, string? FullName); + +internal sealed record PipelineDetails( + string? Uuid, + int BuildNumber, + PipelineState? State, + PipelineTarget? Target, + string? Trigger, + string? CreatedOn, + string? CompletedOn, + int? DurationInSeconds, + PipelineActor? Creator, + PipelineRepository? Repository, + string? Links); + +internal sealed record PipelineStep( + string? Uuid, + string? Name, + PipelineState? State, + string? StartedOn, + string? CompletedOn, + int? DurationInSeconds, + int? RunNumber, + int? MaxTime); diff --git a/src/Bbx/Features/Pipelines/ViewDeploymentEnvironment/ViewDeploymentEnvironmentHandler.cs b/src/Bbx/Features/Pipelines/ViewDeploymentEnvironment/ViewDeploymentEnvironmentHandler.cs index 9450a5c..2a85d6f 100644 --- a/src/Bbx/Features/Pipelines/ViewDeploymentEnvironment/ViewDeploymentEnvironmentHandler.cs +++ b/src/Bbx/Features/Pipelines/ViewDeploymentEnvironment/ViewDeploymentEnvironmentHandler.cs @@ -19,13 +19,13 @@ public async Task HandleAsync(ViewDeploymentEnvironmentRequest request, { uuid = PipelineFormat.GetString(env, "uuid"), name = PipelineFormat.GetString(env, "name"), - environment_type = env.TryGetProperty("environment_type", out var et) ? (object)new + environment_type = env.TryGetObject("environment_type", out var et) ? (object)new { name = PipelineFormat.GetString(et, "name"), rank = et.TryGetProperty("rank", out var r) ? r.GetInt32() : 0, } : null!, deployment_gate_enabled = env.TryGetProperty("deployment_gate_enabled", out var dg) && dg.GetBoolean(), - lock_ = env.TryGetProperty("lock", out var l) ? (object)new + lock_ = env.TryGetObject("lock", out var l) ? (object)new { type = PipelineFormat.GetString(l, "type"), name = PipelineFormat.GetString(l, "name"), diff --git a/src/Bbx/Features/Pipelines/ViewPipeline/ViewPipelineHandler.cs b/src/Bbx/Features/Pipelines/ViewPipeline/ViewPipelineHandler.cs index 32bda24..9cd366a 100644 --- a/src/Bbx/Features/Pipelines/ViewPipeline/ViewPipelineHandler.cs +++ b/src/Bbx/Features/Pipelines/ViewPipeline/ViewPipelineHandler.cs @@ -21,8 +21,9 @@ public async Task HandleAsync(ViewPipelineRequest request, CancellationT { pipeline = PipelineFormat.PipelineDetailed(pipeline), steps = steps.TryGetProperty("values", out var stepsArray) + && stepsArray.ValueKind == JsonValueKind.Array ? stepsArray.EnumerateArray().Select(PipelineFormat.Step).ToList() - : new List(), + : [], }; } } diff --git a/src/Bbx/Features/Repos/RepoPermissions/RepoPermissionsHandler.cs b/src/Bbx/Features/Repos/RepoPermissions/RepoPermissionsHandler.cs index 8d4a071..d2be102 100644 --- a/src/Bbx/Features/Repos/RepoPermissions/RepoPermissionsHandler.cs +++ b/src/Bbx/Features/Repos/RepoPermissions/RepoPermissionsHandler.cs @@ -19,7 +19,7 @@ public async Task HandleAsync(RepoPermissionsRequest request, Cancellati permissions.Add(new { type = "user", - user = perm.TryGetProperty("user", out var u) ? u.GetProperty("display_name").GetString() : null, + user = perm.TryGetObject("user", out var u) ? u.GetStringOrNull("display_name") : null, permission = perm.TryGetProperty("permission", out var p) ? p.GetString() : null, }); } diff --git a/src/Bbx/Features/Snippets/CreateSnippet/CreateSnippetHandler.cs b/src/Bbx/Features/Snippets/CreateSnippet/CreateSnippetHandler.cs index d61e5c8..795800a 100644 --- a/src/Bbx/Features/Snippets/CreateSnippet/CreateSnippetHandler.cs +++ b/src/Bbx/Features/Snippets/CreateSnippet/CreateSnippetHandler.cs @@ -9,8 +9,6 @@ public sealed class CreateSnippetHandler(BitbucketClient client, CredentialManag { public async Task HandleAsync(CreateSnippetRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); var workspace = Resolve.Workspace(credentials, request.Workspace, "Error: Workspace required. Use --workspace or set default with 'bbx auth set-workspace'."); diff --git a/src/Bbx/Features/Snippets/DeleteSnippet/DeleteSnippetHandler.cs b/src/Bbx/Features/Snippets/DeleteSnippet/DeleteSnippetHandler.cs index ab5cacf..afd1143 100644 --- a/src/Bbx/Features/Snippets/DeleteSnippet/DeleteSnippetHandler.cs +++ b/src/Bbx/Features/Snippets/DeleteSnippet/DeleteSnippetHandler.cs @@ -8,8 +8,6 @@ public sealed class DeleteSnippetHandler(BitbucketClient client, CredentialManag { public async Task HandleAsync(DeleteSnippetRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); var workspace = Resolve.Workspace(credentials, request.Workspace, "Error: Workspace required. Use --workspace or set default with 'bbx auth set-workspace'."); diff --git a/src/Bbx/Features/Snippets/ListSnippets/ListSnippetsHandler.cs b/src/Bbx/Features/Snippets/ListSnippets/ListSnippetsHandler.cs index 8e187d8..2ef2ade 100644 --- a/src/Bbx/Features/Snippets/ListSnippets/ListSnippetsHandler.cs +++ b/src/Bbx/Features/Snippets/ListSnippets/ListSnippetsHandler.cs @@ -4,12 +4,10 @@ namespace Bbx.Features.Snippets.ListSnippets; -public sealed class ListSnippetsHandler(BitbucketClient client, CredentialManager credentials) +public sealed class ListSnippetsHandler(BitbucketClient client) { public async Task HandleAsync(ListSnippetsRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); var endpoint = !string.IsNullOrEmpty(request.Workspace) ? $"snippets/{request.Workspace}" : "snippets"; if (!string.IsNullOrEmpty(request.Role)) diff --git a/src/Bbx/Features/Snippets/SnippetComments/SnippetCommentsHandler.cs b/src/Bbx/Features/Snippets/SnippetComments/SnippetCommentsHandler.cs index 6e19ed9..8e3fd77 100644 --- a/src/Bbx/Features/Snippets/SnippetComments/SnippetCommentsHandler.cs +++ b/src/Bbx/Features/Snippets/SnippetComments/SnippetCommentsHandler.cs @@ -9,8 +9,6 @@ public sealed class SnippetCommentsHandler(BitbucketClient client, CredentialMan { public async Task HandleAsync(SnippetCommentsRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); var workspace = Resolve.Workspace(credentials, request.Workspace, "Error: Workspace required. Use --workspace or set default with 'bbx auth set-workspace'."); diff --git a/src/Bbx/Features/Snippets/SnippetFiles/SnippetFilesHandler.cs b/src/Bbx/Features/Snippets/SnippetFiles/SnippetFilesHandler.cs index 37bb293..65de5b1 100644 --- a/src/Bbx/Features/Snippets/SnippetFiles/SnippetFilesHandler.cs +++ b/src/Bbx/Features/Snippets/SnippetFiles/SnippetFilesHandler.cs @@ -10,8 +10,6 @@ public sealed class SnippetFilesHandler(BitbucketClient client, CredentialManage { public async Task HandleAsync(SnippetFilesRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); var workspace = Resolve.Workspace(credentials, request.Workspace, "Error: Workspace required. Use --workspace or set default with 'bbx auth set-workspace'."); @@ -43,7 +41,7 @@ public async Task HandleAsync(SnippetFilesRequest request, CancellationToken ct) else { var content = await client.GetRawAsync( - $"snippets/{workspace}/{request.SnippetId}/files/{request.FileName}", ct); + $"snippets/{workspace}/{request.SnippetId}/files/{EndpointPath.EscapeSegments(request.FileName)}", ct); if (request.Raw) { diff --git a/src/Bbx/Features/Snippets/SnippetWatch/SnippetWatchHandler.cs b/src/Bbx/Features/Snippets/SnippetWatch/SnippetWatchHandler.cs index cfc3c18..702914b 100644 --- a/src/Bbx/Features/Snippets/SnippetWatch/SnippetWatchHandler.cs +++ b/src/Bbx/Features/Snippets/SnippetWatch/SnippetWatchHandler.cs @@ -9,8 +9,6 @@ public sealed class SnippetWatchHandler(BitbucketClient client, CredentialManage { public async Task HandleAsync(SnippetWatchRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); var workspace = Resolve.Workspace(credentials, request.Workspace, "Error: Workspace required. Use --workspace or set default with 'bbx auth set-workspace'."); diff --git a/src/Bbx/Features/Snippets/UpdateSnippet/UpdateSnippetHandler.cs b/src/Bbx/Features/Snippets/UpdateSnippet/UpdateSnippetHandler.cs index 8ead537..4d48fc8 100644 --- a/src/Bbx/Features/Snippets/UpdateSnippet/UpdateSnippetHandler.cs +++ b/src/Bbx/Features/Snippets/UpdateSnippet/UpdateSnippetHandler.cs @@ -9,8 +9,6 @@ public sealed class UpdateSnippetHandler(BitbucketClient client, CredentialManag { public async Task HandleAsync(UpdateSnippetRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); var workspace = Resolve.Workspace(credentials, request.Workspace, "Error: Workspace required. Use --workspace or set default with 'bbx auth set-workspace'."); diff --git a/src/Bbx/Features/Snippets/ViewSnippet/ViewSnippetHandler.cs b/src/Bbx/Features/Snippets/ViewSnippet/ViewSnippetHandler.cs index bdbc3e3..b740315 100644 --- a/src/Bbx/Features/Snippets/ViewSnippet/ViewSnippetHandler.cs +++ b/src/Bbx/Features/Snippets/ViewSnippet/ViewSnippetHandler.cs @@ -9,8 +9,6 @@ public sealed class ViewSnippetHandler(BitbucketClient client, CredentialManager { public async Task HandleAsync(ViewSnippetRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); var workspace = Resolve.Workspace(credentials, request.Workspace, "Error: Workspace required. Use --workspace or set default with 'bbx auth set-workspace'."); @@ -25,12 +23,12 @@ public async Task HandleAsync(ViewSnippetRequest request, CancellationTo scm = snippet.TryGetProperty("scm", out var s) ? s.GetString() : "git", created_on = snippet.TryGetProperty("created_on", out var c) ? c.GetString() : null, updated_on = snippet.TryGetProperty("updated_on", out var u) ? u.GetString() : null, - owner = snippet.TryGetProperty("owner", out var o) ? (object)new + owner = snippet.TryGetObject("owner", out var o) ? (object)new { display_name = o.TryGetProperty("display_name", out var d) ? d.GetString() : null, username = o.TryGetProperty("username", out var un) ? un.GetString() : null, } : null!, - creator = snippet.TryGetProperty("creator", out var cr) ? (object)new + creator = snippet.TryGetObject("creator", out var cr) ? (object)new { display_name = cr.TryGetProperty("display_name", out var cd) ? cd.GetString() : null, username = cr.TryGetProperty("username", out var cu) ? cu.GetString() : null, diff --git a/src/Bbx/Features/Source/CatSource/CatSourceHandler.cs b/src/Bbx/Features/Source/CatSource/CatSourceHandler.cs index 156ffe2..b10d62f 100644 --- a/src/Bbx/Features/Source/CatSource/CatSourceHandler.cs +++ b/src/Bbx/Features/Source/CatSource/CatSourceHandler.cs @@ -15,18 +15,7 @@ public async Task HandleAsync(CatSourceRequest request, CancellationToke "Error: Workspace and repository required."); var refSegment = Uri.EscapeDataString(request.Ref); - var path = NormalizePath(request.Path); + var path = EndpointPath.EscapeSegments(request.Path); return await client.GetStringAsync($"/repositories/{ws}/{repo}/src/{refSegment}/{path}", ct); } - - private static string NormalizePath(string path) - { - var trimmed = path.TrimStart('/'); - var parts = trimmed.Split('/'); - for (var i = 0; i < parts.Length; i++) - { - parts[i] = Uri.EscapeDataString(parts[i]); - } - return string.Join('/', parts); - } } diff --git a/src/Bbx/Features/Source/LsSource/LsSourceHandler.cs b/src/Bbx/Features/Source/LsSource/LsSourceHandler.cs index 4e0f57c..f0633e6 100644 --- a/src/Bbx/Features/Source/LsSource/LsSourceHandler.cs +++ b/src/Bbx/Features/Source/LsSource/LsSourceHandler.cs @@ -12,7 +12,7 @@ public async Task HandleAsync(LsSourceRequest request, CancellationToken var (ws, repo) = Resolve.WorkspaceAndRepo(credentials, request.Workspace, request.Repository, "Error: Workspace and repository required."); - var path = NormalizePath(request.Path); + var path = EndpointPath.EscapeSegments(request.Path); var refSegment = Uri.EscapeDataString(request.Ref); var endpoint = $"/repositories/{ws}/{repo}/src/{refSegment}/{path}"; @@ -45,17 +45,4 @@ public async Task HandleAsync(LsSourceRequest request, CancellationToken }; } - private static string NormalizePath(string? path) - { - if (string.IsNullOrEmpty(path)) return string.Empty; - var trimmed = path.TrimStart('/'); - // Escape segments individually so '/' separators survive but - // characters inside segments (spaces, %, etc.) get encoded. - var parts = trimmed.Split('/'); - for (var i = 0; i < parts.Length; i++) - { - parts[i] = Uri.EscapeDataString(parts[i]); - } - return string.Join('/', parts); - } } diff --git a/src/Bbx/Features/Users/ListUserEmails/ListUserEmailsHandler.cs b/src/Bbx/Features/Users/ListUserEmails/ListUserEmailsHandler.cs index 4e5e39a..a0d6e31 100644 --- a/src/Bbx/Features/Users/ListUserEmails/ListUserEmailsHandler.cs +++ b/src/Bbx/Features/Users/ListUserEmails/ListUserEmailsHandler.cs @@ -4,12 +4,10 @@ namespace Bbx.Features.Users.ListUserEmails; -public sealed class ListUserEmailsHandler(BitbucketClient client, CredentialManager credentials) +public sealed class ListUserEmailsHandler(BitbucketClient client) { public async Task HandleAsync(ListUserEmailsRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); var emails = new List(); var count = 0; diff --git a/src/Bbx/Features/Users/ListUserRepositoryPermissions/ListUserRepositoryPermissionsHandler.cs b/src/Bbx/Features/Users/ListUserRepositoryPermissions/ListUserRepositoryPermissionsHandler.cs index 5bc7894..a17a81d 100644 --- a/src/Bbx/Features/Users/ListUserRepositoryPermissions/ListUserRepositoryPermissionsHandler.cs +++ b/src/Bbx/Features/Users/ListUserRepositoryPermissions/ListUserRepositoryPermissionsHandler.cs @@ -4,12 +4,10 @@ namespace Bbx.Features.Users.ListUserRepositoryPermissions; -public sealed class ListUserRepositoryPermissionsHandler(BitbucketClient client, CredentialManager credentials) +public sealed class ListUserRepositoryPermissionsHandler(BitbucketClient client) { public async Task HandleAsync(ListUserRepositoryPermissionsRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); var perms = new List(); var count = 0; diff --git a/src/Bbx/Features/Users/ListUserWorkspacePermissions/ListUserWorkspacePermissionsHandler.cs b/src/Bbx/Features/Users/ListUserWorkspacePermissions/ListUserWorkspacePermissionsHandler.cs index fd0248f..3b1b77b 100644 --- a/src/Bbx/Features/Users/ListUserWorkspacePermissions/ListUserWorkspacePermissionsHandler.cs +++ b/src/Bbx/Features/Users/ListUserWorkspacePermissions/ListUserWorkspacePermissionsHandler.cs @@ -4,12 +4,10 @@ namespace Bbx.Features.Users.ListUserWorkspacePermissions; -public sealed class ListUserWorkspacePermissionsHandler(BitbucketClient client, CredentialManager credentials) +public sealed class ListUserWorkspacePermissionsHandler(BitbucketClient client) { public async Task HandleAsync(ListUserWorkspacePermissionsRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); var perms = new List(); var count = 0; diff --git a/src/Bbx/Features/Users/SshKeys/AddSshKey/AddSshKeyHandler.cs b/src/Bbx/Features/Users/SshKeys/AddSshKey/AddSshKeyHandler.cs index 9650591..5edc88c 100644 --- a/src/Bbx/Features/Users/SshKeys/AddSshKey/AddSshKeyHandler.cs +++ b/src/Bbx/Features/Users/SshKeys/AddSshKey/AddSshKeyHandler.cs @@ -6,12 +6,10 @@ namespace Bbx.Features.Users.SshKeys.AddSshKey; -public sealed class AddSshKeyHandler(BitbucketClient client, CredentialManager credentials) +public sealed class AddSshKeyHandler(BitbucketClient client) { public async Task HandleAsync(AddSshKeyRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); if (string.IsNullOrWhiteSpace(request.Key)) throw new BbxUserException("Error: --key (the public SSH key body) is required."); diff --git a/src/Bbx/Features/Users/SshKeys/DeleteSshKey/DeleteSshKeyHandler.cs b/src/Bbx/Features/Users/SshKeys/DeleteSshKey/DeleteSshKeyHandler.cs index b7ac916..793440f 100644 --- a/src/Bbx/Features/Users/SshKeys/DeleteSshKey/DeleteSshKeyHandler.cs +++ b/src/Bbx/Features/Users/SshKeys/DeleteSshKey/DeleteSshKeyHandler.cs @@ -4,12 +4,10 @@ namespace Bbx.Features.Users.SshKeys.DeleteSshKey; -public sealed class DeleteSshKeyHandler(BitbucketClient client, CredentialManager credentials) +public sealed class DeleteSshKeyHandler(BitbucketClient client) { public async Task HandleAsync(DeleteSshKeyRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); if (string.IsNullOrEmpty(request.KeyId)) throw new BbxUserException("Error: is required."); diff --git a/src/Bbx/Features/Users/SshKeys/ListSshKeys/ListSshKeysHandler.cs b/src/Bbx/Features/Users/SshKeys/ListSshKeys/ListSshKeysHandler.cs index dd4922d..f081ac4 100644 --- a/src/Bbx/Features/Users/SshKeys/ListSshKeys/ListSshKeysHandler.cs +++ b/src/Bbx/Features/Users/SshKeys/ListSshKeys/ListSshKeysHandler.cs @@ -5,12 +5,10 @@ namespace Bbx.Features.Users.SshKeys.ListSshKeys; -public sealed class ListSshKeysHandler(BitbucketClient client, CredentialManager credentials) +public sealed class ListSshKeysHandler(BitbucketClient client) { public async Task HandleAsync(ListSshKeysRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); var user = await UserSelector.ResolveAsync(client, request.SelectedUser, ct); var keys = new List(); diff --git a/src/Bbx/Features/Users/SshKeys/ViewSshKey/ViewSshKeyHandler.cs b/src/Bbx/Features/Users/SshKeys/ViewSshKey/ViewSshKeyHandler.cs index 19ae0d0..52607a9 100644 --- a/src/Bbx/Features/Users/SshKeys/ViewSshKey/ViewSshKeyHandler.cs +++ b/src/Bbx/Features/Users/SshKeys/ViewSshKey/ViewSshKeyHandler.cs @@ -6,12 +6,10 @@ namespace Bbx.Features.Users.SshKeys.ViewSshKey; -public sealed class ViewSshKeyHandler(BitbucketClient client, CredentialManager credentials) +public sealed class ViewSshKeyHandler(BitbucketClient client) { public async Task HandleAsync(ViewSshKeyRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); if (string.IsNullOrEmpty(request.KeyId)) throw new BbxUserException("Error: is required."); diff --git a/src/Bbx/Features/Users/ViewUser/ViewUserHandler.cs b/src/Bbx/Features/Users/ViewUser/ViewUserHandler.cs index 133e6ec..6c41fcd 100644 --- a/src/Bbx/Features/Users/ViewUser/ViewUserHandler.cs +++ b/src/Bbx/Features/Users/ViewUser/ViewUserHandler.cs @@ -4,12 +4,10 @@ namespace Bbx.Features.Users.ViewUser; -public sealed class ViewUserHandler(BitbucketClient client, CredentialManager credentials) +public sealed class ViewUserHandler(BitbucketClient client) { public async Task HandleAsync(ViewUserRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); // No selector means "show me". Bitbucket exposes that as /2.0/user, // a different route from /2.0/users/{selected_user}. if (string.IsNullOrEmpty(request.SelectedUser) diff --git a/src/Bbx/Features/Workspaces/ListWorkspaceMembers/ListWorkspaceMembersHandler.cs b/src/Bbx/Features/Workspaces/ListWorkspaceMembers/ListWorkspaceMembersHandler.cs index c834650..5c168d2 100644 --- a/src/Bbx/Features/Workspaces/ListWorkspaceMembers/ListWorkspaceMembersHandler.cs +++ b/src/Bbx/Features/Workspaces/ListWorkspaceMembers/ListWorkspaceMembersHandler.cs @@ -9,8 +9,6 @@ public sealed class ListWorkspaceMembersHandler(BitbucketClient client, Credenti { public async Task HandleAsync(ListWorkspaceMembersRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); var workspace = Resolve.Workspace(credentials, request.Workspace, "Error: Workspace required. Use --workspace or set default with 'bbx auth set-workspace'."); @@ -18,7 +16,7 @@ public async Task HandleAsync(ListWorkspaceMembersRequest request, Cance var members = new List(); await foreach (var member in client.GetPaginatedAsync($"workspaces/{workspace}/members", ct)) { - var user = member.TryGetProperty("user", out var u) ? u : member; + var user = member.TryGetObject("user", out var u) ? u : member; members.Add(new { diff --git a/src/Bbx/Features/Workspaces/ListWorkspacePermissions/ListWorkspacePermissionsHandler.cs b/src/Bbx/Features/Workspaces/ListWorkspacePermissions/ListWorkspacePermissionsHandler.cs index 01beda4..d6a0563 100644 --- a/src/Bbx/Features/Workspaces/ListWorkspacePermissions/ListWorkspacePermissionsHandler.cs +++ b/src/Bbx/Features/Workspaces/ListWorkspacePermissions/ListWorkspacePermissionsHandler.cs @@ -9,8 +9,6 @@ public sealed class ListWorkspacePermissionsHandler(BitbucketClient client, Cred { public async Task HandleAsync(ListWorkspacePermissionsRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); var workspace = Resolve.Workspace(credentials, request.Workspace, "Error: Workspace required. Use --workspace or set default with 'bbx auth set-workspace'."); @@ -21,7 +19,7 @@ public async Task HandleAsync(ListWorkspacePermissionsRequest request, C permissions.Add(new { permission = perm.TryGetProperty("permission", out var p) ? p.GetString() : null, - user = perm.TryGetProperty("user", out var u) ? (object)new + user = perm.TryGetObject("user", out var u) ? (object)new { display_name = u.TryGetProperty("display_name", out var d) ? d.GetString() : null, username = u.TryGetProperty("username", out var un) ? un.GetString() : null, diff --git a/src/Bbx/Features/Workspaces/ListWorkspaces/ListWorkspacesHandler.cs b/src/Bbx/Features/Workspaces/ListWorkspaces/ListWorkspacesHandler.cs index 48c738e..85e8087 100644 --- a/src/Bbx/Features/Workspaces/ListWorkspaces/ListWorkspacesHandler.cs +++ b/src/Bbx/Features/Workspaces/ListWorkspaces/ListWorkspacesHandler.cs @@ -4,12 +4,10 @@ namespace Bbx.Features.Workspaces.ListWorkspaces; -public sealed class ListWorkspacesHandler(BitbucketClient client, CredentialManager credentials) +public sealed class ListWorkspacesHandler(BitbucketClient client) { public async Task HandleAsync(ListWorkspacesRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); var endpoint = "workspaces"; if (!string.IsNullOrEmpty(request.Role)) diff --git a/src/Bbx/Features/Workspaces/ViewWorkspace/ViewWorkspaceHandler.cs b/src/Bbx/Features/Workspaces/ViewWorkspace/ViewWorkspaceHandler.cs index af07f9e..1e4ae54 100644 --- a/src/Bbx/Features/Workspaces/ViewWorkspace/ViewWorkspaceHandler.cs +++ b/src/Bbx/Features/Workspaces/ViewWorkspace/ViewWorkspaceHandler.cs @@ -9,8 +9,6 @@ public sealed class ViewWorkspaceHandler(BitbucketClient client, CredentialManag { public async Task HandleAsync(ViewWorkspaceRequest request, CancellationToken ct) { - if (!credentials.HasCredentials()) - throw new BbxUserException("Error: Not authenticated. Run 'bbx auth login' first."); var workspace = Resolve.Workspace(credentials, request.Workspace, "Error: Workspace required. Provide as argument or set default with 'bbx auth set-workspace'."); @@ -24,7 +22,7 @@ public async Task HandleAsync(ViewWorkspaceRequest request, Cancellation uuid = ws.TryGetProperty("uuid", out var u) ? u.GetString() : null, is_private = ws.TryGetProperty("is_private", out var p) && p.GetBoolean(), created_on = ws.TryGetProperty("created_on", out var c) ? c.GetString() : null, - links = ws.TryGetProperty("links", out var l) ? (object)new + links = ws.TryGetObject("links", out var l) ? (object)new { html = l.TryGetObject("html", out var h) && h.TryGetProperty("href", out var href) ? href.GetString() : null, avatar = l.TryGetObject("avatar", out var a) && a.TryGetProperty("href", out var ahref) ? ahref.GetString() : null, diff --git a/src/Bbx/Program.cs b/src/Bbx/Program.cs index 09cb4bb..b59e9fc 100644 --- a/src/Bbx/Program.cs +++ b/src/Bbx/Program.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using System.CommandLine.Invocation; using System.Text; using Bbx.Commands; using Bbx.Composition; @@ -13,7 +14,12 @@ public class Program public static async Task Main(string[] args) { Console.OutputEncoding = Encoding.UTF8; + return await RunAsync(args, ServiceRegistration.Build()); + } + internal static async Task RunAsync( + string[] args, IServiceProvider services, CancellationToken cancellationToken = default) + { // `--json-compact` is a global formatting toggle. It's stripped here // before System.CommandLine sees the args so every group inherits // it transparently, the same effect as setting BBX_JSON_COMPACT=1. @@ -25,7 +31,7 @@ public static async Task Main(string[] args) if (compactFromArg) args = args.Where(a => a != "--json-compact").ToArray(); - Services = ServiceRegistration.Build(); + Services = services; var rootCommand = new RootCommand("Bitbucket Cloud CLI for LLM integration"); // Registered for --help discoverability only; the option is already @@ -61,23 +67,45 @@ public static async Task Main(string[] args) int exitCode; try { - exitCode = await parseResult.InvokeAsync(); + // Ctrl+C, SIGINT and SIGTERM are already handled: System.CommandLine + // links this token to its own source and cancels it from + // ProcessTerminationHandler, so the token CommandBinding hands to a + // handler is always cancelable. Do not add a Console.CancelKeyPress + // hook here; on .NET 7+ the library registers for the POSIX signals + // directly and a second subscriber only competes with it. The token + // below is for a caller that drives RunAsync itself. + exitCode = await parseResult.InvokeAsync(new InvocationConfiguration + { + EnableDefaultExceptionHandler = false, + }, cancellationToken); + } + catch (BbxUserException ex) + { + Console.Error.WriteLine(ex.Message); + return 1; + } + // An HttpClient timeout also surfaces as an OperationCanceledException, + // but carries a TimeoutException inside; that is a failure, not a + // cancellation, so it falls through to the handler below. + catch (OperationCanceledException ex) when (ex.InnerException is not TimeoutException) + { + // 130 is the shell convention for a run stopped by SIGINT, and what + // System.CommandLine returns when a handler ignores the token and + // gets forced out. A cancelled run is not an error, so say nothing. + return 130; + } + catch (HttpRequestException ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; } catch (Exception ex) { - // CommandRunner catches the expected failures. Anything else that - // escapes a handler would otherwise print a full stack trace; - // System.CommandLine 2.0 dropped the built-in exception handler, so - // report the message here instead. + // Do not print a full stack trace for an unexpected CLI failure. Console.Error.WriteLine($"Error: {ex.Message}"); return 1; } - // A value returned from Main overrides Environment.ExitCode, and the - // invocation reports 0 whenever a handler returned normally. Handlers - // catch their own errors and set Environment.ExitCode, so returning the - // invocation's result alone made every failed command exit 0 and look - // successful to a script or an agent. - return exitCode != 0 ? exitCode : Environment.ExitCode; + return exitCode; } } diff --git a/tests/Bbx.Tests/Api/BitbucketClientTests.cs b/tests/Bbx.Tests/Api/BitbucketClientTests.cs index 04d5714..0f111b2 100644 --- a/tests/Bbx.Tests/Api/BitbucketClientTests.cs +++ b/tests/Bbx.Tests/Api/BitbucketClientTests.cs @@ -270,6 +270,69 @@ public async Task Redirects_to_another_origin_do_not_carry_credentials() handler.Calls[1].Headers.Authorization.Should().BeNull(); } + [Fact] + public async Task Redirects_never_restore_credentials_after_leaving_the_api_origin() + { + var handler = new FakeHttpMessageHandler(); + handler.EnqueueResponder(_ => + { + var redirect = new HttpResponseMessage(HttpStatusCode.Found); + redirect.Headers.Location = new Uri("https://downloads.example.com/first"); + return redirect; + }); + handler.EnqueueResponder(_ => + { + var redirect = new HttpResponseMessage(HttpStatusCode.Found); + redirect.Headers.Location = new Uri("https://downloads.example.com/second"); + return redirect; + }); + handler.Enqueue(HttpStatusCode.OK, "binary", "application/octet-stream"); + + using var http = TestHttpClientFactory.Create(handler); + var client = new BitbucketClient(http, new BasicAuthProvider("jane@example.com", "token")); + + await client.GetByteArrayAsync( + "repositories/ws/repo/downloads/artifact.zip", TestContext.Current.CancellationToken); + + handler.Calls.Should().HaveCount(3); + handler.Calls[1].Headers.Authorization.Should().BeNull(); + handler.Calls[2].Headers.Authorization.Should().BeNull(); + } + + [Fact] + public async Task Absolute_urls_outside_the_api_origin_do_not_receive_credentials() + { + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpStatusCode.OK, "{}"); + + using var http = TestHttpClientFactory.Create(handler); + var client = new BitbucketClient(http, new BasicAuthProvider("jane@example.com", "token")); + + await client.GetAsync( + "https://example.com/page-two", TestContext.Current.CancellationToken); + + handler.Calls.Single().Headers.Authorization.Should().BeNull(); + } + + [Fact] + public async Task CopyToAsync_streams_non_json_content_to_the_destination() + { + var handler = new FakeHttpMessageHandler(); + handler.EnqueueResponder(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent([0, 1, 2, 3, 255]), + }); + + using var http = TestHttpClientFactory.Create(handler); + var client = new BitbucketClient(http, new NullAuthProvider()); + await using var destination = new MemoryStream(); + + await client.CopyToAsync("downloads/file", destination, TestContext.Current.CancellationToken); + + destination.ToArray().Should().Equal(0, 1, 2, 3, 255); + handler.Calls.Single().Headers.Accept.Select(a => a.MediaType).Should().Equal("*/*"); + } + [Fact] public async Task Redirect_loops_stop_rather_than_hanging() { diff --git a/tests/Bbx.Tests/Auth/FileCredentialStoreTests.cs b/tests/Bbx.Tests/Auth/FileCredentialStoreTests.cs new file mode 100644 index 0000000..c1d7b79 --- /dev/null +++ b/tests/Bbx.Tests/Auth/FileCredentialStoreTests.cs @@ -0,0 +1,51 @@ +using AwesomeAssertions; +using Bbx.Auth; + +namespace Bbx.Tests.Auth; + +public sealed class FileCredentialStoreTests : IDisposable +{ + private readonly string _directory = Path.Combine( + Path.GetTempPath(), $"bbx-credential-tests-{Guid.NewGuid():N}"); + + [Fact] + public void Save_writes_a_private_file_and_leaves_no_temporary_file() + { + var path = Path.Combine(_directory, "config.json"); + var store = new FileCredentialStore(_directory, path); + + store.Save(new BbxConfig + { + Username = "jane@example.com", + ApiToken = "secret", + }); + + store.Load().ApiToken.Should().Be("secret"); + Directory.GetFiles(_directory).Should().Equal(path); + if (!OperatingSystem.IsWindows()) + { + File.GetUnixFileMode(path).Should().Be( + UnixFileMode.UserRead | UnixFileMode.UserWrite); + File.GetUnixFileMode(_directory).Should().Be( + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + } + + [Fact] + public void Load_reports_a_corrupt_config_instead_of_treating_it_as_logged_out() + { + Directory.CreateDirectory(_directory); + var path = Path.Combine(_directory, "config.json"); + File.WriteAllText(path, "{not-json"); + var store = new FileCredentialStore(_directory, path); + + var act = store.Load; + + act.Should().Throw().WithMessage("*Could not read bbx config*"); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); + } +} diff --git a/tests/Bbx.Tests/Commands/CommandBindingTests.cs b/tests/Bbx.Tests/Commands/CommandBindingTests.cs new file mode 100644 index 0000000..dd7d340 --- /dev/null +++ b/tests/Bbx.Tests/Commands/CommandBindingTests.cs @@ -0,0 +1,62 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using AwesomeAssertions; +using Bbx.Commands; + +namespace Bbx.Tests.Commands; + +public class CommandBindingTests +{ + [Fact] + public async Task System_command_line_cancellation_reaches_bound_handlers() + { + var command = new Command("test"); + var seen = CancellationToken.None; + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + command.SetHandler(async () => + { + seen = CommandBinding.CancellationToken; + entered.SetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, seen); + }); + using var source = new CancellationTokenSource(); + + var invocation = command.Parse([]).InvokeAsync(new InvocationConfiguration(), source.Token); + await entered.Task; + source.Cancel(); + try + { + await invocation; + } + catch (OperationCanceledException) + { + } + + seen.CanBeCanceled.Should().BeTrue(); + seen.IsCancellationRequested.Should().BeTrue(); + } + + [Fact] + public async Task Bound_handlers_get_a_cancelable_token_even_with_no_token_supplied() + { + var command = new Command("test"); + var seen = CancellationToken.None; + command.SetHandler(() => + { + seen = CommandBinding.CancellationToken; + return Task.CompletedTask; + }); + + // Passing no token is the point of the test, so xUnit1051 is off here. +#pragma warning disable xUnit1051 + await command.Parse([]).InvokeAsync(new InvocationConfiguration()); +#pragma warning restore xUnit1051 + + // System.CommandLine links whatever token it is handed to a source of + // its own and cancels that from ProcessTerminationHandler, so Ctrl+C, + // SIGINT and SIGTERM reach handlers without Program hooking any signal + // itself. A false here means the CLI has stopped answering Ctrl+C. + seen.CanBeCanceled.Should().BeTrue(); + seen.IsCancellationRequested.Should().BeFalse(); + } +} diff --git a/tests/Bbx.Tests/Commands/CommandRunnerTests.cs b/tests/Bbx.Tests/Commands/CommandRunnerTests.cs index 9a39687..ab5a599 100644 --- a/tests/Bbx.Tests/Commands/CommandRunnerTests.cs +++ b/tests/Bbx.Tests/Commands/CommandRunnerTests.cs @@ -9,7 +9,6 @@ namespace Bbx.Tests.Commands; [Collection("Console")] public class CommandRunnerTests : IDisposable { - private readonly int _originalExitCode = Environment.ExitCode; private readonly IServiceProvider? _originalServices = Program.Services; public CommandRunnerTests() @@ -27,60 +26,43 @@ public CommandRunnerTests() public void Dispose() { - Environment.ExitCode = _originalExitCode; Program.Services = _originalServices!; } - // Regression: handlers catch their own errors and set Environment.ExitCode, - // but Program.Main returned InvokeAsync's result, which overrode it. Every - // failed command exited 0 and read as success to a script. [Fact] - public async Task RunJsonAsync_sets_a_failing_exit_code_on_api_errors() + public async Task RunJsonAsync_leaves_api_errors_for_the_program_boundary() { - Environment.ExitCode = 0; + var act = async () => await CommandRunner.RunJsonAsync( + () => throw new HttpRequestException("Repository not found")); - var (stdout, stderr) = await CaptureConsole.RunAsync(() => - CommandRunner.RunJsonAsync(() => throw new HttpRequestException("Repository not found"))); - - Environment.ExitCode.Should().Be(1); - stderr.Should().Contain("Repository not found"); - stdout.Should().BeEmpty(); + (await act.Should().ThrowAsync()).WithMessage("Repository not found"); } [Fact] - public async Task RunJsonAsync_sets_a_failing_exit_code_on_user_errors() + public async Task RunJsonAsync_leaves_user_errors_for_the_program_boundary() { - Environment.ExitCode = 0; - - var (_, stderr) = await CaptureConsole.RunAsync(() => - CommandRunner.RunJsonAsync(() => throw new BbxUserException("Error: repo required."))); + var act = async () => await CommandRunner.RunJsonAsync( + () => throw new BbxUserException("Error: repo required.")); - Environment.ExitCode.Should().Be(1); - stderr.Should().Contain("repo required"); + (await act.Should().ThrowAsync()).WithMessage("*repo required*"); } [Fact] public async Task RunJsonAsync_leaves_the_exit_code_alone_on_success() { - Environment.ExitCode = 0; - var (stdout, stderr) = await CaptureConsole.RunAsync(() => CommandRunner.RunJsonAsync(() => Task.FromResult(new { ok = true }))); - Environment.ExitCode.Should().Be(0); stdout.Should().Contain("\"ok\""); stderr.Should().BeEmpty(); } [Fact] - public async Task RunRawAsync_sets_a_failing_exit_code_on_api_errors() + public async Task RunRawAsync_leaves_api_errors_for_the_program_boundary() { - Environment.ExitCode = 0; - - var (_, stderr) = await CaptureConsole.RunAsync(() => - CommandRunner.RunRawAsync(() => throw new HttpRequestException("HTTP 406 Not Acceptable"))); + var act = async () => await CommandRunner.RunRawAsync( + () => throw new HttpRequestException("HTTP 406 Not Acceptable")); - Environment.ExitCode.Should().Be(1); - stderr.Should().Contain("406"); + (await act.Should().ThrowAsync()).WithMessage("*406*"); } } diff --git a/tests/Bbx.Tests/Commands/ConfirmationOutputTests.cs b/tests/Bbx.Tests/Commands/ConfirmationOutputTests.cs new file mode 100644 index 0000000..f014c1c --- /dev/null +++ b/tests/Bbx.Tests/Commands/ConfirmationOutputTests.cs @@ -0,0 +1,45 @@ +using AwesomeAssertions; +using Bbx.Commands; +using Bbx.Tests.TestKit; +using Microsoft.Extensions.DependencyInjection; + +namespace Bbx.Tests.Commands; + +[Collection("Console")] +public class ConfirmationOutputTests +{ + [Theory] + [InlineData("repo")] + [InlineData("branch")] + [InlineData("issue")] + public async Task Destructive_prompts_do_not_write_to_stdout(string group) + { + var services = new ServiceCollection().BuildServiceProvider(); + var command = group switch + { + "repo" => RepoCommand.Create(services), + "branch" => BranchCommand.Create(services), + _ => IssueCommand.Create(services), + }; + var args = group switch + { + "repo" => new[] { "delete", "ws/repo" }, + "branch" => new[] { "delete", "feature" }, + _ => new[] { "delete", "1" }, + }; + var originalInput = Console.In; + Console.SetIn(new StringReader("n\n")); + try + { + var (stdout, stderr) = await CaptureConsole.RunAsync( + () => command.Parse(args).InvokeAsync()); + + stdout.Should().BeEmpty(); + stderr.Should().Contain("[y/N]").And.Contain("Cancelled."); + } + finally + { + Console.SetIn(originalInput); + } + } +} diff --git a/tests/Bbx.Tests/Features/Auth/AuthStatusHandlerTests.cs b/tests/Bbx.Tests/Features/Auth/AuthStatusHandlerTests.cs index 2793cdd..61a40de 100644 --- a/tests/Bbx.Tests/Features/Auth/AuthStatusHandlerTests.cs +++ b/tests/Bbx.Tests/Features/Auth/AuthStatusHandlerTests.cs @@ -28,10 +28,10 @@ public async Task Stale_oauth_config_reads_as_not_authenticated() using var client = new BitbucketClient(http, new NullAuthProvider()); var handler = new AuthStatusHandler(client, creds); - var (stdout, _) = await CaptureConsole.RunAsync(() => - handler.HandleAsync(new AuthStatusRequest(), TestContext.Current.CancellationToken)); + var act = async () => await handler.HandleAsync( + new AuthStatusRequest(), TestContext.Current.CancellationToken); - stdout.Should().Contain("Not authenticated"); + (await act.Should().ThrowAsync()).WithMessage("*Not authenticated*"); fakeHttp.Calls.Should().BeEmpty(); } @@ -53,11 +53,11 @@ public async Task ApiToken_status_reports_api_token_method() using var client = new BitbucketClient(http, new NullAuthProvider()); var handler = new AuthStatusHandler(client, creds); - var (stdout, _) = await CaptureConsole.RunAsync(() => - handler.HandleAsync(new AuthStatusRequest(), TestContext.Current.CancellationToken)); + var output = await handler.HandleAsync( + new AuthStatusRequest(), TestContext.Current.CancellationToken); - stdout.Should().Contain("Auth method: api-token"); - stdout.Should().NotContain("Expires at:"); + output.Should().Contain("Auth method: api-token"); + output.Should().NotContain("Expires at:"); } [Fact] @@ -70,10 +70,30 @@ public async Task Reports_not_authenticated_when_no_credentials() using var client = new BitbucketClient(http, new NullAuthProvider()); var handler = new AuthStatusHandler(client, creds); - var (stdout, _) = await CaptureConsole.RunAsync(() => - handler.HandleAsync(new AuthStatusRequest(), TestContext.Current.CancellationToken)); + var act = async () => await handler.HandleAsync( + new AuthStatusRequest(), TestContext.Current.CancellationToken); - stdout.Should().Contain("Not authenticated"); + (await act.Should().ThrowAsync()).WithMessage("*Not authenticated*"); fakeHttp.Calls.Should().BeEmpty(); } + + [Fact] + public async Task Api_failure_is_not_reported_as_success() + { + var store = new InMemoryCredentialStore(new BbxConfig + { + Username = "jane@example.com", + ApiToken = "expired", + }); + var fakeHttp = new FakeHttpMessageHandler(); + fakeHttp.Enqueue(HttpStatusCode.Unauthorized, "{}"); + using var http = TestHttpClientFactory.Create(fakeHttp); + using var client = new BitbucketClient(http, new NullAuthProvider()); + var handler = new AuthStatusHandler(client, new CredentialManager(store)); + + var act = async () => await handler.HandleAsync( + new AuthStatusRequest(), TestContext.Current.CancellationToken); + + await act.Should().ThrowAsync(); + } } diff --git a/tests/Bbx.Tests/Features/Auth/AuthTokenHandlerTests.cs b/tests/Bbx.Tests/Features/Auth/AuthTokenHandlerTests.cs index 5126318..e4fdafa 100644 --- a/tests/Bbx.Tests/Features/Auth/AuthTokenHandlerTests.cs +++ b/tests/Bbx.Tests/Features/Auth/AuthTokenHandlerTests.cs @@ -6,12 +6,8 @@ namespace Bbx.Tests.Features.Auth; [Collection("Console")] -public class AuthTokenHandlerTests : IDisposable +public class AuthTokenHandlerTests { - private readonly int _originalExitCode = Environment.ExitCode; - - public void Dispose() => Environment.ExitCode = _originalExitCode; - // Printed as email:token so it can be piped straight into curl -u. [Fact] public async Task Prints_the_basic_auth_pair() @@ -23,24 +19,20 @@ public async Task Prints_the_basic_auth_pair() ApiToken = "ATATTsecret", }))); - var (stdout, stderr) = await CaptureConsole.RunAsync(() => - handler.HandleAsync(new AuthTokenRequest(), TestContext.Current.CancellationToken)); + var output = await handler.HandleAsync( + new AuthTokenRequest(), TestContext.Current.CancellationToken); - stdout.Trim().Should().Be("jane@example.com:ATATTsecret"); - stderr.Should().BeEmpty(); + output.Should().Be("jane@example.com:ATATTsecret"); } [Fact] public async Task Reports_not_authenticated_and_fails_when_no_token_is_stored() { - Environment.ExitCode = 0; var handler = new AuthTokenHandler(new CredentialManager(new InMemoryCredentialStore())); - var (stdout, stderr) = await CaptureConsole.RunAsync(() => - handler.HandleAsync(new AuthTokenRequest(), TestContext.Current.CancellationToken)); + var act = async () => await handler.HandleAsync( + new AuthTokenRequest(), TestContext.Current.CancellationToken); - stdout.Should().BeEmpty(); - stderr.Should().Contain("Not authenticated"); - Environment.ExitCode.Should().Be(1); + (await act.Should().ThrowAsync()).WithMessage("*Not authenticated*"); } } diff --git a/tests/Bbx.Tests/Features/Downloads/GetDownloadHandlerTests.cs b/tests/Bbx.Tests/Features/Downloads/GetDownloadHandlerTests.cs index 9b2539c..e097e15 100644 --- a/tests/Bbx.Tests/Features/Downloads/GetDownloadHandlerTests.cs +++ b/tests/Bbx.Tests/Features/Downloads/GetDownloadHandlerTests.cs @@ -22,10 +22,13 @@ public async Task HandleAsync_returns_raw_bytes_from_downloads_path() new InMemoryCredentialStore(new BbxConfig { DefaultWorkspace = "ws", Username = "u", ApiToken = "t" })); var handler = new GetDownloadHandler(client, credentials); - var bytes = await handler.HandleAsync( - new GetDownloadRequest("ws", "myrepo", "release.bin", null), TestContext.Current.CancellationToken); + await using var destination = new MemoryStream(); + await handler.HandleAsync( + new GetDownloadRequest("ws", "myrepo", "release.bin", null), + destination, + TestContext.Current.CancellationToken); - bytes.Should().Equal(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }); + destination.ToArray().Should().Equal(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }); http.Calls.Single().RequestUri!.AbsoluteUri .Should().Be("https://api.bitbucket.org/2.0/repositories/ws/myrepo/downloads/release.bin"); } @@ -40,7 +43,9 @@ public async Task HandleAsync_throws_when_filename_empty() var handler = new GetDownloadHandler(client, credentials); var act = async () => await handler.HandleAsync( - new GetDownloadRequest("ws", "myrepo", "", null), TestContext.Current.CancellationToken); + new GetDownloadRequest("ws", "myrepo", "", null), + Stream.Null, + TestContext.Current.CancellationToken); (await act.Should().ThrowAsync()) .WithMessage("**"); diff --git a/tests/Bbx.Tests/Features/NullNestedObjectHandlerTests.cs b/tests/Bbx.Tests/Features/NullNestedObjectHandlerTests.cs new file mode 100644 index 0000000..e1bd985 --- /dev/null +++ b/tests/Bbx.Tests/Features/NullNestedObjectHandlerTests.cs @@ -0,0 +1,69 @@ +using System.Net; +using AwesomeAssertions; +using Bbx.Api; +using Bbx.Auth; +using Bbx.Features.Pipelines.ViewDeploymentEnvironment; +using Bbx.Features.Repos.RepoPermissions; +using Bbx.Features.Snippets.ViewSnippet; +using Bbx.Tests.TestKit; + +namespace Bbx.Tests.Features; + +public class NullNestedObjectHandlerTests +{ + [Fact] + public async Task ViewSnippet_accepts_null_owner_and_creator() + { + var http = new FakeHttpMessageHandler(); + http.Enqueue(HttpStatusCode.OK, + """{"id":"s1","owner":null,"creator":null}"""); + var handler = new ViewSnippetHandler(Client(http), Credentials()); + + var result = await handler.HandleAsync( + new ViewSnippetRequest("s1", "ws"), TestContext.Current.CancellationToken); + var json = System.Text.Json.JsonSerializer.Serialize(result); + + json.Should().Contain("\"owner\":null").And.Contain("\"creator\":null"); + } + + [Fact] + public async Task ViewDeploymentEnvironment_accepts_null_nested_objects() + { + var http = new FakeHttpMessageHandler(); + http.Enqueue(HttpStatusCode.OK, + """{"uuid":"e1","environment_type":null,"lock":null}"""); + var handler = new ViewDeploymentEnvironmentHandler(Client(http), Credentials()); + + var result = await handler.HandleAsync( + new ViewDeploymentEnvironmentRequest("ws", "repo", "test"), + TestContext.Current.CancellationToken); + var json = System.Text.Json.JsonSerializer.Serialize(result); + + json.Should().Contain("\"environment_type\":null").And.Contain("\"lock_\":null"); + } + + [Fact] + public async Task RepoPermissions_accepts_a_null_user() + { + var http = new FakeHttpMessageHandler(); + http.Enqueue(HttpStatusCode.OK, + """{"values":[{"user":null,"permission":"read"}]}"""); + var handler = new RepoPermissionsHandler(Client(http), Credentials()); + + var result = await handler.HandleAsync( + new RepoPermissionsRequest("ws", "repo"), TestContext.Current.CancellationToken); + var json = System.Text.Json.JsonSerializer.Serialize(result); + + json.Should().Contain("\"user\":null"); + } + + private static BitbucketClient Client(FakeHttpMessageHandler http) => + new(TestHttpClientFactory.Create(http), new NullAuthProvider()); + + private static CredentialManager Credentials() => new(new InMemoryCredentialStore(new BbxConfig + { + DefaultWorkspace = "ws", + Username = "u", + ApiToken = "t", + })); +} diff --git a/tests/Bbx.Tests/Features/Pipelines/PipelineFormatTests.cs b/tests/Bbx.Tests/Features/Pipelines/PipelineFormatTests.cs new file mode 100644 index 0000000..facd64c --- /dev/null +++ b/tests/Bbx.Tests/Features/Pipelines/PipelineFormatTests.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using AwesomeAssertions; +using Bbx.Features.Pipelines; + +namespace Bbx.Tests.Features.Pipelines; + +public class PipelineFormatTests +{ + [Fact] + public void Pipeline_accepts_null_result_and_target_members() + { + var pipeline = JsonDocument.Parse(""" + { + "uuid":"{p1}", + "build_number":7, + "state":{"name":"IN_PROGRESS","result":null}, + "target":{"ref_type":"branch","ref_name":"main","commit":null}, + "trigger":null, + "duration_in_seconds":null + } + """).RootElement; + + var result = PipelineFormat.Pipeline(pipeline); + + result.State!.Name.Should().Be("IN_PROGRESS"); + result.State.Result.Should().BeNull(); + result.Target!.Commit.Should().BeNull(); + result.Trigger.Should().BeNull(); + result.DurationInSeconds.Should().BeNull(); + } + + [Fact] + public void PipelineDetailed_keeps_the_existing_flat_json_shape() + { + var pipeline = JsonDocument.Parse(""" + {"uuid":"{p1}","build_number":7,"state":null,"target":null, + "creator":null,"repository":null,"links":null} + """).RootElement; + + var json = JsonSerializer.Serialize(PipelineFormat.PipelineDetailed(pipeline), + Bbx.Composition.JsonOptions.Current); + + json.Should().Contain("\"uuid\": \"{p1}\"") + .And.Contain("\"build_number\": 7") + .And.NotContain("\"pipeline\""); + } + + [Fact] + public void Step_accepts_null_state() + { + var step = JsonDocument.Parse("""{"uuid":"{s1}","state":null}""").RootElement; + + var result = PipelineFormat.Step(step); + + result.Uuid.Should().Be("{s1}"); + result.State.Should().BeNull(); + } +} diff --git a/tests/Bbx.Tests/Features/Snippets/SnippetFilesHandlerTests.cs b/tests/Bbx.Tests/Features/Snippets/SnippetFilesHandlerTests.cs new file mode 100644 index 0000000..5736099 --- /dev/null +++ b/tests/Bbx.Tests/Features/Snippets/SnippetFilesHandlerTests.cs @@ -0,0 +1,33 @@ +using System.Net; +using AwesomeAssertions; +using Bbx.Api; +using Bbx.Auth; +using Bbx.Features.Snippets.SnippetFiles; +using Bbx.Tests.TestKit; + +namespace Bbx.Tests.Features.Snippets; + +public class SnippetFilesHandlerTests +{ + [Fact] + public async Task File_path_segments_are_encoded_without_losing_directories() + { + var http = new FakeHttpMessageHandler(); + http.Enqueue(HttpStatusCode.OK, "contents", "text/plain"); + var client = new BitbucketClient(TestHttpClientFactory.Create(http), new NullAuthProvider()); + var credentials = new CredentialManager(new InMemoryCredentialStore(new BbxConfig + { + DefaultWorkspace = "ws", + Username = "u", + ApiToken = "t", + })); + var handler = new SnippetFilesHandler(client, credentials); + + await handler.HandleAsync( + new SnippetFilesRequest("snippet", "docs/a#b?.txt", "ws", Raw: true), + TestContext.Current.CancellationToken); + + http.Calls.Single().RequestUri!.AbsoluteUri.Should().Be( + "https://api.bitbucket.org/2.0/snippets/ws/snippet/files/docs/a%23b%3F.txt"); + } +} diff --git a/tests/Bbx.Tests/Features/Users/UserHandlersTests.cs b/tests/Bbx.Tests/Features/Users/UserHandlersTests.cs index e37877d..43ea9c0 100644 --- a/tests/Bbx.Tests/Features/Users/UserHandlersTests.cs +++ b/tests/Bbx.Tests/Features/Users/UserHandlersTests.cs @@ -18,17 +18,13 @@ public class UserHandlersTests private static BitbucketClient Client(FakeHttpMessageHandler http) => new(TestHttpClientFactory.Create(http), new NullAuthProvider()); - private static CredentialManager Creds() => - new(new InMemoryCredentialStore(new BbxConfig - { Username = "john@solrevdev.com", ApiToken = "t" })); - [Fact] public async Task ListUserEmails_hits_user_emails_endpoint() { var http = new FakeHttpMessageHandler(); http.Enqueue(HttpStatusCode.OK, """{"values":[{"email":"a@b","is_primary":true,"is_confirmed":true}],"next":null}"""); - var handler = new ListUserEmailsHandler(Client(http), Creds()); + var handler = new ListUserEmailsHandler(Client(http)); await handler.HandleAsync(new ListUserEmailsRequest(25), TestContext.Current.CancellationToken); @@ -36,25 +32,12 @@ public async Task ListUserEmails_hits_user_emails_endpoint() .Should().Be("https://api.bitbucket.org/2.0/user/emails"); } - [Fact] - public async Task ListUserEmails_throws_when_no_credentials() - { - var http = new FakeHttpMessageHandler(); - var creds = new CredentialManager(new InMemoryCredentialStore()); - var handler = new ListUserEmailsHandler(Client(http), creds); - - var act = async () => await handler.HandleAsync(new ListUserEmailsRequest(25), TestContext.Current.CancellationToken); - - (await act.Should().ThrowAsync()) - .WithMessage("*Not authenticated*"); - } - [Fact] public async Task ListUserWorkspacePermissions_hits_workspaces_path() { var http = new FakeHttpMessageHandler(); http.Enqueue(HttpStatusCode.OK, """{"values":[],"next":null}"""); - var handler = new ListUserWorkspacePermissionsHandler(Client(http), Creds()); + var handler = new ListUserWorkspacePermissionsHandler(Client(http)); await handler.HandleAsync(new ListUserWorkspacePermissionsRequest(50), TestContext.Current.CancellationToken); @@ -67,7 +50,7 @@ public async Task ListUserRepositoryPermissions_hits_repositories_path() { var http = new FakeHttpMessageHandler(); http.Enqueue(HttpStatusCode.OK, """{"values":[],"next":null}"""); - var handler = new ListUserRepositoryPermissionsHandler(Client(http), Creds()); + var handler = new ListUserRepositoryPermissionsHandler(Client(http)); await handler.HandleAsync(new ListUserRepositoryPermissionsRequest(50), TestContext.Current.CancellationToken); @@ -80,7 +63,7 @@ public async Task ViewUser_escapes_selector_in_url() { var http = new FakeHttpMessageHandler(); http.Enqueue(HttpStatusCode.OK, """{"display_name":"Alice"}"""); - var handler = new ViewUserHandler(Client(http), Creds()); + var handler = new ViewUserHandler(Client(http)); await handler.HandleAsync(new ViewUserRequest("{abc-uuid}"), TestContext.Current.CancellationToken); @@ -102,7 +85,7 @@ public async Task ListSshKeys_resolves_current_user_to_its_uuid(string selector) var http = new FakeHttpMessageHandler(); http.Enqueue(HttpStatusCode.OK, CurrentUserJson); http.Enqueue(HttpStatusCode.OK, """{"values":[{"uuid":"{k1}","label":"laptop"}],"next":null}"""); - var handler = new ListSshKeysHandler(Client(http), Creds()); + var handler = new ListSshKeysHandler(Client(http)); await handler.HandleAsync(new ListSshKeysRequest(selector, 25), TestContext.Current.CancellationToken); @@ -115,7 +98,7 @@ public async Task ListSshKeys_uses_an_explicit_selector_verbatim() { var http = new FakeHttpMessageHandler(); http.Enqueue(HttpStatusCode.OK, """{"values":[],"next":null}"""); - var handler = new ListSshKeysHandler(Client(http), Creds()); + var handler = new ListSshKeysHandler(Client(http)); await handler.HandleAsync(new ListSshKeysRequest("{other-uuid}", 25), TestContext.Current.CancellationToken); @@ -129,7 +112,7 @@ public async Task AddSshKey_posts_key_with_optional_label() var http = new FakeHttpMessageHandler(); http.Enqueue(HttpStatusCode.OK, CurrentUserJson); http.Enqueue(HttpStatusCode.Created, """{"uuid":"{k1}","label":"laptop"}"""); - var handler = new AddSshKeyHandler(Client(http), Creds()); + var handler = new AddSshKeyHandler(Client(http)); await handler.HandleAsync( new AddSshKeyRequest("me", "ssh-ed25519 AAAA", "laptop"), TestContext.Current.CancellationToken); @@ -145,7 +128,7 @@ public async Task DeleteSshKey_deletes_keyed_endpoint() var http = new FakeHttpMessageHandler(); http.Enqueue(HttpStatusCode.OK, CurrentUserJson); http.Enqueue(HttpStatusCode.NoContent, ""); - var handler = new DeleteSshKeyHandler(Client(http), Creds()); + var handler = new DeleteSshKeyHandler(Client(http)); await handler.HandleAsync(new DeleteSshKeyRequest("me", "{k1}"), TestContext.Current.CancellationToken); diff --git a/tests/Bbx.Tests/ProgramTests.cs b/tests/Bbx.Tests/ProgramTests.cs new file mode 100644 index 0000000..af1881a --- /dev/null +++ b/tests/Bbx.Tests/ProgramTests.cs @@ -0,0 +1,79 @@ +using System.Net; +using AwesomeAssertions; +using Bbx.Api; +using Bbx.Auth; +using Bbx.Features.Auth.Status; +using Bbx.Tests.TestKit; +using Microsoft.Extensions.DependencyInjection; + +namespace Bbx.Tests; + +[Collection("Console")] +public sealed class ProgramTests : IDisposable +{ + private readonly IServiceProvider? _originalServices = Program.Services; + + [Fact] + public async Task Expected_command_failure_returns_one_without_shared_exit_state() + { + var http = new FakeHttpMessageHandler(); + http.Enqueue(HttpStatusCode.Unauthorized, "{}"); + var services = new ServiceCollection(); + services.AddSingleton(new CredentialManager(new InMemoryCredentialStore(new BbxConfig + { + Username = "jane@example.com", + ApiToken = "expired", + }))); + services.AddSingleton(new BitbucketClient( + TestHttpClientFactory.Create(http), new NullAuthProvider())); + services.AddTransient(); + var provider = services.BuildServiceProvider(); + + var (stdout, stderr) = await CaptureConsole.RunAsync(async () => + { + var exitCode = await Program.RunAsync(["auth", "status"], provider); + exitCode.Should().Be(1); + }); + + stdout.Should().BeEmpty(); + stderr.Should().Contain("401") + .And.NotContain("Unhandled exception") + .And.NotContain(" at Bbx."); + } + + [Fact] + public async Task Cancelling_the_supplied_token_stops_the_command_before_it_calls_the_api() + { + var http = new FakeHttpMessageHandler(); + http.Enqueue(HttpStatusCode.OK, """{"display_name":"Jane"}"""); + var provider = BuildAuthenticatedProvider(http); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + + var (stdout, stderr) = await CaptureConsole.RunAsync(async () => + { + var exitCode = await Program.RunAsync(["auth", "status"], provider, cancellation.Token); + exitCode.Should().Be(130); + }); + + http.Calls.Should().BeEmpty(); + stdout.Should().BeEmpty(); + stderr.Should().BeEmpty(); + } + + private static IServiceProvider BuildAuthenticatedProvider(FakeHttpMessageHandler http) + { + var services = new ServiceCollection(); + services.AddSingleton(new CredentialManager(new InMemoryCredentialStore(new BbxConfig + { + Username = "jane@example.com", + ApiToken = "token", + }))); + services.AddSingleton(new BitbucketClient( + TestHttpClientFactory.Create(http), new NullAuthProvider())); + services.AddTransient(); + return services.BuildServiceProvider(); + } + + public void Dispose() => Program.Services = _originalServices!; +} diff --git a/tests/Bbx.Tests/TestKit/FakeHttpMessageHandler.cs b/tests/Bbx.Tests/TestKit/FakeHttpMessageHandler.cs index be0531e..9769602 100644 --- a/tests/Bbx.Tests/TestKit/FakeHttpMessageHandler.cs +++ b/tests/Bbx.Tests/TestKit/FakeHttpMessageHandler.cs @@ -23,6 +23,10 @@ public void EnqueueResponder(Func respo protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + // HttpClient hands the token straight to its handler without checking it + // first, so a fake that ignores it would answer a request a real one + // would have refused. + cancellationToken.ThrowIfCancellationRequested(); Calls.Add(request); string? body = null; if (request.Content is not null)