From bf71f6ea15397d4cbc8ef2dbccffbe2ab75d3ecf Mon Sep 17 00:00:00 2001 From: Vitautas Brazas Date: Tue, 23 Jun 2026 20:11:37 +0300 Subject: [PATCH] feat(comments): expose thread resolution in list output (CLI + MCP) Bitbucket's default PR-comments projection omits the resolution object, so callers cannot tell an open thread from a resolved one. Request it with fields=+values.resolution.user.display_name, add a Resolution field to PRComment, and surface a Resolved column in the CLI table. The MCP list tool returns the same struct, so it gains resolution for free. Fixture + test cover resolved (non-nil), explicit-null, and absent cases. --- cmd/cli/prs_comments.go | 10 +- internal/bitbucket/comments.go | 7 +- internal/bitbucket/fixtures_test.go | 41 +++++ .../testdata/fixtures/list_comments.yaml | 144 +++++++++--------- internal/bitbucket/types.go | 33 ++-- 5 files changed, 151 insertions(+), 84 deletions(-) diff --git a/cmd/cli/prs_comments.go b/cmd/cli/prs_comments.go index 6525baf..1c1a48f 100644 --- a/cmd/cli/prs_comments.go +++ b/cmd/cli/prs_comments.go @@ -59,7 +59,7 @@ var prCommentsListCmd = &cobra.Command{ return } t := NewTable() - t.Header("ID", "Author", "Content", "Created") + t.Header("ID", "Author", "Resolved", "Content", "Created") for _, c := range result.Values { author := "-" if c.User != nil { @@ -69,9 +69,17 @@ var prCommentsListCmd = &cobra.Command{ if c.Deleted { content = "(deleted)" } + resolved := "-" + if c.Resolution != nil { + resolved = "✓" + if c.Resolution.User != nil && c.Resolution.User.DisplayName != "" { + resolved = "✓ " + c.Resolution.User.DisplayName + } + } t.Row( fmt.Sprintf("%d", c.ID), author, + resolved, content, FormatTime(c.CreatedOn), ) diff --git a/internal/bitbucket/comments.go b/internal/bitbucket/comments.go index 660010d..fb040ca 100644 --- a/internal/bitbucket/comments.go +++ b/internal/bitbucket/comments.go @@ -28,8 +28,13 @@ func (c *Client) ListPRComments(args ListPRCommentsArgs) (*Paginated[PRComment], page = 1 } + // resolutionFields asks Bitbucket to ADD the resolution object (and its + // resolver) to the default comments projection, which otherwise omits it. + // "+" must be percent-encoded as %2B; kept out of the format string so fmt + // doesn't treat %2B as a verb. + const resolutionFields = "&fields=%2Bvalues.resolution.user.display_name" path := fmt.Sprintf("/repositories/%s/%s/pullrequests/%d/comments?pagelen=%d&page=%d", - QueryEscape(args.Workspace), QueryEscape(args.RepoSlug), args.PRID, pagelen, page) + QueryEscape(args.Workspace), QueryEscape(args.RepoSlug), args.PRID, pagelen, page) + resolutionFields return GetPaginated[PRComment](c, path) } diff --git a/internal/bitbucket/fixtures_test.go b/internal/bitbucket/fixtures_test.go index aa7f94e..7b0d84e 100644 --- a/internal/bitbucket/fixtures_test.go +++ b/internal/bitbucket/fixtures_test.go @@ -141,6 +141,47 @@ func TestFixture_InlineComments_NullFromParsesAsNil(t *testing.T) { } } +// TestFixture_Comments_ResolutionParses verifies that thread resolution decodes: +// a resolved comment carries a non-nil Resolution with the resolver, while open +// comments (explicit null or absent) decode to a nil Resolution. Regression for +// the resolution-aware list (fields=+values.resolution) — without it the list +// could not distinguish open from resolved threads. +func TestFixture_Comments_ResolutionParses(t *testing.T) { + c := newVCRClient(t, "list_comments") + + result, err := c.ListPRComments(ListPRCommentsArgs{ + Workspace: "demo-ws", + RepoSlug: "demo-repo", + PRID: 1, + }) + if err != nil { + t.Fatalf("ListPRComments: %v", err) + } + + byID := map[int]PRComment{} + for _, cm := range result.Values { + byID[cm.ID] = cm + } + + resolved, ok := byID[1002] + if !ok { + t.Fatal("fixture must contain comment 1002") + } + if resolved.Resolution == nil { + t.Fatal("comment 1002 should be resolved (non-nil Resolution)") + } + if resolved.Resolution.User == nil || resolved.Resolution.User.DisplayName != "Zach Snell" { + t.Errorf("comment 1002 resolver = %+v, want display_name Zach Snell", resolved.Resolution.User) + } + + if open, ok := byID[1001]; ok && open.Resolution != nil { + t.Errorf("comment 1001 (explicit null) should decode to nil Resolution, got %+v", open.Resolution) + } + if open, ok := byID[1003]; ok && open.Resolution != nil { + t.Errorf("comment 1003 (absent) should decode to nil Resolution, got %+v", open.Resolution) + } +} + // TestFixture_ListWorkspaces_FlattensWorkspaceAccess verifies that the // workspace_access envelope returned by /user/workspaces is flattened into // usable Workspace rows. Regression: before the fix, the listing decoded diff --git a/internal/bitbucket/testdata/fixtures/list_comments.yaml b/internal/bitbucket/testdata/fixtures/list_comments.yaml index efd29f8..fd067f6 100644 --- a/internal/bitbucket/testdata/fixtures/list_comments.yaml +++ b/internal/bitbucket/testdata/fixtures/list_comments.yaml @@ -1,74 +1,76 @@ --- version: 2 interactions: - - id: 0 - request: - proto: HTTP/1.1 - proto_major: 1 - proto_minor: 1 - content_length: 0 - host: api.bitbucket.org - url: https://api.bitbucket.org/2.0/repositories/demo-ws/demo-repo/pullrequests/1/comments?pagelen=50&page=1 - method: GET - headers: - Accept: - - application/json - response: - proto: HTTP/2.0 - proto_major: 2 - proto_minor: 0 - content_length: -1 - uncompressed: true - status: 200 OK - code: 200 - duration: 33ms - headers: - Content-Type: - - application/json; charset=utf-8 - body: | - { - "pagelen": 50, - "size": 3, - "page": 1, - "values": [ - { - "id": 1001, - "content": {"raw": "general comment with no anchor", "markup": "markdown", "html": "

