From 9b08ba15a460c7eaa174128691b811eb525eae56 Mon Sep 17 00:00:00 2001 From: John Smith Date: Tue, 4 Aug 2026 08:15:09 +0100 Subject: [PATCH 1/2] fix: harden command and API handling Keep authentication scoped to the Bitbucket API origin, handle nullable API data safely, stream downloads, preserve clean command output, and store credentials with safer file permissions. Add regression tests for each reviewed issue and propagate command cancellation throughout the CLI. --- src/Bbx/Api/BitbucketClient.cs | 30 ++- src/Bbx/Auth/FileCredentialStore.cs | 38 +++- src/Bbx/Commands/AuthCommand.cs | 26 +-- src/Bbx/Commands/BranchCommand.cs | 43 ++-- src/Bbx/Commands/CommandBinding.cs | 47 +++-- src/Bbx/Commands/CommandRunner.cs | 127 +++--------- src/Bbx/Commands/CommitCommand.cs | 38 ++-- src/Bbx/Commands/DownloadCommand.cs | 51 +++-- src/Bbx/Commands/IssueCommand.cs | 29 ++- src/Bbx/Commands/PipelineCommand.cs | 56 +++--- src/Bbx/Commands/PrCommand.cs | 62 +++--- src/Bbx/Commands/RepoCommand.cs | 72 ++++--- src/Bbx/Commands/SnippetCommand.cs | 36 ++-- src/Bbx/Commands/SrcCommand.cs | 17 +- src/Bbx/Commands/UserCommand.cs | 18 +- src/Bbx/Commands/WorkspaceCommand.cs | 70 +++---- .../Features/Auth/Status/AuthStatusHandler.cs | 30 ++- .../Features/Auth/Token/AuthTokenHandler.cs | 15 +- .../Commits/FileHistory/FileHistoryHandler.cs | 12 +- src/Bbx/Features/Common/EndpointPath.cs | 11 ++ .../GetDownload/GetDownloadHandler.cs | 6 +- .../ListDeploymentEnvironmentsHandler.cs | 4 +- .../ListPipelineSchedulesHandler.cs | 2 +- .../ListPipelineVariablesHandler.cs | 8 +- src/Bbx/Features/Pipelines/PipelineFormat.cs | 187 ++++++++++++------ .../ViewDeploymentEnvironmentHandler.cs | 4 +- .../ViewPipeline/ViewPipelineHandler.cs | 3 +- .../RepoPermissions/RepoPermissionsHandler.cs | 2 +- .../CreateSnippet/CreateSnippetHandler.cs | 2 - .../DeleteSnippet/DeleteSnippetHandler.cs | 2 - .../ListSnippets/ListSnippetsHandler.cs | 4 +- .../SnippetComments/SnippetCommentsHandler.cs | 2 - .../SnippetFiles/SnippetFilesHandler.cs | 4 +- .../SnippetWatch/SnippetWatchHandler.cs | 2 - .../UpdateSnippet/UpdateSnippetHandler.cs | 2 - .../ViewSnippet/ViewSnippetHandler.cs | 6 +- .../Source/CatSource/CatSourceHandler.cs | 13 +- .../Source/LsSource/LsSourceHandler.cs | 15 +- .../ListUserEmails/ListUserEmailsHandler.cs | 4 +- .../ListUserRepositoryPermissionsHandler.cs | 4 +- .../ListUserWorkspacePermissionsHandler.cs | 4 +- .../SshKeys/AddSshKey/AddSshKeyHandler.cs | 4 +- .../DeleteSshKey/DeleteSshKeyHandler.cs | 4 +- .../SshKeys/ListSshKeys/ListSshKeysHandler.cs | 4 +- .../SshKeys/ViewSshKey/ViewSshKeyHandler.cs | 4 +- .../Users/ViewUser/ViewUserHandler.cs | 4 +- .../ListWorkspaceMembersHandler.cs | 4 +- .../ListWorkspacePermissionsHandler.cs | 4 +- .../ListWorkspaces/ListWorkspacesHandler.cs | 4 +- .../ViewWorkspace/ViewWorkspaceHandler.cs | 4 +- src/Bbx/Program.cs | 34 ++-- tests/Bbx.Tests/Api/BitbucketClientTests.cs | 63 ++++++ .../Auth/FileCredentialStoreTests.cs | 51 +++++ .../Bbx.Tests/Commands/CommandBindingTests.cs | 38 ++++ .../Bbx.Tests/Commands/CommandRunnerTests.cs | 42 ++-- .../Commands/ConfirmationOutputTests.cs | 45 +++++ .../Features/Auth/AuthStatusHandlerTests.cs | 40 +++- .../Features/Auth/AuthTokenHandlerTests.cs | 22 +-- .../Downloads/GetDownloadHandlerTests.cs | 13 +- .../Features/NullNestedObjectHandlerTests.cs | 69 +++++++ .../Features/Pipelines/PipelineFormatTests.cs | 58 ++++++ .../Snippets/SnippetFilesHandlerTests.cs | 33 ++++ .../Features/Users/UserHandlersTests.cs | 33 +--- tests/Bbx.Tests/ProgramTests.cs | 45 +++++ 64 files changed, 1040 insertions(+), 690 deletions(-) create mode 100644 src/Bbx/Features/Common/EndpointPath.cs create mode 100644 tests/Bbx.Tests/Auth/FileCredentialStoreTests.cs create mode 100644 tests/Bbx.Tests/Commands/CommandBindingTests.cs create mode 100644 tests/Bbx.Tests/Commands/ConfirmationOutputTests.cs create mode 100644 tests/Bbx.Tests/Features/NullNestedObjectHandlerTests.cs create mode 100644 tests/Bbx.Tests/Features/Pipelines/PipelineFormatTests.cs create mode 100644 tests/Bbx.Tests/Features/Snippets/SnippetFilesHandlerTests.cs create mode 100644 tests/Bbx.Tests/ProgramTests.cs 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..82ef72b 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,11 @@ 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) + { // `--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 +30,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 +66,28 @@ public static async Task Main(string[] args) int exitCode; try { - exitCode = await parseResult.InvokeAsync(); + exitCode = await parseResult.InvokeAsync(new InvocationConfiguration + { + EnableDefaultExceptionHandler = false, + }, default); + } + catch (BbxUserException ex) + { + Console.Error.WriteLine(ex.Message); + return 1; + } + 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..96f044d --- /dev/null +++ b/tests/Bbx.Tests/Commands/CommandBindingTests.cs @@ -0,0 +1,38 @@ +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(); + } +} 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..1f11da4 --- /dev/null +++ b/tests/Bbx.Tests/ProgramTests.cs @@ -0,0 +1,45 @@ +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."); + } + + public void Dispose() => Program.Services = _originalServices!; +} From 9b855c9a1c0e787aae0ec1570832b747e024b7d1 Mon Sep 17 00:00:00 2001 From: John Smith Date: Tue, 4 Aug 2026 09:07:56 +0100 Subject: [PATCH 2/2] fix(cli): let a caller cancel a run and exit 130 on Ctrl+C Program.RunAsync hardcoded `default` as the token for InvokeAsync, so a caller driving RunAsync had no way to stop a command. It now takes an optional token and passes it through. Ctrl+C already reached handlers: System.CommandLine links whatever token it is given to a source of its own and cancels that from ProcessTerminationHandler. What it did not do was exit cleanly. The resulting OperationCanceledException fell to the catch-all and printed "Error: The operation was canceled." with exit 1. A cancelled run is not an error, so it now exits 130 and says nothing. An HttpClient timeout also surfaces as an OperationCanceledException but carries a TimeoutException inside, so it still reports as a failure with exit 1. FakeHttpMessageHandler now honours the token. HttpClient hands it to the handler without checking it first, so a fake that ignored it answered requests a real handler would have refused. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 +++- CLAUDE.md | 6 ++++ src/Bbx/Program.cs | 22 ++++++++++-- .../Bbx.Tests/Commands/CommandBindingTests.cs | 24 +++++++++++++ tests/Bbx.Tests/ProgramTests.cs | 34 +++++++++++++++++++ .../TestKit/FakeHttpMessageHandler.cs | 4 +++ 6 files changed, 94 insertions(+), 3 deletions(-) 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/Program.cs b/src/Bbx/Program.cs index 82ef72b..b59e9fc 100644 --- a/src/Bbx/Program.cs +++ b/src/Bbx/Program.cs @@ -17,7 +17,8 @@ public static async Task Main(string[] args) return await RunAsync(args, ServiceRegistration.Build()); } - internal static async Task RunAsync(string[] args, IServiceProvider services) + 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 @@ -66,16 +67,33 @@ internal static async Task RunAsync(string[] args, IServiceProvider service int exitCode; try { + // 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, - }, default); + }, 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}"); diff --git a/tests/Bbx.Tests/Commands/CommandBindingTests.cs b/tests/Bbx.Tests/Commands/CommandBindingTests.cs index 96f044d..dd7d340 100644 --- a/tests/Bbx.Tests/Commands/CommandBindingTests.cs +++ b/tests/Bbx.Tests/Commands/CommandBindingTests.cs @@ -35,4 +35,28 @@ public async Task System_command_line_cancellation_reaches_bound_handlers() 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/ProgramTests.cs b/tests/Bbx.Tests/ProgramTests.cs index 1f11da4..af1881a 100644 --- a/tests/Bbx.Tests/ProgramTests.cs +++ b/tests/Bbx.Tests/ProgramTests.cs @@ -41,5 +41,39 @@ public async Task Expected_command_failure_returns_one_without_shared_exit_state .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)