diff --git a/README.md b/README.md index bd3bfd2..6f16aee 100644 --- a/README.md +++ b/README.md @@ -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 --json @@ -88,6 +94,7 @@ If `--email`/`--token` are omitted, `auth login` prompts interactively (unless s - `bbt pr list [--state ] [--limit ]` - `bbt pr view []` +- `bbt pr summary []` - `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 ]` @@ -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`, `` is optional. +- For `pr view`, `pr summary`, `pr diff`, `pr comments`, and `pr threads`, `` is optional. - If `` 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. diff --git a/docs/contracts/bitbucket-cloud-v0.1.md b/docs/contracts/bitbucket-cloud-v0.1.md index 53a4da8..73c25f2 100644 --- a/docs/contracts/bitbucket-cloud-v0.1.md +++ b/docs/contracts/bitbucket-cloud-v0.1.md @@ -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 @@ -128,6 +128,7 @@ Minimal fields `bbt` relies on: Used by: - `bbt pr list` - `bbt pr view` (when `` is omitted; list+filter by `source.branch.name`) +- `bbt pr summary` (when `` is omitted; list+filter by `source.branch.name`) - `bbt pr diff` (when `` is omitted) - `bbt pr comments` (when `` is omitted) @@ -154,6 +155,7 @@ Minimal fields `bbt` relies on: Used by: - `bbt pr view ` +- `bbt pr summary ` Responses: - `200 application/json`: PR object @@ -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 ` (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: @@ -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 ` | `GET /repositories/{ws}/{repo}/pullrequests/{id}` | If `` omitted: list open PRs and filter by branch name. | +| `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 comment ` | `POST /repositories/{ws}/{repo}/pullrequests/{id}/comments` | Inline anchor uses `inline.*` fields. | @@ -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//comments\" ``` - diff --git a/src/Bbt.Core/Bitbucket/BitbucketClient.cs b/src/Bbt.Core/Bitbucket/BitbucketClient.cs index b2869b8..ead9a29 100644 --- a/src/Bbt.Core/Bitbucket/BitbucketClient.cs +++ b/src/Bbt.Core/Bitbucket/BitbucketClient.cs @@ -83,6 +83,18 @@ public async Task GetPullRequestAsync(string workspace, st cancellationToken); } + public async Task> 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>(() => new HttpRequestMessage(HttpMethod.Get, url), cancellationToken); + } + public async Task GetPullRequestDiffAsync(string workspace, string repo, int pullRequestId, CancellationToken cancellationToken = default) { var startUrl = $"repositories/{Uri.EscapeDataString(workspace)}/{Uri.EscapeDataString(repo)}/pullrequests/{pullRequestId}/diff"; diff --git a/src/Bbt.Core/Bitbucket/Models/BitbucketPullRequest.cs b/src/Bbt.Core/Bitbucket/Models/BitbucketPullRequest.cs index d89d0d5..73c3acd 100644 --- a/src/Bbt.Core/Bitbucket/Models/BitbucketPullRequest.cs +++ b/src/Bbt.Core/Bitbucket/Models/BitbucketPullRequest.cs @@ -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? Reviewers { get; set; } @@ -52,4 +55,3 @@ public sealed class BitbucketBranch [JsonPropertyName("name")] public string? Name { get; set; } } - diff --git a/src/Bbt.Core/Bitbucket/Models/BitbucketPullRequestActivity.cs b/src/Bbt.Core/Bitbucket/Models/BitbucketPullRequestActivity.cs new file mode 100644 index 0000000..507b3ea --- /dev/null +++ b/src/Bbt.Core/Bitbucket/Models/BitbucketPullRequestActivity.cs @@ -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; } +} diff --git a/src/Bbt.Core/Bitbucket/PullRequestActivityAnalyzer.cs b/src/Bbt.Core/Bitbucket/PullRequestActivityAnalyzer.cs new file mode 100644 index 0000000..66d300d --- /dev/null +++ b/src/Bbt.Core/Bitbucket/PullRequestActivityAnalyzer.cs @@ -0,0 +1,27 @@ +using Bbt.Core.Bitbucket.Models; + +namespace Bbt.Core.Bitbucket; + +public static class PullRequestActivityAnalyzer +{ + public static DateTimeOffset? TryGetMergedAt(IEnumerable 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; + } +} diff --git a/src/Bbt.Core/Diff/Models.cs b/src/Bbt.Core/Diff/Models.cs index 41e3bd4..b7df200 100644 --- a/src/Bbt.Core/Diff/Models.cs +++ b/src/Bbt.Core/Diff/Models.cs @@ -23,10 +23,11 @@ public sealed record DiffHunk( public sealed record DiffFile(string Path, bool IsBinary, List Hunks); +public sealed record PullRequestDiffStats(int FilesChanged, int LinesAdded, int LinesRemoved); + public sealed record PullRequestDiff( int PullRequestId, string Workspace, string Repo, List Files, string? RawDiff); - diff --git a/src/Bbt.Core/Diff/PullRequestDiffStatsCalculator.cs b/src/Bbt.Core/Diff/PullRequestDiffStatsCalculator.cs new file mode 100644 index 0000000..4990b06 --- /dev/null +++ b/src/Bbt.Core/Diff/PullRequestDiffStatsCalculator.cs @@ -0,0 +1,34 @@ +namespace Bbt.Core.Diff; + +public static class PullRequestDiffStatsCalculator +{ + public static PullRequestDiffStats Calculate(IEnumerable 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); + } +} diff --git a/src/Bbt/Commands/Llms/LlmsCommand.cs b/src/Bbt/Commands/Llms/LlmsCommand.cs index 97035df..c1ea438 100644 --- a/src/Bbt/Commands/Llms/LlmsCommand.cs +++ b/src/Bbt/Commands/Llms/LlmsCommand.cs @@ -76,6 +76,7 @@ private static LlmsDocument BuildDocument(string version) new LlmsCommandEntry("bbt auth logout [--profile ]", "Delete token and profile."), new LlmsCommandEntry("bbt pr list [--state ] [--limit ]", "List pull requests (default state: OPEN)."), new LlmsCommandEntry("bbt pr view []", "View pull request details. Infers id from current branch when omitted."), + new LlmsCommandEntry("bbt pr summary []", "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 [] [--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}."), @@ -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.")); } diff --git a/src/Bbt/Commands/Pr/PrSummaryCommand.cs b/src/Bbt/Commands/Pr/PrSummaryCommand.cs new file mode 100644 index 0000000..985a07e --- /dev/null +++ b/src/Bbt/Commands/Pr/PrSummaryCommand.cs @@ -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 +{ + 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 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 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); + } +} diff --git a/src/Bbt/Infrastructure/ModelMappers.cs b/src/Bbt/Infrastructure/ModelMappers.cs index e603f21..53ca1b7 100644 --- a/src/Bbt/Infrastructure/ModelMappers.cs +++ b/src/Bbt/Infrastructure/ModelMappers.cs @@ -1,4 +1,5 @@ using Bbt.Core.Bitbucket.Models; +using Bbt.Core.Diff; using Bbt.Models; namespace Bbt.Infrastructure; @@ -49,6 +50,37 @@ public static PullRequestView ToPullRequestView(BitbucketPullRequest pr) Participants: participants); } + public static PullRequestSummary ToPullRequestSummary( + BitbucketPullRequest pr, + string workspace, + string repo, + PullRequestDiffStats diffStats, + DateTimeOffset? mergedAt) + { + var reviewers = pr.Reviewers?.Select(ToUserSummary).Where(x => x is not null).Cast().ToList() ?? []; + + return new PullRequestSummary( + PrId: pr.Id, + Workspace: workspace, + Repo: repo, + Title: pr.Title, + State: pr.State, + Author: ToUserSummary(pr.Author), + SourceBranch: pr.Source?.Branch?.Name, + TargetBranch: pr.Destination?.Branch?.Name, + HtmlUrl: pr.Links?.Html?.Href, + OpenedAt: pr.CreatedOn, + UpdatedAt: pr.UpdatedOn, + MergedAt: mergedAt, + Reviewers: reviewers, + Approvals: CountApprovals(pr.Participants), + ChangesRequested: CountChangesRequested(pr.Participants), + CommentCount: pr.CommentCount ?? 0, + FilesChanged: diffStats.FilesChanged, + LinesAdded: diffStats.LinesAdded, + LinesRemoved: diffStats.LinesRemoved); + } + public static PullRequestComment ToPullRequestComment(BitbucketComment comment) { return new PullRequestComment( @@ -69,4 +101,34 @@ public static PullRequestComment ToPullRequestComment(BitbucketComment comment) StartTo: comment.Inline.StartTo, StartFrom: comment.Inline.StartFrom)); } + + private static int CountApprovals(IEnumerable? participants) + { + return participants?.Count(p => !IsChangesRequestedState(p.State) && (p.Approved == true || IsApprovedState(p.State))) ?? 0; + } + + private static int CountChangesRequested(IEnumerable? participants) + { + return participants?.Count(p => IsChangesRequestedState(p.State)) ?? 0; + } + + private static bool IsApprovedState(string? state) + { + return string.Equals(NormalizeState(state), "approved", StringComparison.Ordinal); + } + + private static bool IsChangesRequestedState(string? state) + { + return NormalizeState(state) is "changesrequested" or "requestchanges" or "requestedchanges" or "needswork"; + } + + private static string NormalizeState(string? state) + { + if (string.IsNullOrWhiteSpace(state)) + { + return string.Empty; + } + + return new string(state.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant(); + } } diff --git a/src/Bbt/Models/PullRequestModels.cs b/src/Bbt/Models/PullRequestModels.cs index 085cd76..1cc1cc7 100644 --- a/src/Bbt/Models/PullRequestModels.cs +++ b/src/Bbt/Models/PullRequestModels.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace Bbt.Models; public sealed record PullRequestListItem( @@ -25,10 +27,31 @@ public sealed record PullRequestView( List? Reviewers, List? Participants); +public sealed record PullRequestSummary( + int PrId, + string Workspace, + string Repo, + string Title, + string State, + UserSummary? Author, + string? SourceBranch, + string? TargetBranch, + string? HtmlUrl, + DateTimeOffset? OpenedAt, + DateTimeOffset? UpdatedAt, + [property: JsonIgnore(Condition = JsonIgnoreCondition.Never)] + DateTimeOffset? MergedAt, + List Reviewers, + int Approvals, + int ChangesRequested, + int CommentCount, + int FilesChanged, + int LinesAdded, + int LinesRemoved); + public sealed record PullRequestParticipant( UserSummary? User, string? Role, bool? Approved, string? State, DateTimeOffset? ParticipatedOn); - diff --git a/src/Bbt/Program.cs b/src/Bbt/Program.cs index 01ba4ee..834be53 100644 --- a/src/Bbt/Program.cs +++ b/src/Bbt/Program.cs @@ -25,6 +25,7 @@ pr.SetDescription("Pull request operations."); pr.AddCommand("list").WithDescription("List pull requests (default: --state OPEN)."); pr.AddCommand("view").WithDescription("View pull request details (id inferred from current branch if omitted)."); + pr.AddCommand("summary").WithDescription("Show normalized pull request summary metrics (id inferred from current branch if omitted)."); 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)."); diff --git a/tests/Bbt.Core.Tests/Bitbucket/PullRequestActivityAnalyzerTests.cs b/tests/Bbt.Core.Tests/Bitbucket/PullRequestActivityAnalyzerTests.cs new file mode 100644 index 0000000..6562142 --- /dev/null +++ b/tests/Bbt.Core.Tests/Bitbucket/PullRequestActivityAnalyzerTests.cs @@ -0,0 +1,63 @@ +using Bbt.Core.Bitbucket; +using Bbt.Core.Bitbucket.Models; + +namespace Bbt.Core.Tests.Bitbucket; + +public sealed class PullRequestActivityAnalyzerTests +{ + [Fact] + public void TryGetMergedAt_ReturnsLatestMergedUpdateTimestamp() + { + var activities = new[] + { + new BitbucketPullRequestActivity + { + Update = new BitbucketPullRequestActivityUpdate + { + State = "OPEN", + Date = DateTimeOffset.Parse("2026-04-01T12:00:00Z"), + }, + }, + new BitbucketPullRequestActivity + { + Update = new BitbucketPullRequestActivityUpdate + { + State = "MERGED", + Date = DateTimeOffset.Parse("2026-04-02T13:00:00Z"), + }, + }, + new BitbucketPullRequestActivity + { + Update = new BitbucketPullRequestActivityUpdate + { + State = "merged", + Date = DateTimeOffset.Parse("2026-04-02T13:05:00Z"), + }, + }, + }; + + var mergedAt = PullRequestActivityAnalyzer.TryGetMergedAt(activities); + + Assert.Equal(DateTimeOffset.Parse("2026-04-02T13:05:00Z"), mergedAt); + } + + [Fact] + public void TryGetMergedAt_ReturnsNullWhenMergedUpdateIsMissing() + { + var activities = new[] + { + new BitbucketPullRequestActivity + { + Update = new BitbucketPullRequestActivityUpdate + { + State = "OPEN", + Date = DateTimeOffset.Parse("2026-04-01T12:00:00Z"), + }, + }, + }; + + var mergedAt = PullRequestActivityAnalyzer.TryGetMergedAt(activities); + + Assert.Null(mergedAt); + } +} diff --git a/tests/Bbt.Core.Tests/Bitbucket/PullRequestSummaryModelTests.cs b/tests/Bbt.Core.Tests/Bitbucket/PullRequestSummaryModelTests.cs new file mode 100644 index 0000000..12a5490 --- /dev/null +++ b/tests/Bbt.Core.Tests/Bitbucket/PullRequestSummaryModelTests.cs @@ -0,0 +1,123 @@ +using Bbt.Core.Bitbucket.Models; +using Bbt.Core.Diff; +using Bbt.Infrastructure; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Bbt.Core.Tests.Bitbucket; + +public sealed class PullRequestSummaryModelTests +{ + [Fact] + public void ModelMappers_ToPullRequestSummary_MapsCountsBranchesAndMergedAt() + { + var pr = new BitbucketPullRequest + { + Id = 123, + Title = "Add summary command", + State = "MERGED", + Author = new BitbucketAccount + { + DisplayName = "Author Name", + Nickname = "author", + Uuid = "{author}", + }, + Source = new BitbucketPullRequestEndpoint + { + Branch = new BitbucketBranch { Name = "feature/summary" }, + }, + Destination = new BitbucketPullRequestEndpoint + { + Branch = new BitbucketBranch { Name = "main" }, + }, + CreatedOn = DateTimeOffset.Parse("2026-04-01T12:34:56Z"), + UpdatedOn = DateTimeOffset.Parse("2026-04-02T13:10:00Z"), + CommentCount = 14, + Reviewers = + [ + new BitbucketAccount + { + DisplayName = "Reviewer Name", + Nickname = "reviewer", + Uuid = "{reviewer}", + }, + ], + Participants = + [ + new BitbucketParticipant + { + Approved = true, + State = "approved", + }, + new BitbucketParticipant + { + Approved = false, + State = "changes_requested", + }, + new BitbucketParticipant + { + Approved = false, + State = "needs_work", + }, + new BitbucketParticipant + { + Approved = false, + State = null, + }, + ], + }; + + var summary = ModelMappers.ToPullRequestSummary( + pr, + workspace: "my-workspace", + repo: "my-repo", + diffStats: new PullRequestDiffStats(FilesChanged: 5, LinesAdded: 120, LinesRemoved: 37), + mergedAt: DateTimeOffset.Parse("2026-04-02T13:00:00Z")); + + Assert.Equal(123, summary.PrId); + Assert.Equal("my-workspace", summary.Workspace); + Assert.Equal("my-repo", summary.Repo); + Assert.Equal("feature/summary", summary.SourceBranch); + Assert.Equal("main", summary.TargetBranch); + Assert.Equal(14, summary.CommentCount); + Assert.Equal(5, summary.FilesChanged); + Assert.Equal(120, summary.LinesAdded); + Assert.Equal(37, summary.LinesRemoved); + Assert.Equal(1, summary.Approvals); + Assert.Equal(2, summary.ChangesRequested); + Assert.Equal(DateTimeOffset.Parse("2026-04-02T13:00:00Z"), summary.MergedAt); + Assert.Single(summary.Reviewers); + Assert.Equal("Author Name", summary.Author!.DisplayName); + } + + [Fact] + public void PullRequestSummary_Serialization_IncludesMergedAtWhenNull() + { + var summary = new Bbt.Models.PullRequestSummary( + PrId: 1, + Workspace: "mkdevforge", + Repo: "bbt-demo", + Title: "Test", + State: "OPEN", + Author: null, + SourceBranch: "test-pr", + TargetBranch: "main", + HtmlUrl: "https://bitbucket.org/mkdevforge/bbt-demo/pull-requests/1", + OpenedAt: DateTimeOffset.Parse("2026-02-09T21:30:28.727793+00:00"), + UpdatedAt: DateTimeOffset.Parse("2026-02-13T16:17:08.848810+00:00"), + MergedAt: null, + Reviewers: [], + Approvals: 0, + ChangesRequested: 0, + CommentCount: 111, + FilesChanged: 14, + LinesAdded: 839, + LinesRemoved: 112); + + var json = JsonSerializer.Serialize(summary, BbtJson.OutputSerializerOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + Assert.True(node.ContainsKey("mergedAt")); + Assert.Null(node["mergedAt"]); + } +} diff --git a/tests/Bbt.Core.Tests/Diff/PullRequestDiffStatsCalculatorTests.cs b/tests/Bbt.Core.Tests/Diff/PullRequestDiffStatsCalculatorTests.cs new file mode 100644 index 0000000..4e4afbf --- /dev/null +++ b/tests/Bbt.Core.Tests/Diff/PullRequestDiffStatsCalculatorTests.cs @@ -0,0 +1,43 @@ +using Bbt.Core.Diff; + +namespace Bbt.Core.Tests.Diff; + +public sealed class PullRequestDiffStatsCalculatorTests +{ + [Fact] + public void Calculate_CountsFilesAddedAndRemovedLines() + { + var files = new List + { + new( + Path: "src/a.cs", + IsBinary: false, + Hunks: + [ + new DiffHunk( + Header: "@@ -1,2 +1,3 @@", + OldStart: 1, + OldCount: 2, + NewStart: 1, + NewCount: 3, + Lines: + [ + new DiffLine(DiffLineType.Context, 1, 1, "same"), + new DiffLine(DiffLineType.Del, 2, null, "old"), + new DiffLine(DiffLineType.Add, null, 2, "new"), + new DiffLine(DiffLineType.Add, null, 3, "extra"), + ]) + ]), + new( + Path: "bin/tool.dat", + IsBinary: true, + Hunks: []) + }; + + var stats = PullRequestDiffStatsCalculator.Calculate(files); + + Assert.Equal(2, stats.FilesChanged); + Assert.Equal(2, stats.LinesAdded); + Assert.Equal(1, stats.LinesRemoved); + } +}