general comment with no anchor

"}, - "user": {"uuid": "{u1}", "display_name": "Zach Snell", "type": "user", "links": {}}, - "created_on": "2026-03-05T09:00:00.000000+00:00", - "updated_on": "2026-03-05T09:00:00.000000+00:00", - "inline": null, - "parent": null, - "deleted": false, - "pending": false, - "type": "pullrequest_comment", - "links": {} - }, - { - "id": 1002, - "content": {"raw": "nit on new line", "markup": "markdown", "html": "

nit on new line

"}, - "user": {"uuid": "{u2}", "display_name": "Aulia Lionar", "type": "user", "links": {}}, - "created_on": "2026-03-05T09:05:00.000000+00:00", - "updated_on": "2026-03-05T09:05:00.000000+00:00", - "inline": {"from": null, "to": 42, "path": "internal/bitbucket/client.go"}, - "parent": null, - "deleted": false, - "pending": false, - "type": "pullrequest_comment", - "links": {} - }, - { - "id": 1003, - "content": {"raw": "why was this removed?", "markup": "markdown", "html": "

why was this removed?

"}, - "user": {"uuid": "{u3}", "display_name": "Vitautas Brazas", "type": "user", "links": {}}, - "created_on": "2026-03-05T09:10:00.000000+00:00", - "updated_on": "2026-03-05T09:10:00.000000+00:00", - "inline": {"from": 7, "to": null, "path": "internal/bitbucket/types.go"}, - "parent": {"id": 1002}, - "deleted": false, - "pending": false, - "type": "pullrequest_comment", - "links": {} - } - ] - } + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.bitbucket.org + url: https://api.bitbucket.org/2.0/repositories/demo-ws/demo-repo/pullrequests/1/comments?pagelen=50&page=1&fields=%2Bvalues.resolution.user.display_name + method: GET + headers: + Accept: + - application/json + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + status: 200 OK + code: 200 + duration: 33ms + headers: + Content-Type: + - application/json; charset=utf-8 + body: | + { + "pagelen": 50, + "size": 3, + "page": 1, + "values": [ + { + "id": 1001, + "content": {"raw": "general comment with no anchor", "markup": "markdown", "html": "

