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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ If `--email`/`--token` are omitted, `auth login` prompts interactively (unless s
- `bbt pr diff [<id>] [--include-raw]`
- `bbt pr comments [<id>] [--limit <n>] [--sort <expr>] [--page <n>] [--pagelen <n>] [--paginate] [--contains <text> | -q/--query <expr>]`
- `bbt pr threads [<id>] [--limit <n>] [--sort <expr>] [--pagelen <n>] [--contains <text> | -q/--query <expr>]`
- `bbt pr create [-t/--title <text>] [--body <text>|--body-file <path>] [-s/--source <branch>] [-d/--destination <branch>] [-r/--reviewer <id>]... [--close-source-branch] [--draft]`
- `bbt pr comment <id> (--body <text> | --body-file <path>) [--reply-to <comment-id>] [--file <path> --line <n> [--line-end <n>] [--side <to|from>]]`
- `bbt pr review <id> (--approve|--unapprove|--request-changes|--unrequest-changes) [--body <text>|--body-file <path>]`

Expand All @@ -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/<source>` 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)

Expand Down
32 changes: 32 additions & 0 deletions docs/contracts/bitbucket-cloud-v0.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -304,6 +335,7 @@ Responses:
| `bbt pr summary <id>` | `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 <id>` | `GET /repositories/{ws}/{repo}/pullrequests/{id}/diff` (follow 302) | Human mode prints raw diff; JSON mode parses diff. |
| `bbt pr comments <id>` | `GET /repositories/{ws}/{repo}/pullrequests/{id}/comments` (paged) | If `<id>` 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 <id>` | `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. |
Expand Down
33 changes: 24 additions & 9 deletions src/Bbt.Core/Bitbucket/BitbucketClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,23 @@ public async Task<BitbucketPullRequest> GetPullRequestAsync(string workspace, st
cancellationToken);
}

public async Task<BitbucketPullRequest> 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<BitbucketPullRequest>(
() => new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
},
cancellationToken);
}

public async Task<BitbucketPaginated<BitbucketPullRequestActivity>> ListPullRequestActivityAsync(
string workspace,
string repo,
Expand Down Expand Up @@ -305,14 +322,16 @@ private async Task<HttpResponseMessage> 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;
}
Expand All @@ -328,6 +347,10 @@ private async Task<HttpResponseMessage> 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)));
Expand Down Expand Up @@ -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))
Expand Down
38 changes: 38 additions & 0 deletions src/Bbt.Core/Bitbucket/BitbucketRetryPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.Net;

namespace Bbt.Core.Bitbucket;

public static class BitbucketRetryPolicy
{
/// <summary>
/// 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).
/// </summary>
public static bool IsIdempotent(HttpMethod method)
{
return method != HttpMethod.Post && method != HttpMethod.Patch;
}

/// <summary>
/// 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.
/// </summary>
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);
}
}
68 changes: 68 additions & 0 deletions src/Bbt.Core/Bitbucket/Models/CreatePullRequestRequest.cs
Original file line number Diff line number Diff line change
@@ -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<CreatePullRequestReviewer>? 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; }

/// <summary>
/// Values wrapped in braces (e.g. "{1234-...}") are treated as UUIDs; anything else as an Atlassian account id.
/// </summary>
public static CreatePullRequestReviewer FromIdentifier(string value)
{
var trimmed = value.Trim();
return trimmed.StartsWith('{') && trimmed.EndsWith('}')
? new CreatePullRequestReviewer { Uuid = trimmed }
: new CreatePullRequestReviewer { AccountId = trimmed };
}
}
12 changes: 12 additions & 0 deletions src/Bbt.Core/Git/GitClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,17 @@ public async Task<bool> IsInsideWorkTreeAsync(CancellationToken cancellationToke

return value;
}

public async Task<string?> 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;
}
}

15 changes: 15 additions & 0 deletions src/Bbt.Core/Util/CommandLineArguments.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Bbt.Core.Util;

public static class CommandLineArguments
{
/// <summary>
/// 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.
/// </summary>
public static string[] TrimLeadingWhitespace(string[] args)
{
return args.Select(arg => arg.TrimStart()).ToArray();
}
}
17 changes: 8 additions & 9 deletions src/Bbt/Commands/Api/ApiCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -426,14 +427,16 @@ private static async Task<HttpResponseMessage> 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;
}
Expand All @@ -452,6 +455,10 @@ private static async Task<HttpResponseMessage> 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)));
Expand Down Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions src/Bbt/Commands/Llms/LlmsCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ private static LlmsDocument BuildDocument(string version)
new LlmsCommandEntry("bbt pr diff [<id>] [--include-raw]", "Show raw diff in human mode, structured diff in JSON mode."),
new LlmsCommandEntry("bbt pr comments [<id>] [--limit <n>] [--sort <expr>] [--page <n>] [--pagelen <n>] [--paginate] [--contains <text> | -q/--query <expr>]", "List pull request comments (default: newest-first and one page unless --paginate/--limit requires more)."),
new LlmsCommandEntry("bbt pr threads [<id>] [--limit <n>] [--sort <expr>] [--pagelen <n>] [--contains <text> | -q/--query <expr>]", "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 <text>] [--body <text>|--body-file <path>] [-s/--source <branch>] [-d/--destination <branch>] [-r/--reviewer <uuid|account-id>]... [--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 <id> (--body <text> | --body-file <path>) [--reply-to <comment-id>] [--file <path> --line <n> [--line-end <n>] [--side <to|from>]]", "Post global, inline, or reply comment (default inline side: to)."),
new LlmsCommandEntry("bbt pr review <id> (--approve|--unapprove|--request-changes|--unrequest-changes) [--body <text>|--body-file <path>]", "Apply review status; optional body posts global comment first."),
new LlmsCommandEntry("bbt api <PATH> <METHOD> [--input <file>] [--paginate]", "Raw Bitbucket API access with placeholder replacement for {workspace}/{repo}. <METHOD> <PATH> order is also accepted."),
Expand Down
Loading
Loading