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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,13 @@ bbt pr list
bbt pr view
```

4. Get structured diff JSON:
4. Get a normalized PR summary object for automation:

```bash
bbt pr summary --json
```

5. Get structured diff JSON:

```bash
bbt pr diff <id> --json
Expand All @@ -88,6 +94,7 @@ If `--email`/`--token` are omitted, `auth login` prompts interactively (unless s

- `bbt pr list [--state <STATE>] [--limit <n>]`
- `bbt pr view [<id>]`
- `bbt pr summary [<id>]`
- `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>]`
Expand All @@ -96,11 +103,12 @@ If `--email`/`--token` are omitted, `auth login` prompts interactively (unless s

Notes:

- For `pr view`, `pr diff`, `pr comments`, and `pr threads`, `<id>` is optional.
- For `pr view`, `pr summary`, `pr diff`, `pr comments`, and `pr threads`, `<id>` is optional.
- If `<id>` is omitted, `bbt` tries to resolve the PR from your current git branch.
- `pr list` defaults to `--state OPEN`.
- `pr comment` and `pr review` always require explicit PR id.
- Inline comments default to `--side to` (new side).
- `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 review --body/--body-file` posts a global comment first, then performs the review action.
Expand Down
25 changes: 23 additions & 2 deletions docs/contracts/bitbucket-cloud-v0.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This document pins the API contracts that `bbt` v0.1 depends on. It is intentionally narrow in scope: only the endpoints and fields used by the v0.1 command surface.

Last reviewed: 2026-02-09
Last reviewed: 2026-04-17

## 1) Base contract

Expand Down Expand Up @@ -128,6 +128,7 @@ Minimal fields `bbt` relies on:
Used by:
- `bbt pr list`
- `bbt pr view` (when `<id>` is omitted; list+filter by `source.branch.name`)
- `bbt pr summary` (when `<id>` is omitted; list+filter by `source.branch.name`)
- `bbt pr diff` (when `<id>` is omitted)
- `bbt pr comments` (when `<id>` is omitted)

Expand All @@ -154,6 +155,7 @@ Minimal fields `bbt` relies on:

Used by:
- `bbt pr view <id>`
- `bbt pr summary <id>`

Responses:
- `200 application/json`: PR object
Expand All @@ -162,9 +164,28 @@ Responses:

Minimal fields `bbt` relies on (in addition to list fields):
- `description` (string)
- `comment_count` (int; total PR comments count)
- `reviewers[]` (array of accounts)
- `participants[]` (array; used for “approved/changes_requested” state)

#### `GET /repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/activity`

Used by:
- `bbt pr summary <id>` (only when the PR state is `MERGED`, to derive `mergedAt`)

Responses:
- `200 application/json`: paginated activity log
- `401` (may be empty body)
- `404 application/json`: not found/no access

Minimal fields `bbt` relies on:
- `values[].update.state` (string; `MERGED` entries indicate the merge state transition)
- `values[].update.date` (ISO 8601; exact timestamp used for `mergedAt`)

Notes:
- `bbt` does not use `updated_on` as a merged timestamp fallback.
- If no activity entry with `update.state == MERGED` is returned, `mergedAt` is emitted as `null`.

#### `GET /repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/diff`

Used by:
Expand Down Expand Up @@ -280,6 +301,7 @@ Responses:
| `bbt auth status --check` | `GET /user` | `--check` is the only mode that hits the network. |
| `bbt pr list` | `GET /repositories/{ws}/{repo}/pullrequests?state=…` (paged) | Defaults to `state=OPEN`. |
| `bbt pr view <id>` | `GET /repositories/{ws}/{repo}/pullrequests/{id}` | If `<id>` omitted: list open PRs and filter by branch name. |
| `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 comment <id>` | `POST /repositories/{ws}/{repo}/pullrequests/{id}/comments` | Inline anchor uses `inline.*` fields. |
Expand Down Expand Up @@ -345,4 +367,3 @@ curl -u \"$BBT_EMAIL:$BBT_TOKEN\" \\
-d '{\"content\":{\"raw\":\"inline comment\",\"markup\":\"markdown\"},\"inline\":{\"path\":\"src/Foo.cs\",\"to\":42}}' \\
\"https://api.bitbucket.org/2.0/repositories/$BBT_WORKSPACE/$BBT_REPO/pullrequests/<PR_ID>/comments\"
```

12 changes: 12 additions & 0 deletions src/Bbt.Core/Bitbucket/BitbucketClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ public async Task<BitbucketPullRequest> GetPullRequestAsync(string workspace, st
cancellationToken);
}

public async Task<BitbucketPaginated<BitbucketPullRequestActivity>> ListPullRequestActivityAsync(
string workspace,
string repo,
int pullRequestId,
int pageLen = 50,
string? pageUrl = null,
CancellationToken cancellationToken = default)
{
var url = pageUrl ?? $"repositories/{Uri.EscapeDataString(workspace)}/{Uri.EscapeDataString(repo)}/pullrequests/{pullRequestId}/activity?pagelen={pageLen}";
return await SendJsonAsync<BitbucketPaginated<BitbucketPullRequestActivity>>(() => new HttpRequestMessage(HttpMethod.Get, url), cancellationToken);
}

public async Task<string> GetPullRequestDiffAsync(string workspace, string repo, int pullRequestId, CancellationToken cancellationToken = default)
{
var startUrl = $"repositories/{Uri.EscapeDataString(workspace)}/{Uri.EscapeDataString(repo)}/pullrequests/{pullRequestId}/diff";
Expand Down
4 changes: 3 additions & 1 deletion src/Bbt.Core/Bitbucket/Models/BitbucketPullRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ public sealed class BitbucketPullRequest
[JsonPropertyName("updated_on")]
public DateTimeOffset? UpdatedOn { get; set; }

[JsonPropertyName("comment_count")]
public int? CommentCount { get; set; }

[JsonPropertyName("reviewers")]
public List<BitbucketAccount>? Reviewers { get; set; }

Expand All @@ -52,4 +55,3 @@ public sealed class BitbucketBranch
[JsonPropertyName("name")]
public string? Name { get; set; }
}

18 changes: 18 additions & 0 deletions src/Bbt.Core/Bitbucket/Models/BitbucketPullRequestActivity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;

namespace Bbt.Core.Bitbucket.Models;

public sealed class BitbucketPullRequestActivity
{
[JsonPropertyName("update")]
public BitbucketPullRequestActivityUpdate? Update { get; set; }
}

public sealed class BitbucketPullRequestActivityUpdate
{
[JsonPropertyName("state")]
public string? State { get; set; }

[JsonPropertyName("date")]
public DateTimeOffset? Date { get; set; }
}
27 changes: 27 additions & 0 deletions src/Bbt.Core/Bitbucket/PullRequestActivityAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Bbt.Core.Bitbucket.Models;

namespace Bbt.Core.Bitbucket;

public static class PullRequestActivityAnalyzer
{
public static DateTimeOffset? TryGetMergedAt(IEnumerable<BitbucketPullRequestActivity> activities)
{
DateTimeOffset? mergedAt = null;

foreach (var activity in activities)
{
var update = activity.Update;
if (update?.Date is null || !string.Equals(update.State, "MERGED", StringComparison.OrdinalIgnoreCase))
{
continue;
}

if (mergedAt is null || update.Date > mergedAt)
{
mergedAt = update.Date;
}
}

return mergedAt;
}
}
3 changes: 2 additions & 1 deletion src/Bbt.Core/Diff/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ public sealed record DiffHunk(

public sealed record DiffFile(string Path, bool IsBinary, List<DiffHunk> Hunks);

public sealed record PullRequestDiffStats(int FilesChanged, int LinesAdded, int LinesRemoved);

public sealed record PullRequestDiff(
int PullRequestId,
string Workspace,
string Repo,
List<DiffFile> Files,
string? RawDiff);

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

public static class PullRequestDiffStatsCalculator
{
public static PullRequestDiffStats Calculate(IEnumerable<DiffFile> files)
{
var filesChanged = 0;
var linesAdded = 0;
var linesRemoved = 0;

foreach (var file in files)
{
filesChanged++;

foreach (var hunk in file.Hunks)
{
foreach (var line in hunk.Lines)
{
switch (line.Type)
{
case DiffLineType.Add:
linesAdded++;
break;
case DiffLineType.Del:
linesRemoved++;
break;
}
}
}
}

return new PullRequestDiffStats(filesChanged, linesAdded, linesRemoved);
}
}
3 changes: 2 additions & 1 deletion src/Bbt/Commands/Llms/LlmsCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ private static LlmsDocument BuildDocument(string version)
new LlmsCommandEntry("bbt auth logout [--profile <name>]", "Delete token and profile."),
new LlmsCommandEntry("bbt pr list [--state <OPEN|MERGED|DECLINED|SUPERSEDED>] [--limit <n>]", "List pull requests (default state: OPEN)."),
new LlmsCommandEntry("bbt pr view [<id>]", "View pull request details. Infers id from current branch when omitted."),
new LlmsCommandEntry("bbt pr summary [<id>]", "Show a normalized pull request summary object for automation. Output: --quiet prints the PR id; --json emits one summary object with review, comment, diff, and mergedAt fields."),
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}."),
Expand All @@ -92,7 +93,7 @@ private static LlmsDocument BuildDocument(string version)
"Current profile defaults",
"Git origin parsing"
],
PullRequestIdInference: "For pr view/diff/comments/threads, omitted id resolves from current branch's open PR.",
PullRequestIdInference: "For pr view/summary/diff/comments/threads, omitted id resolves from current branch's open PR.",
MutationSafety: "pr comment and pr review require explicit pull request id.",
FieldsSelection: "For arrays, selected fields missing on some items are returned as null; unknown fields still fail."));
}
Expand Down
114 changes: 114 additions & 0 deletions src/Bbt/Commands/Pr/PrSummaryCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System.ComponentModel;
using Bbt.Core.Auth;
using Bbt.Core.Bitbucket;
using Bbt.Core.Config;
using Bbt.Core.Context;
using Bbt.Core.Diff;
using Bbt.Core.Git;
using Bbt.Core.IO;
using Bbt.Infrastructure;
using Spectre.Cli;
using Spectre.Console;

namespace Bbt.Commands.Pr;

public sealed class PrSummaryCommand : BbtAsyncCommand<PrSummaryCommand.Settings>
{
public sealed class Settings : BbtRepoSettings
{
[Description("Pull request id (optional; inferred from current branch if omitted).")]
[CommandArgument(0, "[ID]")]
public int? Id { get; init; }
}

protected override async Task<int> 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 auth = await AuthContextResolver.ResolveAsync(configStore, credentialStore, profileOverride: null, requireToken: true);
using var client = AuthContextResolver.CreateClient(auth, settings.Verbose, settings.NoRetry);

var prId = await PullRequestIdResolver.ResolveAsync(client, gitClient, repoContext.Workspace, repoContext.Repo, settings.Id);
var prTask = client.GetPullRequestAsync(repoContext.Workspace, repoContext.Repo, prId);
var rawDiffTask = client.GetPullRequestDiffAsync(repoContext.Workspace, repoContext.Repo, prId);

await Task.WhenAll(prTask, rawDiffTask);

var pr = await prTask;
var files = UnifiedDiffParser.Parse(await rawDiffTask);
var diffStats = PullRequestDiffStatsCalculator.Calculate(files);
var mergedAt = await ResolveMergedAtAsync(client, repoContext.Workspace, repoContext.Repo, pr);
var summary = ModelMappers.ToPullRequestSummary(pr, repoContext.Workspace, repoContext.Repo, diffStats, mergedAt);

switch (settings.GetOutputMode())
{
case OutputMode.Quiet:
OutputWriter.WriteQuiet(summary.PrId.ToString());
return 0;
case OutputMode.Json:
await new OutputWriter(processRunner).WriteJsonAsync(summary, settings);
return 0;
default:
Spectre.Console.AnsiConsole.MarkupLine($"[yellow]#{summary.PrId}[/] {TerminalSanitizer.EscapeMarkup(summary.Title)}");
Spectre.Console.AnsiConsole.MarkupLine($"State: {TerminalSanitizer.EscapeMarkup(summary.State)}");
Spectre.Console.AnsiConsole.MarkupLine($"Branches: {TerminalSanitizer.EscapeMarkup(summary.SourceBranch ?? "?")} -> {TerminalSanitizer.EscapeMarkup(summary.TargetBranch ?? "?")}");
Spectre.Console.AnsiConsole.MarkupLine($"Reviews: {summary.Approvals} approvals, {summary.ChangesRequested} changes requested");
Spectre.Console.AnsiConsole.MarkupLine($"Comments: {summary.CommentCount}");
Spectre.Console.AnsiConsole.MarkupLine($"Diff: {summary.FilesChanged} files, +{summary.LinesAdded} / -{summary.LinesRemoved}");

if (summary.OpenedAt is not null)
{
Spectre.Console.AnsiConsole.MarkupLine($"Opened: {TerminalSanitizer.EscapeMarkup(summary.OpenedAt.Value.ToString("u"))}");
}

if (summary.MergedAt is not null)
{
Spectre.Console.AnsiConsole.MarkupLine($"Merged: {TerminalSanitizer.EscapeMarkup(summary.MergedAt.Value.ToString("u"))}");
}
else if (string.Equals(summary.State, "MERGED", StringComparison.OrdinalIgnoreCase))
{
Spectre.Console.AnsiConsole.MarkupLine("Merged: unavailable");
}

if (!string.IsNullOrWhiteSpace(summary.HtmlUrl))
{
Spectre.Console.AnsiConsole.MarkupLine($"URL: {TerminalSanitizer.EscapeMarkup(summary.HtmlUrl)}");
}

return 0;
}
}

private static async Task<DateTimeOffset?> ResolveMergedAtAsync(
BitbucketClient client,
string workspace,
string repo,
Bbt.Core.Bitbucket.Models.BitbucketPullRequest pr)
{
if (!string.Equals(pr.State, "MERGED", StringComparison.OrdinalIgnoreCase))
{
return null;
}

// Bitbucket's PR object does not expose a dedicated merged timestamp in the fields we use,
// so prefer the activity log's MERGED state transition over guessing from updated_on.
var activities = await BitbucketPager.GetAllAsync(
async (next, ct) => await client.ListPullRequestActivityAsync(workspace, repo, pr.Id, pageLen: 100, pageUrl: next, cancellationToken: ct),
limit: null,
cancellationToken: default);

return PullRequestActivityAnalyzer.TryGetMergedAt(activities);
}
}
Loading
Loading