general comment with no anchor

"}, + "user": {"uuid": "{u1}", "display_name": "Zach Snell", "type": "user", "links": {}}, + "created_on": "2026-03-05T09:00:00.000000+00:00", + "updated_on": "2026-03-05T09:00:00.000000+00:00", + "inline": null, + "parent": null, + "deleted": false, + "pending": false, + "type": "pullrequest_comment", + "links": {}, + "resolution": null + }, + { + "id": 1002, + "content": {"raw": "nit on new line", "markup": "markdown", "html": "

nit on new line

"}, + "user": {"uuid": "{u2}", "display_name": "Aulia Lionar", "type": "user", "links": {}}, + "created_on": "2026-03-05T09:05:00.000000+00:00", + "updated_on": "2026-03-05T09:05:00.000000+00:00", + "inline": {"from": null, "to": 42, "path": "internal/bitbucket/client.go"}, + "parent": null, + "deleted": false, + "pending": false, + "type": "pullrequest_comment", + "links": {}, + "resolution": {"type": "resolution_resolved", "user": {"display_name": "Zach Snell", "type": "user"}, "created_on": "2026-03-06T10:00:00.000000+00:00"} + }, + { + "id": 1003, + "content": {"raw": "why was this removed?", "markup": "markdown", "html": "

why was this removed?

"}, + "user": {"uuid": "{u3}", "display_name": "Vitautas Brazas", "type": "user", "links": {}}, + "created_on": "2026-03-05T09:10:00.000000+00:00", + "updated_on": "2026-03-05T09:10:00.000000+00:00", + "inline": {"from": 7, "to": null, "path": "internal/bitbucket/types.go"}, + "parent": {"id": 1002}, + "deleted": false, + "pending": false, + "type": "pullrequest_comment", + "links": {} + } + ] + } diff --git a/internal/bitbucket/types.go b/internal/bitbucket/types.go index 78e470b..258beab 100644 --- a/internal/bitbucket/types.go +++ b/internal/bitbucket/types.go @@ -147,17 +147,28 @@ type Participant struct { // PRComment represents a comment on a PR. type PRComment struct { - ID int `json:"id"` - Content Content `json:"content"` - User *User `json:"user"` - CreatedOn time.Time `json:"created_on"` - UpdatedOn time.Time `json:"updated_on"` - Inline *Inline `json:"inline"` - Parent *ParentRef `json:"parent"` - Deleted bool `json:"deleted"` - Pending bool `json:"pending"` - Type string `json:"type"` - Links Links `json:"links"` + ID int `json:"id"` + Content Content `json:"content"` + User *User `json:"user"` + CreatedOn time.Time `json:"created_on"` + UpdatedOn time.Time `json:"updated_on"` + Inline *Inline `json:"inline"` + Parent *ParentRef `json:"parent"` + Deleted bool `json:"deleted"` + Pending bool `json:"pending"` + Type string `json:"type"` + Links Links `json:"links"` + Resolution *Resolution `json:"resolution,omitempty"` +} + +// Resolution marks a comment thread as resolved. A nil/absent Resolution means +// the thread is still open. Bitbucket omits this field from the default +// comments projection — request it with fields=+values.resolution (done by +// ListPRComments). +type Resolution struct { + Type string `json:"type,omitempty"` + User *User `json:"user,omitempty"` + CreatedOn time.Time `json:"created_on,omitempty"` } // Content represents rich content with raw/markup/html.