Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
30 changes: 24 additions & 6 deletions src/Bbx/Api/BitbucketClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ public async Task<byte[]> 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<T?> PostAsync<T>(string endpoint, object? body = null, CancellationToken ct = default)
{
var content = body != null
Expand Down Expand Up @@ -123,10 +131,12 @@ private async Task<HttpResponseMessage> 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
Expand All @@ -146,11 +156,11 @@ private async Task<HttpResponseMessage> 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;
Expand All @@ -162,6 +172,7 @@ private async Task<HttpResponseMessage> SendOnceAsync(
HttpContent? content,
CancellationToken ct,
string? accept,
HttpCompletionOption completionOption,
bool applyAuth = true)
{
var request = new HttpRequestMessage(method, url)
Expand All @@ -176,9 +187,16 @@ private async Task<HttpResponseMessage> 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,
Expand Down
38 changes: 31 additions & 7 deletions src/Bbx/Auth/FileCredentialStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,27 +37,51 @@ public BbxConfig Load()
var json = File.ReadAllText(_configFile);
return JsonSerializer.Deserialize<BbxConfig>(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);
}
}

Expand Down
26 changes: 10 additions & 16 deletions src/Bbx/Commands/AuthCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoginApiTokenHandler>()
.HandleAsync(new LoginApiTokenRequest(email, token), CancellationToken.None));
.HandleAsync(new LoginApiTokenRequest(email, token), CommandBinding.CancellationToken));
}, emailOption, tokenOption);

return loginCommand;
Expand All @@ -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<AuthStatusHandler>()
.HandleAsync(new AuthStatusRequest(), CancellationToken.None);
});
statusCommand.SetHandler(() => CommandRunner.RunActionNoGateAsync(() =>
services.GetRequiredService<AuthStatusHandler>()
.HandleAsync(new AuthStatusRequest(), CommandBinding.CancellationToken)));
return statusCommand;
}

Expand All @@ -87,19 +83,17 @@ private static Command BuildLogoutCommand(IServiceProvider services)
{
await CommandRunner.RunActionNoGateAsync(() =>
services.GetRequiredService<LogoutHandler>()
.HandleAsync(new LogoutRequest(), CancellationToken.None));
.HandleAsync(new LogoutRequest(), CommandBinding.CancellationToken));
});
return logoutCommand;
}

private static Command BuildTokenCommand(IServiceProvider services)
{
var tokenCommand = new Command("token", "Display current access token");
tokenCommand.SetHandler(async () =>
{
await services.GetRequiredService<AuthTokenHandler>()
.HandleAsync(new AuthTokenRequest(), CancellationToken.None);
});
tokenCommand.SetHandler(() => CommandRunner.RunActionNoGateAsync(() =>
services.GetRequiredService<AuthTokenHandler>()
.HandleAsync(new AuthTokenRequest(), CommandBinding.CancellationToken)));
return tokenCommand;
}

Expand All @@ -112,7 +106,7 @@ private static Command BuildSetWorkspaceCommand(IServiceProvider services)
{
await CommandRunner.RunActionNoGateAsync(() =>
services.GetRequiredService<SetWorkspaceHandler>()
.HandleAsync(new SetWorkspaceRequest(workspace), CancellationToken.None));
.HandleAsync(new SetWorkspaceRequest(workspace), CommandBinding.CancellationToken));
}, workspaceArg);
return setWorkspaceCommand;
}
Expand Down
43 changes: 18 additions & 25 deletions src/Bbx/Commands/BranchCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ListBranchesHandler>()
.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);

