From d484b70e3b0dcc918a3a1a971ad34fbe8eb0599d Mon Sep 17 00:00:00 2001 From: mkajander Date: Thu, 24 Sep 2026 01:16:25 +0300 Subject: [PATCH 1/3] Add pr create command Add `bbt pr create` to open a pull request from a branch. The source defaults to the current branch. The destination defaults to the repository main branch. The title defaults to the latest commit subject on `origin/`, then on the local branch. The command uses a local title only when the checkout origin is the target repository. Do not resend POST or PATCH requests after a gateway error, a timeout, or a lost connection. Bitbucket can apply such a request before the failure, so a retry can create a duplicate pull request or comment. These requests retry only on 429. The change applies to BitbucketClient and to `bbt api`. Remove control characters from all error messages before they go to stderr, so user input and API text cannot send terminal escape codes. --- README.md | 3 + docs/contracts/bitbucket-cloud-v0.1.md | 32 +++ src/Bbt.Core/Bitbucket/BitbucketClient.cs | 33 +++- .../Bitbucket/BitbucketRetryPolicy.cs | 38 ++++ .../Models/CreatePullRequestRequest.cs | 68 +++++++ src/Bbt.Core/Git/GitClient.cs | 12 ++ src/Bbt/Commands/Api/ApiCommand.cs | 17 +- src/Bbt/Commands/Llms/LlmsCommand.cs | 1 + src/Bbt/Commands/Pr/PrCreateCommand.cs | 183 ++++++++++++++++++ src/Bbt/Infrastructure/BbtAsyncCommand.cs | 12 +- src/Bbt/Program.cs | 1 + .../BitbucketClientCreatePullRequestTests.cs | 109 +++++++++++ .../Bitbucket/BitbucketClientRetryTests.cs | 101 ++++++++++ 13 files changed, 589 insertions(+), 21 deletions(-) create mode 100644 src/Bbt.Core/Bitbucket/BitbucketRetryPolicy.cs create mode 100644 src/Bbt.Core/Bitbucket/Models/CreatePullRequestRequest.cs create mode 100644 src/Bbt/Commands/Pr/PrCreateCommand.cs create mode 100644 tests/Bbt.Core.Tests/Bitbucket/BitbucketClientCreatePullRequestTests.cs create mode 100644 tests/Bbt.Core.Tests/Bitbucket/BitbucketClientRetryTests.cs diff --git a/README.md b/README.md index 6f16aee..3895566 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ If `--email`/`--token` are omitted, `auth login` prompts interactively (unless s - `bbt pr diff [] [--include-raw]` - `bbt pr comments [] [--limit ] [--sort ] [--page ] [--pagelen ] [--paginate] [--contains | -q/--query ]` - `bbt pr threads [] [--limit ] [--sort ] [--pagelen ] [--contains | -q/--query ]` +- `bbt pr create [-t/--title ] [--body |--body-file ] [-s/--source ] [-d/--destination ] [-r/--reviewer ]... [--close-source-branch] [--draft]` - `bbt pr comment (--body | --body-file ) [--reply-to ] [--file --line [--line-end ] [--side ]]` - `bbt pr review (--approve|--unapprove|--request-changes|--unrequest-changes) [--body |--body-file ]` @@ -111,7 +112,9 @@ Notes: - `pr summary --json` emits one normalized metrics-friendly object. `mergedAt` comes from the PR activity log's `MERGED` state transition when Bitbucket exposes it; otherwise it is `null` instead of falling back to `updated_on`. - `pr comments` defaults to newest-first (`--sort -created_on`) and returns one page by default (50 comments). Use `--paginate`, a larger `--pagelen`, or `--limit` to fetch more. - `pr threads` groups comments into discussion threads (root + replies, including nested replies). Threads are ordered by discovery sort (default: `--sort -created_on`); with `--contains/-q`, ordering is based on the newest matching comment. Defaults: `--limit 20`, `--pagelen 100`. +- `pr create` defaults `--source` to the current git branch, `--destination` to the repository main branch, and `--title` to the subject of the latest commit on the source branch (the pushed `origin/` tip when available; only when the checkout's origin is the target repository). The source branch must already be pushed. `--reviewer` accepts a UUID (`{...}`) or an Atlassian account id and can be repeated. - `pr review --body/--body-file` posts a global comment first, then performs the review action. +- Write requests (POST) are retried only on `429`. After a gateway error or a lost connection they are not resent, because Bitbucket may already have applied them; check the current state before you run the command again. #### PR comment threads (recommended) diff --git a/docs/contracts/bitbucket-cloud-v0.1.md b/docs/contracts/bitbucket-cloud-v0.1.md index 73c25f2..2b2daf6 100644 --- a/docs/contracts/bitbucket-cloud-v0.1.md +++ b/docs/contracts/bitbucket-cloud-v0.1.md @@ -248,6 +248,37 @@ Responses: - `403 application/json`: insufficient permission - `404 application/json`: PR not found/no access +#### `POST /repositories/{workspace}/{repo_slug}/pullrequests` + +Used by: +- `bbt pr create` + +Request body: + +```json +{ + "title": "Add feature", + "description": "markdown text", + "source": { "branch": { "name": "feature/x" } }, + "destination": { "branch": { "name": "main" } }, + "reviewers": [{ "uuid": "{...}" }, { "account_id": "557058:..." }], + "close_source_branch": true, + "draft": true +} +``` + +Rules: +- `title` and `source.branch.name` are required. +- `destination` is omitted when `--destination` is not given; Bitbucket then uses the repository main branch. +- `description`, `reviewers`, `close_source_branch`, and `draft` are omitted unless set. +- Reviewer values wrapped in braces are sent as `uuid`; all other values are sent as `account_id`. + +Responses: +- `201 application/json`: created pull request object +- `400 application/json`: invalid request (e.g., source branch not found, invalid reviewer) +- `403 application/json`: insufficient permission +- `404 application/json`: repository not found/no access + ### 3.3 Review actions #### `POST /repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/approve` @@ -304,6 +335,7 @@ Responses: | `bbt pr summary ` | `GET /repositories/{ws}/{repo}/pullrequests/{id}` + `GET /repositories/{ws}/{repo}/pullrequests/{id}/diff` + `GET /repositories/{ws}/{repo}/pullrequests/{id}/activity` (MERGED only) | Uses `comment_count` from the PR object; `mergedAt` comes from the latest activity `update.state == MERGED`, else `null`. | | `bbt pr diff ` | `GET /repositories/{ws}/{repo}/pullrequests/{id}/diff` (follow 302) | Human mode prints raw diff; JSON mode parses diff. | | `bbt pr comments ` | `GET /repositories/{ws}/{repo}/pullrequests/{id}/comments` (paged) | If `` omitted: resolve PR by branch first. | +| `bbt pr create` | `POST /repositories/{ws}/{repo}/pullrequests` | Source defaults to current branch; title defaults to latest commit subject. | | `bbt pr comment ` | `POST /repositories/{ws}/{repo}/pullrequests/{id}/comments` | Inline anchor uses `inline.*` fields. | | `bbt pr review …` | (optional) `POST …/comments` then `POST`/`DELETE` approve/request-changes | Body is posted as a global comment. | | `bbt api …` | Any method/path | Optional `--paginate` merges `values` from `next` pages. | diff --git a/src/Bbt.Core/Bitbucket/BitbucketClient.cs b/src/Bbt.Core/Bitbucket/BitbucketClient.cs index ead9a29..9365455 100644 --- a/src/Bbt.Core/Bitbucket/BitbucketClient.cs +++ b/src/Bbt.Core/Bitbucket/BitbucketClient.cs @@ -83,6 +83,23 @@ public async Task GetPullRequestAsync(string workspace, st cancellationToken); } + public async Task CreatePullRequestAsync( + string workspace, + string repo, + CreatePullRequestRequest body, + CancellationToken cancellationToken = default) + { + var url = $"repositories/{Uri.EscapeDataString(workspace)}/{Uri.EscapeDataString(repo)}/pullrequests"; + var json = JsonSerializer.Serialize(body); + + return await SendJsonAsync( + () => new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }, + cancellationToken); + } + public async Task> ListPullRequestActivityAsync( string workspace, string repo, @@ -305,14 +322,16 @@ private async Task SendWithRetryAsync( } const int maxAttempts = 4; + var method = HttpMethod.Get; for (var attempt = 1; attempt <= maxAttempts; attempt++) { try { using var request = createRequest(); + method = request.Method; var response = await SendOnceAsync(request, allowRedirect, cancellationToken); - if (!IsTransient(response.StatusCode)) + if (!BitbucketRetryPolicy.ShouldRetry(response.StatusCode, BitbucketRetryPolicy.IsIdempotent(method))) { return response; } @@ -328,6 +347,10 @@ private async Task SendWithRetryAsync( await Task.Delay(delay, cancellationToken); continue; } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested && !BitbucketRetryPolicy.IsIdempotent(method)) + { + throw BitbucketRetryPolicy.CreateOutcomeUnknownException(method, ex); + } catch (HttpRequestException ex) when (attempt < maxAttempts) { var delay = TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, attempt))); @@ -436,14 +459,6 @@ private static void EnsureSecureTransportOrThrow(Uri uri, string description) } } - private static bool IsTransient(HttpStatusCode statusCode) - { - return statusCode is HttpStatusCode.TooManyRequests - or HttpStatusCode.BadGateway - or HttpStatusCode.ServiceUnavailable - or HttpStatusCode.GatewayTimeout; - } - private static TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt) { if (response.Headers.TryGetValues("Retry-After", out var values)) diff --git a/src/Bbt.Core/Bitbucket/BitbucketRetryPolicy.cs b/src/Bbt.Core/Bitbucket/BitbucketRetryPolicy.cs new file mode 100644 index 0000000..484e698 --- /dev/null +++ b/src/Bbt.Core/Bitbucket/BitbucketRetryPolicy.cs @@ -0,0 +1,38 @@ +using System.Net; + +namespace Bbt.Core.Bitbucket; + +public static class BitbucketRetryPolicy +{ + /// + /// POST/PATCH may already have been applied when a gateway error, timeout, or dropped connection occurs, + /// so resending them risks duplicates (e.g. two pull requests or comments). + /// + public static bool IsIdempotent(HttpMethod method) + { + return method != HttpMethod.Post && method != HttpMethod.Patch; + } + + /// + /// 429 means the request was rejected before processing, so it is safe to retry for any method. + /// Gateway errors are ambiguous and only retried for idempotent methods. + /// + public static bool ShouldRetry(HttpStatusCode statusCode, bool idempotent) + { + if (statusCode == HttpStatusCode.TooManyRequests) + { + return true; + } + + return idempotent && statusCode is HttpStatusCode.BadGateway + or HttpStatusCode.ServiceUnavailable + or HttpStatusCode.GatewayTimeout; + } + + public static HttpRequestException CreateOutcomeUnknownException(HttpMethod method, Exception inner) + { + return new HttpRequestException( + $"{method} request failed before a response was received ({inner.Message}). It was not retried because it may already have been applied; check the current state before running it again.", + inner); + } +} diff --git a/src/Bbt.Core/Bitbucket/Models/CreatePullRequestRequest.cs b/src/Bbt.Core/Bitbucket/Models/CreatePullRequestRequest.cs new file mode 100644 index 0000000..d98c065 --- /dev/null +++ b/src/Bbt.Core/Bitbucket/Models/CreatePullRequestRequest.cs @@ -0,0 +1,68 @@ +using System.Text.Json.Serialization; + +namespace Bbt.Core.Bitbucket.Models; + +public sealed class CreatePullRequestRequest +{ + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } + + [JsonPropertyName("source")] + public CreatePullRequestEndpoint Source { get; set; } = new(); + + [JsonPropertyName("destination")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CreatePullRequestEndpoint? Destination { get; set; } + + [JsonPropertyName("reviewers")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Reviewers { get; set; } + + [JsonPropertyName("close_source_branch")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? CloseSourceBranch { get; set; } + + [JsonPropertyName("draft")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Draft { get; set; } +} + +public sealed class CreatePullRequestEndpoint +{ + [JsonPropertyName("branch")] + public CreatePullRequestBranch Branch { get; set; } = new(); + + public static CreatePullRequestEndpoint ForBranch(string name) => new() { Branch = new CreatePullRequestBranch { Name = name } }; +} + +public sealed class CreatePullRequestBranch +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; +} + +public sealed class CreatePullRequestReviewer +{ + [JsonPropertyName("uuid")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Uuid { get; set; } + + [JsonPropertyName("account_id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? AccountId { get; set; } + + /// + /// Values wrapped in braces (e.g. "{1234-...}") are treated as UUIDs; anything else as an Atlassian account id. + /// + public static CreatePullRequestReviewer FromIdentifier(string value) + { + var trimmed = value.Trim(); + return trimmed.StartsWith('{') && trimmed.EndsWith('}') + ? new CreatePullRequestReviewer { Uuid = trimmed } + : new CreatePullRequestReviewer { AccountId = trimmed }; + } +} diff --git a/src/Bbt.Core/Git/GitClient.cs b/src/Bbt.Core/Git/GitClient.cs index 2853255..eb633bc 100644 --- a/src/Bbt.Core/Git/GitClient.cs +++ b/src/Bbt.Core/Git/GitClient.cs @@ -45,5 +45,17 @@ public async Task IsInsideWorkTreeAsync(CancellationToken cancellationToke return value; } + + public async Task TryGetCommitSubjectAsync(string revision, CancellationToken cancellationToken = default) + { + var result = await _processRunner.RunAsync("git", ["log", "-1", "--format=%s", "--end-of-options", revision, "--"], cancellationToken: cancellationToken); + if (result.ExitCode != 0) + { + return null; + } + + var value = result.Stdout.Trim(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } } diff --git a/src/Bbt/Commands/Api/ApiCommand.cs b/src/Bbt/Commands/Api/ApiCommand.cs index 2d214b6..35c8c5f 100644 --- a/src/Bbt/Commands/Api/ApiCommand.cs +++ b/src/Bbt/Commands/Api/ApiCommand.cs @@ -6,6 +6,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using Bbt.Core.Auth; +using Bbt.Core.Bitbucket; using Bbt.Core.Config; using Bbt.Core.Context; using Bbt.Core.Git; @@ -426,14 +427,16 @@ private static async Task SendWithRetryAsync( } const int maxAttempts = 4; + var method = HttpMethod.Get; for (var attempt = 1; attempt <= maxAttempts; attempt++) { try { using var request = createRequest(); + method = request.Method; var response = await SendOnceAsync(http, request, settings, authorizationHeader, allowAuthToBitbucketHosts, baseUri, cancellationToken); - if (!IsTransient(response.StatusCode)) + if (!BitbucketRetryPolicy.ShouldRetry(response.StatusCode, BitbucketRetryPolicy.IsIdempotent(method))) { return response; } @@ -452,6 +455,10 @@ private static async Task SendWithRetryAsync( response.Dispose(); await Task.Delay(delay, cancellationToken); } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested && !BitbucketRetryPolicy.IsIdempotent(method)) + { + throw BitbucketRetryPolicy.CreateOutcomeUnknownException(method, ex); + } catch (HttpRequestException ex) when (attempt < maxAttempts) { var delay = TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, attempt))); @@ -579,14 +586,6 @@ private static void EnsureSecureTransportOrThrow(Uri uri, string description) } } - private static bool IsTransient(HttpStatusCode statusCode) - { - return statusCode is HttpStatusCode.TooManyRequests - or HttpStatusCode.BadGateway - or HttpStatusCode.ServiceUnavailable - or HttpStatusCode.GatewayTimeout; - } - private static TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt) { if (response.Headers.TryGetValues("Retry-After", out var values)) diff --git a/src/Bbt/Commands/Llms/LlmsCommand.cs b/src/Bbt/Commands/Llms/LlmsCommand.cs index c1ea438..3aca851 100644 --- a/src/Bbt/Commands/Llms/LlmsCommand.cs +++ b/src/Bbt/Commands/Llms/LlmsCommand.cs @@ -80,6 +80,7 @@ private static LlmsDocument BuildDocument(string version) new LlmsCommandEntry("bbt pr diff [] [--include-raw]", "Show raw diff in human mode, structured diff in JSON mode."), new LlmsCommandEntry("bbt pr comments [] [--limit ] [--sort ] [--page ] [--pagelen ] [--paginate] [--contains | -q/--query ]", "List pull request comments (default: newest-first and one page unless --paginate/--limit requires more)."), new LlmsCommandEntry("bbt pr threads [] [--limit ] [--sort ] [--pagelen ] [--contains | -q/--query ]", "List pull request comment threads (root + replies, including nested replies). Threads are ordered by discovery sort (default: -created_on); with --contains/-q, ordering is based on the newest matching comment. Default: --limit 20, --pagelen 100. Filtering: --contains matches any comment in the thread (server-side discovery). Output: --quiet prints root ids; --json emits {rootId, root, replies, lastActivityOn}."), + new LlmsCommandEntry("bbt pr create [-t/--title ] [--body |--body-file ] [-s/--source ] [-d/--destination ] [-r/--reviewer ]... [--close-source-branch] [--draft]", "Create a pull request. Defaults: source = current branch (must be pushed), destination = repo main branch, title = latest commit subject on source. Output: --quiet prints the new PR id; --json emits the PR view object."), new LlmsCommandEntry("bbt pr comment (--body | --body-file ) [--reply-to ] [--file --line [--line-end ] [--side ]]", "Post global, inline, or reply comment (default inline side: to)."), new LlmsCommandEntry("bbt pr review (--approve|--unapprove|--request-changes|--unrequest-changes) [--body |--body-file ]", "Apply review status; optional body posts global comment first."), new LlmsCommandEntry("bbt api [--input ] [--paginate]", "Raw Bitbucket API access with placeholder replacement for {workspace}/{repo}. order is also accepted."), diff --git a/src/Bbt/Commands/Pr/PrCreateCommand.cs b/src/Bbt/Commands/Pr/PrCreateCommand.cs new file mode 100644 index 0000000..0501fc7 --- /dev/null +++ b/src/Bbt/Commands/Pr/PrCreateCommand.cs @@ -0,0 +1,183 @@ +using System.ComponentModel; +using Bbt.Core.Auth; +using Bbt.Core.Bitbucket; +using Bbt.Core.Bitbucket.Models; +using Bbt.Core.Config; +using Bbt.Core.Context; +using Bbt.Core.Git; +using Bbt.Core.IO; +using Bbt.Infrastructure; +using Spectre.Cli; +using Spectre.Console; + +namespace Bbt.Commands.Pr; + +public sealed class PrCreateCommand : BbtAsyncCommand +{ + public sealed class Settings : BbtRepoSettings + { + [Description("Pull request title (default: subject of the latest commit on the source branch).")] + [CommandOption("-t|--title ")] + public string? Title { get; init; } + + [Description("Pull request description.")] + [CommandOption("--body ")] + public string? Body { get; init; } + + [Description("Read pull request description from file path.")] + [CommandOption("--body-file ")] + public string? BodyFile { get; init; } + + [Description("Source branch (default: current git branch). Must already be pushed to Bitbucket.")] + [CommandOption("-s|--source ")] + public string? Source { get; init; } + + [Description("Destination branch (default: repository main branch).")] + [CommandOption("-d|--destination ")] + public string? Destination { get; init; } + + [Description("Reviewer UUID ('{...}') or Atlassian account id. Repeatable.")] + [CommandOption("-r|--reviewer ")] + public string[] Reviewers { get; init; } = []; + + [Description("Close the source branch when the pull request is merged.")] + [CommandOption("--close-source-branch")] + public bool CloseSourceBranch { get; init; } + + [Description("Create the pull request as a draft.")] + [CommandOption("--draft")] + public bool Draft { get; init; } + + public override Spectre.Cli.ValidationResult Validate() + { + var baseResult = base.Validate(); + if (!baseResult.Successful) + { + return baseResult; + } + + if (Body is not null && BodyFile is not null) + { + return Spectre.Cli.ValidationResult.Error("Specify at most one of --body or --body-file."); + } + + if (Title is not null && string.IsNullOrWhiteSpace(Title)) + { + return Spectre.Cli.ValidationResult.Error("--title cannot be empty."); + } + + if (Source is not null && string.IsNullOrWhiteSpace(Source)) + { + return Spectre.Cli.ValidationResult.Error("--source cannot be empty."); + } + + if (Destination is not null && string.IsNullOrWhiteSpace(Destination)) + { + return Spectre.Cli.ValidationResult.Error("--destination cannot be empty."); + } + + if (Reviewers.Any(string.IsNullOrWhiteSpace)) + { + return Spectre.Cli.ValidationResult.Error("--reviewer cannot be empty."); + } + + return Spectre.Cli.ValidationResult.Success(); + } + } + + protected override async Task ExecuteCommandAsync(CommandContext context, Settings settings) + { + var processRunner = new ProcessRunner(); + var credentialStore = CredentialStoreFactory.CreateDefault(processRunner); + var configStore = new BbtConfigStore(); + var gitClient = new GitClient(processRunner); + var repoResolver = new RepoContextResolver(configStore, gitClient); + + var repoContext = await repoResolver.TryResolveAsync(settings.Workspace, settings.Repo, profileOverride: null); + if (repoContext is null) + { + throw new InvalidOperationException("Could not resolve workspace/repo. Use --workspace/--repo, set BBT_WORKSPACE/BBT_REPO, or run inside a git repo with a Bitbucket origin remote."); + } + + ResolvedContextReporter.LogRepoContext(settings, repoContext); + + var source = settings.Source?.Trim() ?? await gitClient.TryGetCurrentBranchAsync(); + if (string.IsNullOrWhiteSpace(source)) + { + throw new InvalidOperationException("Could not determine the source branch. Use --source or run on a checked-out branch."); + } + + var title = settings.Title?.Trim() ?? await TryDeriveTitleAsync(gitClient, repoContext, source); + if (string.IsNullOrWhiteSpace(title)) + { + throw new InvalidOperationException($"Could not derive a title from the latest commit on '{source}' (branch not found locally, or the current checkout is not {repoContext.Workspace}/{repoContext.Repo}). Use --title ."); + } + + var description = settings.Body; + if (settings.BodyFile is not null) + { + description = await File.ReadAllTextAsync(settings.BodyFile); + } + + var request = new CreatePullRequestRequest + { + Title = title, + Description = description, + Source = CreatePullRequestEndpoint.ForBranch(source), + Destination = settings.Destination is null ? null : CreatePullRequestEndpoint.ForBranch(settings.Destination.Trim()), + Reviewers = settings.Reviewers.Length == 0 ? null : settings.Reviewers.Select(CreatePullRequestReviewer.FromIdentifier).ToList(), + CloseSourceBranch = settings.CloseSourceBranch ? true : null, + Draft = settings.Draft ? true : null, + }; + + var auth = await AuthContextResolver.ResolveAsync(configStore, credentialStore, profileOverride: null, requireToken: true); + using var client = AuthContextResolver.CreateClient(auth, settings.Verbose, settings.NoRetry); + + var created = await client.CreatePullRequestAsync(repoContext.Workspace, repoContext.Repo, request); + var view = ModelMappers.ToPullRequestView(created); + + switch (settings.GetOutputMode()) + { + case OutputMode.Quiet: + OutputWriter.WriteQuiet(view.Id.ToString()); + return 0; + case OutputMode.Json: + await new OutputWriter(processRunner).WriteJsonAsync(view, settings); + return 0; + default: + Spectre.Console.AnsiConsole.MarkupLine($"Created pull request [yellow]#{view.Id}[/] {TerminalSanitizer.EscapeMarkup(view.Title)}"); + Spectre.Console.AnsiConsole.MarkupLine($"Branch: {TerminalSanitizer.EscapeMarkup(view.SourceBranch ?? source)} -> {TerminalSanitizer.EscapeMarkup(view.DestinationBranch ?? "?")}"); + if (!string.IsNullOrWhiteSpace(view.HtmlUrl)) + { + Spectre.Console.AnsiConsole.MarkupLine($"URL: {TerminalSanitizer.EscapeMarkup(view.HtmlUrl)}"); + } + + return 0; + } + } + + private static async Task TryDeriveTitleAsync(GitClient gitClient, ResolvedRepoContext repoContext, string source) + { + try + { + // Local history only describes the PR when this checkout is the target repository. + var origin = await gitClient.TryGetOriginUrlAsync(); + if (origin is null || + !BitbucketRemoteParser.TryParse(origin, out var workspace, out var repo) || + !workspace.Equals(repoContext.Workspace, StringComparison.OrdinalIgnoreCase) || + !repo.Equals(repoContext.Repo, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + // Prefer the pushed tip so unpushed local commits cannot supply the title. + return await gitClient.TryGetCommitSubjectAsync($"refs/remotes/origin/{source}") + ?? await gitClient.TryGetCommitSubjectAsync(source); + } + catch (Win32Exception) + { + // git is not installed. + return null; + } + } +} diff --git a/src/Bbt/Infrastructure/BbtAsyncCommand.cs b/src/Bbt/Infrastructure/BbtAsyncCommand.cs index 1ae9eeb..740f21a 100644 --- a/src/Bbt/Infrastructure/BbtAsyncCommand.cs +++ b/src/Bbt/Infrastructure/BbtAsyncCommand.cs @@ -19,20 +19,26 @@ public sealed override async Task ExecuteAsync(CommandContext context, TSet var authHint = ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden ? $"\nHint: verify credentials and token scopes: {BitbucketTokenScopes.MinimumCsv}" : string.Empty; - Console.Error.WriteLine($"{(int)ex.StatusCode} {ex.StatusCode}: {ex.ApiMessage ?? ex.Message}{detail}{authHint}"); + WriteError($"{(int)ex.StatusCode} {ex.StatusCode}: {ex.ApiMessage ?? ex.Message}{detail}{authHint}"); return 1; } catch (InvalidOperationException ex) { - Console.Error.WriteLine(ex.Message); + WriteError(ex.Message); return 2; } catch (Exception ex) { - Console.Error.WriteLine(ex.Message); + WriteError(ex.Message); return 1; } } + // Error text can echo user input or API responses; strip control characters so it cannot drive the terminal. + private static void WriteError(string message) + { + Console.Error.WriteLine(TerminalSanitizer.Sanitize(message)); + } + protected abstract Task ExecuteCommandAsync(CommandContext context, TSettings settings); } diff --git a/src/Bbt/Program.cs b/src/Bbt/Program.cs index 834be53..69862b6 100644 --- a/src/Bbt/Program.cs +++ b/src/Bbt/Program.cs @@ -29,6 +29,7 @@ pr.AddCommand("diff").WithDescription("Show pull request diff (raw in human mode, structured in --json; id inferred from current branch if omitted)."); pr.AddCommand("comments").WithDescription("List pull request comments (default: newest-first, one page unless --paginate/--limit requires more; id inferred from current branch if omitted)."); pr.AddCommand("threads").WithDescription("List pull request comment threads (root + replies; ordered by discovery sort (default: -created_on); id inferred from current branch if omitted)."); + pr.AddCommand("create").WithDescription("Create a pull request (source: current branch, destination: repo main branch, title: latest commit subject unless overridden)."); pr.AddCommand("comment").WithDescription("Post a pull request comment (global/inline/reply; inline default: --side to)."); pr.AddCommand("review").WithDescription("Set pull request review status (approve/request changes; --body posts global comment first)."); }); diff --git a/tests/Bbt.Core.Tests/Bitbucket/BitbucketClientCreatePullRequestTests.cs b/tests/Bbt.Core.Tests/Bitbucket/BitbucketClientCreatePullRequestTests.cs new file mode 100644 index 0000000..7356fb1 --- /dev/null +++ b/tests/Bbt.Core.Tests/Bitbucket/BitbucketClientCreatePullRequestTests.cs @@ -0,0 +1,109 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Bbt.Core.Bitbucket; +using Bbt.Core.Bitbucket.Models; + +namespace Bbt.Core.Tests.Bitbucket; + +public sealed class BitbucketClientCreatePullRequestTests +{ + [Fact] + public async Task CreatePullRequestAsync_PostsFullBody() + { + var handler = new CapturingHandler(); + using var client = CreateClient(handler); + + var created = await client.CreatePullRequestAsync( + "my-ws", + "my-repo", + new CreatePullRequestRequest + { + Title = "Add feature", + Description = "Details", + Source = CreatePullRequestEndpoint.ForBranch("feature/x"), + Destination = CreatePullRequestEndpoint.ForBranch("develop"), + Reviewers = + [ + CreatePullRequestReviewer.FromIdentifier("{11111111-2222-3333-4444-555555555555}"), + CreatePullRequestReviewer.FromIdentifier("557058:abc"), + ], + CloseSourceBranch = true, + Draft = true, + }); + + Assert.Equal(HttpMethod.Post, handler.Method); + Assert.Equal("/2.0/repositories/my-ws/my-repo/pullrequests", handler.Uri!.AbsolutePath); + Assert.Equal(42, created.Id); + + using var doc = JsonDocument.Parse(handler.Body!); + var root = doc.RootElement; + Assert.Equal("Add feature", root.GetProperty("title").GetString()); + Assert.Equal("Details", root.GetProperty("description").GetString()); + Assert.Equal("feature/x", root.GetProperty("source").GetProperty("branch").GetProperty("name").GetString()); + Assert.Equal("develop", root.GetProperty("destination").GetProperty("branch").GetProperty("name").GetString()); + Assert.True(root.GetProperty("close_source_branch").GetBoolean()); + Assert.True(root.GetProperty("draft").GetBoolean()); + + var reviewers = root.GetProperty("reviewers"); + Assert.Equal("{11111111-2222-3333-4444-555555555555}", reviewers[0].GetProperty("uuid").GetString()); + Assert.False(reviewers[0].TryGetProperty("account_id", out _)); + Assert.Equal("557058:abc", reviewers[1].GetProperty("account_id").GetString()); + Assert.False(reviewers[1].TryGetProperty("uuid", out _)); + } + + [Fact] + public async Task CreatePullRequestAsync_OmitsUnsetOptionalFields() + { + var handler = new CapturingHandler(); + using var client = CreateClient(handler); + + await client.CreatePullRequestAsync( + "my-ws", + "my-repo", + new CreatePullRequestRequest + { + Title = "Add feature", + Source = CreatePullRequestEndpoint.ForBranch("feature/x"), + }); + + using var doc = JsonDocument.Parse(handler.Body!); + var names = doc.RootElement.EnumerateObject().Select(p => p.Name).ToHashSet(); + Assert.Equal(new HashSet { "title", "source" }, names); + } + + private static BitbucketClient CreateClient(HttpMessageHandler handler) + { + return new BitbucketClient( + new BitbucketClientOptions( + BaseUri: new Uri("https://api.bitbucket.org/2.0/"), + Email: "test@example.com", + Token: "token", + Verbose: false, + NoRetry: true, + VerboseLog: null), + handler); + } + + private sealed class CapturingHandler : HttpMessageHandler + { + public HttpMethod? Method { get; private set; } + + public Uri? Uri { get; private set; } + + public string? Body { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Method = request.Method; + Uri = request.RequestUri; + Body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + + var json = "{\"id\":42,\"title\":\"Add feature\",\"state\":\"OPEN\",\"source\":{\"branch\":{\"name\":\"feature/x\"}},\"destination\":{\"branch\":{\"name\":\"develop\"}}}"; + return new HttpResponseMessage(HttpStatusCode.Created) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + } + } +} diff --git a/tests/Bbt.Core.Tests/Bitbucket/BitbucketClientRetryTests.cs b/tests/Bbt.Core.Tests/Bitbucket/BitbucketClientRetryTests.cs new file mode 100644 index 0000000..e124cc7 --- /dev/null +++ b/tests/Bbt.Core.Tests/Bitbucket/BitbucketClientRetryTests.cs @@ -0,0 +1,101 @@ +using System.Net; +using System.Text; +using Bbt.Core.Bitbucket; +using Bbt.Core.Bitbucket.Models; + +namespace Bbt.Core.Tests.Bitbucket; + +public sealed class BitbucketClientRetryTests +{ + private const string PullRequestJson = "{\"id\":42,\"title\":\"t\",\"state\":\"OPEN\"}"; + + [Fact] + public async Task Post_GatewayError_IsNotRetried() + { + var handler = new SequenceHandler(_ => Respond(HttpStatusCode.BadGateway, "{}")); + using var client = CreateClient(handler); + + await Assert.ThrowsAsync(() => client.CreatePullRequestAsync("ws", "repo", CreateRequest())); + Assert.Equal(1, handler.Calls); + } + + [Fact] + public async Task Post_NetworkError_IsNotRetried_AndSaysOutcomeUnknown() + { + var handler = new SequenceHandler(_ => throw new HttpRequestException("connection reset")); + using var client = CreateClient(handler); + + var ex = await Assert.ThrowsAsync(() => client.CreatePullRequestAsync("ws", "repo", CreateRequest())); + Assert.Equal(1, handler.Calls); + Assert.Contains("may already have been applied", ex.Message); + } + + [Fact] + public async Task Post_TooManyRequests_IsRetried() + { + var handler = new SequenceHandler(call => call == 1 + ? Respond(HttpStatusCode.TooManyRequests, "{}", retryAfterSeconds: 0) + : Respond(HttpStatusCode.Created, PullRequestJson)); + using var client = CreateClient(handler); + + var created = await client.CreatePullRequestAsync("ws", "repo", CreateRequest()); + Assert.Equal(42, created.Id); + Assert.Equal(2, handler.Calls); + } + + [Fact] + public async Task Get_GatewayError_IsRetried() + { + var handler = new SequenceHandler(call => call == 1 + ? Respond(HttpStatusCode.ServiceUnavailable, "{}", retryAfterSeconds: 0) + : Respond(HttpStatusCode.OK, PullRequestJson)); + using var client = CreateClient(handler); + + var pr = await client.GetPullRequestAsync("ws", "repo", 42); + Assert.Equal(42, pr.Id); + Assert.Equal(2, handler.Calls); + } + + private static CreatePullRequestRequest CreateRequest() + { + return new CreatePullRequestRequest { Title = "t", Source = CreatePullRequestEndpoint.ForBranch("feature/x") }; + } + + private static HttpResponseMessage Respond(HttpStatusCode status, string json, int? retryAfterSeconds = null) + { + var response = new HttpResponseMessage(status) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + if (retryAfterSeconds is not null) + { + response.Headers.TryAddWithoutValidation("Retry-After", retryAfterSeconds.Value.ToString()); + } + + return response; + } + + private static BitbucketClient CreateClient(HttpMessageHandler handler) + { + return new BitbucketClient( + new BitbucketClientOptions( + BaseUri: new Uri("https://api.bitbucket.org/2.0/"), + Email: "test@example.com", + Token: "token", + Verbose: false, + NoRetry: false, + VerboseLog: null), + handler); + } + + private sealed class SequenceHandler(Func respond) : HttpMessageHandler + { + public int Calls { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Calls++; + return Task.FromResult(respond(Calls)); + } + } +} From 6c3d63a7b393aa97eb7c1d2e8e29e5582439aef4 Mon Sep 17 00:00:00 2001 From: mkajander Date: Thu, 24 Sep 2026 01:54:42 +0300 Subject: [PATCH 2/3] Reject an empty --body-file path in pr create Validate the path before the command reads the file, the same as the --title, --source, and --destination checks. --- src/Bbt/Commands/Pr/PrCreateCommand.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Bbt/Commands/Pr/PrCreateCommand.cs b/src/Bbt/Commands/Pr/PrCreateCommand.cs index 0501fc7..66bff50 100644 --- a/src/Bbt/Commands/Pr/PrCreateCommand.cs +++ b/src/Bbt/Commands/Pr/PrCreateCommand.cs @@ -61,6 +61,11 @@ public override Spectre.Cli.ValidationResult Validate() return Spectre.Cli.ValidationResult.Error("Specify at most one of --body or --body-file."); } + if (BodyFile is not null && string.IsNullOrWhiteSpace(BodyFile)) + { + return Spectre.Cli.ValidationResult.Error("--body-file cannot be empty."); + } + if (Title is not null && string.IsNullOrWhiteSpace(Title)) { return Spectre.Cli.ValidationResult.Error("--title cannot be empty."); From 14a202f810528b0bcab7bc2db19ab38e99ccc2f5 Mon Sep 17 00:00:00 2001 From: mkajander Date: Thu, 24 Sep 2026 02:22:44 +0300 Subject: [PATCH 3/3] Trim leading whitespace from arguments before parsing Spectre.Cli 0.49.0 hangs when an argument starts with whitespace. For example, `bbt pr view " "` hangs, and `--body "$(cat notes.md)"` hangs when the file starts with a blank line. Version 0.49.0 is the latest release, so an upgrade does not fix it. Spectre trims option values, so this change does not alter a value that parses today. A whitespace-only argument becomes empty, and Spectre then shows its "no value provided" error. --- src/Bbt.Core/Util/CommandLineArguments.cs | 15 +++++++++++++ src/Bbt/Program.cs | 3 ++- .../Util/CommandLineArgumentsTests.cs | 22 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 src/Bbt.Core/Util/CommandLineArguments.cs create mode 100644 tests/Bbt.Core.Tests/Util/CommandLineArgumentsTests.cs diff --git a/src/Bbt.Core/Util/CommandLineArguments.cs b/src/Bbt.Core/Util/CommandLineArguments.cs new file mode 100644 index 0000000..2674b3d --- /dev/null +++ b/src/Bbt.Core/Util/CommandLineArguments.cs @@ -0,0 +1,15 @@ +namespace Bbt.Core.Util; + +public static class CommandLineArguments +{ + /// + /// Spectre.Cli 0.49.0 hangs forever when an argument starts with whitespace (e.g. `--body "$(cat notes.md)"` + /// where the file starts with a blank line, or `pr view " "`). Spectre trims values anyway, so trimming the + /// leading whitespace first does not change any value that parses today. A whitespace-only argument becomes + /// empty, which Spectre reports as a missing value. + /// + public static string[] TrimLeadingWhitespace(string[] args) + { + return args.Select(arg => arg.TrimStart()).ToArray(); + } +} diff --git a/src/Bbt/Program.cs b/src/Bbt/Program.cs index 69862b6..f858efe 100644 --- a/src/Bbt/Program.cs +++ b/src/Bbt/Program.cs @@ -3,6 +3,7 @@ using Bbt.Commands.Auth; using Bbt.Commands.Llms; using Bbt.Commands.Pr; +using Bbt.Core.Util; using Bbt.Infrastructure; var app = new CommandApp(); @@ -38,4 +39,4 @@ config.AddCommand("llms").WithDescription("Print full CLI capabilities in one output for LLM/tool context."); }); -return await app.RunAsync(args); +return await app.RunAsync(CommandLineArguments.TrimLeadingWhitespace(args)); diff --git a/tests/Bbt.Core.Tests/Util/CommandLineArgumentsTests.cs b/tests/Bbt.Core.Tests/Util/CommandLineArgumentsTests.cs new file mode 100644 index 0000000..09b6058 --- /dev/null +++ b/tests/Bbt.Core.Tests/Util/CommandLineArgumentsTests.cs @@ -0,0 +1,22 @@ +using Bbt.Core.Util; + +namespace Bbt.Core.Tests.Util; + +public sealed class CommandLineArgumentsTests +{ + [Fact] + public void TrimLeadingWhitespace_TrimsOnlyTheStartOfEachArgument() + { + var result = CommandLineArguments.TrimLeadingWhitespace(["pr", "create", "--body", "\n\tline one\n line two ", "--title", "a b"]); + + Assert.Equal(["pr", "create", "--body", "line one\n line two ", "--title", "a b"], result); + } + + [Fact] + public void TrimLeadingWhitespace_WhitespaceOnlyBecomesEmpty() + { + var result = CommandLineArguments.TrimLeadingWhitespace(["pr", "view", " \t "]); + + Assert.Equal(["pr", "view", ""], result); + } +}