Expand All @@ -44,19 +44,19 @@ public static Command Create(IServiceProvider services)
viewCommand.SetHandler((string? workspace, string? repo, string name) =>
CommandRunner.RunJsonAsync(() =>
services.GetRequiredService<ViewBranchHandler>()
.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<string>("name") { Description = "Branch name" };
var targetOption = new Option<string>("--target") { Description = "Target commit hash or branch name" , Required = true };
var targetOption = new Option<string>("--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<CreateBranchHandler>()
.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);

Expand All @@ -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<DeleteBranchHandler>()
.HandleAsync(new DeleteBranchRequest(workspace, repo, name), CancellationToken.None));
.HandleAsync(new DeleteBranchRequest(workspace, repo, name), CommandBinding.CancellationToken));
}, workspaceOption, repoOption, deleteNameArg, yesOption);
command.Subcommands.Add(deleteCommand);

Expand All @@ -88,19 +81,19 @@ await CommandRunner.RunActionAsync(() =>
restrictionsListCommand.SetHandler((string? workspace, string? repo) =>
CommandRunner.RunJsonAsync(() =>
services.GetRequiredService<ListBranchRestrictionsHandler>()
.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<string>("--kind") { Description = "Restriction kind (push, force, delete, require_passing_builds_to_merge, require_approvals_to_merge, etc.)" , Required = true };
var patternOption = new Option<string>("--pattern") { Description = "Branch pattern (glob or exact match)" , Required = true };
var kindOption = new Option<string>("--kind") { Description = "Restriction kind (push, force, delete, require_passing_builds_to_merge, require_approvals_to_merge, etc.)", Required = true };
var patternOption = new Option<string>("--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<AddBranchRestrictionHandler>()
.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);

Expand All @@ -117,7 +110,7 @@ await CommandRunner.RunActionAsync(() =>
return;
await CommandRunner.RunActionAsync(() =>
services.GetRequiredService<DeleteBranchRestrictionHandler>()
.HandleAsync(new DeleteBranchRestrictionRequest(workspace, repo, id), CancellationToken.None));
.HandleAsync(new DeleteBranchRestrictionRequest(workspace, repo, id), CommandBinding.CancellationToken));
}, workspaceOption, repoOption, restrictionIdArg, restrictionYesOption);
restrictionsCommand.Subcommands.Add(restrictionsDeleteCommand);

Expand All @@ -142,7 +135,7 @@ private static Command CreateTagCommand(IServiceProvider services, Option<string
listCommand.SetHandler((string? workspace, string? repo, int limit, string? sort, string? query) =>
CommandRunner.RunJsonAsync(() =>
services.GetRequiredService<ListTagsHandler>()
.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);

Expand All @@ -152,21 +145,21 @@ private static Command CreateTagCommand(IServiceProvider services, Option<string
viewCommand.SetHandler((string? workspace, string? repo, string name) =>
CommandRunner.RunJsonAsync(() =>
services.GetRequiredService<ViewTagHandler>()
.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<string>("name") { Description = "Tag name" };
var createTargetOption = new Option<string>("--target") { Description = "Target commit hash or branch name" , Required = true };
var createTargetOption = new Option<string>("--target") { Description = "Target commit hash or branch name", Required = true };
var createMessageOption = new Option<string?>("--message") { Description = "Annotation message (creates an annotated tag)" };
createCommand.Arguments.Add(createNameArg);
createCommand.Options.Add(createTargetOption);
createCommand.Options.Add(createMessageOption);
createCommand.SetHandler((string? workspace, string? repo, string name, string target, string? message) =>
CommandRunner.RunJsonAsync(() =>
services.GetRequiredService<CreateTagHandler>()
.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);

Expand All @@ -181,7 +174,7 @@ private static Command CreateTagCommand(IServiceProvider services, Option<string
return;
await CommandRunner.RunActionAsync(() =>
services.GetRequiredService<DeleteTagHandler>()
.HandleAsync(new DeleteTagRequest(workspace, repo, name), CancellationToken.None));
.HandleAsync(new DeleteTagRequest(workspace, repo, name), CommandBinding.CancellationToken));
}, workspaceOption, repoOption, deleteNameArg, yesOption);
tagCommand.Subcommands.Add(deleteCommand);

Expand Down
Loading