diff --git a/internal/api/pullrequests.go b/internal/api/pullrequests.go index 32d2c2c..b604a08 100644 --- a/internal/api/pullrequests.go +++ b/internal/api/pullrequests.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "time" ) @@ -419,6 +420,71 @@ func (c *Client) AddPRComment(ctx context.Context, workspace, repoSlug string, p return ParseResponse[*PRComment](resp) } +// ListAllPRComments lists every comment on a pull request, following pagination +// until all pages have been fetched. Unlike ListPRComments, which returns only +// the first page, this returns the complete set of comments (including replies). +func (c *Client) ListAllPRComments(ctx context.Context, workspace, repoSlug string, prID int64) ([]PRComment, error) { + path := fmt.Sprintf("/repositories/%s/%s/pullrequests/%d/comments", workspace, repoSlug, prID) + + query := url.Values{} + query.Set("pagelen", "50") + + resp, err := c.Get(ctx, path, query) + if err != nil { + return nil, err + } + + page, err := ParseResponse[*Paginated[PRComment]](resp) + if err != nil { + return nil, err + } + + all := append([]PRComment(nil), page.Values...) + + // Follow the "next" links. Bitbucket returns absolute URLs, so strip the + // base URL to reuse the client (which prepends it in Do). + next := page.Next + for next != "" { + nextPath := strings.TrimPrefix(next, c.baseURL) + + resp, err := c.Get(ctx, nextPath, nil) + if err != nil { + return nil, err + } + + page, err = ParseResponse[*Paginated[PRComment]](resp) + if err != nil { + return nil, err + } + + all = append(all, page.Values...) + next = page.Next + } + + return all, nil +} + +// GetPRComment fetches a single comment on a pull request by its ID. +func (c *Client) GetPRComment(ctx context.Context, workspace, repoSlug string, prID, commentID int64) (*PRComment, error) { + path := fmt.Sprintf("/repositories/%s/%s/pullrequests/%d/comments/%d", workspace, repoSlug, prID, commentID) + + resp, err := c.Get(ctx, path, nil) + if err != nil { + return nil, err + } + + return ParseResponse[*PRComment](resp) +} + +// ReplyToPRComment posts a threaded reply to an existing pull request comment, +// setting the parent to the comment being replied to. +func (c *Client) ReplyToPRComment(ctx context.Context, workspace, repoSlug string, prID, parentID int64, content string) (*PRComment, error) { + return c.AddPRComment(ctx, workspace, repoSlug, prID, &AddPRCommentOptions{ + Content: content, + ParentID: parentID, + }) +} + // UpdatePullRequest updates an existing pull request func (c *Client) UpdatePullRequest(ctx context.Context, workspace, repoSlug string, prID int64, opts *PRCreateOptions) (*PullRequest, error) { path := fmt.Sprintf("/repositories/%s/%s/pullrequests/%d", workspace, repoSlug, prID) diff --git a/internal/api/pullrequests_test.go b/internal/api/pullrequests_test.go index 72dc798..52bf592 100644 --- a/internal/api/pullrequests_test.go +++ b/internal/api/pullrequests_test.go @@ -1172,3 +1172,141 @@ func TestGetPullRequestStatuses(t *testing.T) { t.Errorf("expected second status state 'INPROGRESS', got %q", statuses.Values[1].State) } } + +func TestListAllPRComments(t *testing.T) { + var calls int + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/comments") { + http.Error(w, "wrong endpoint", http.StatusBadRequest) + return + } + + calls++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + // Second page: no "next", one more comment. + if r.URL.Query().Get("page") == "2" { + w.Write([]byte(`{ + "size": 3, "page": 2, "pagelen": 2, + "values": [ + {"id": 3, "content": {"raw": "Third"}, "user": {"display_name": "C3"}, "created_on": "2024-01-03T00:00:00Z"} + ] + }`)) + return + } + + // First page: an absolute "next" URL pointing back at this server. + next := "http://" + r.Host + r.URL.Path + "?page=2" + w.Write([]byte(`{ + "size": 3, "page": 1, "pagelen": 2, + "next": "` + next + `", + "values": [ + {"id": 1, "content": {"raw": "First"}, "user": {"display_name": "C1"}, "created_on": "2024-01-01T00:00:00Z"}, + {"id": 2, "content": {"raw": "Second"}, "user": {"display_name": "C2"}, "created_on": "2024-01-02T00:00:00Z"} + ] + }`)) + })) + defer server.Close() + + client := NewClient(WithBaseURL(server.URL)) + + comments, err := client.ListAllPRComments(context.Background(), "workspace", "repo", 800) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if calls != 2 { + t.Errorf("expected 2 page requests (pagination followed), got %d", calls) + } + if len(comments) != 3 { + t.Fatalf("expected 3 comments across pages, got %d", len(comments)) + } + if comments[0].ID != 1 || comments[2].ID != 3 { + t.Errorf("expected comments in order 1..3, got %d..%d", comments[0].ID, comments[2].ID) + } +} + +func TestGetPRComment(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/comments/456") { + http.Error(w, "wrong endpoint", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "id": 456, + "content": {"raw": "A comment"}, + "user": {"display_name": "Author"}, + "created_on": "2024-01-01T00:00:00Z" + }`)) + })) + defer server.Close() + + client := NewClient(WithBaseURL(server.URL), WithToken("test-token")) + + comment, err := client.GetPRComment(context.Background(), "workspace", "repo", 800, 456) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if comment.ID != 456 { + t.Errorf("expected comment ID 456, got %d", comment.ID) + } + if comment.Content.Raw != "A comment" { + t.Errorf("expected content 'A comment', got %q", comment.Content.Raw) + } +} + +func TestReplyToPRComment(t *testing.T) { + var receivedBody []byte + var receivedPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.Path + receivedBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{ + "id": 101, + "content": {"raw": "A reply"}, + "user": {"display_name": "Me"}, + "parent": {"id": 456}, + "created_on": "2024-01-01T00:00:00Z" + }`)) + })) + defer server.Close() + + client := NewClient(WithBaseURL(server.URL), WithToken("test-token")) + + comment, err := client.ReplyToPRComment(context.Background(), "workspace", "repo", 900, 456, "A reply") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // The reply must be POSTed with a parent id set. + var body map[string]interface{} + if err := json.Unmarshal(receivedBody, &body); err != nil { + t.Fatalf("failed to parse body: %v", err) + } + parent, ok := body["parent"].(map[string]interface{}) + if !ok { + t.Fatal("expected parent object in reply body") + } + if int64(parent["id"].(float64)) != 456 { + t.Errorf("expected parent id 456, got %v", parent["id"]) + } + content, _ := body["content"].(map[string]interface{}) + if content["raw"] != "A reply" { + t.Errorf("expected raw content 'A reply', got %v", content["raw"]) + } + if !strings.HasSuffix(receivedPath, "/pullrequests/900/comments") { + t.Errorf("unexpected request path: %s", receivedPath) + } + if comment.ID != 101 { + t.Errorf("expected reply ID 101, got %d", comment.ID) + } +} diff --git a/internal/cmd/pr/comment.go b/internal/cmd/pr/comment.go index 8f6cf5e..2900e9c 100644 --- a/internal/cmd/pr/comment.go +++ b/internal/cmd/pr/comment.go @@ -50,6 +50,10 @@ for you to enter the comment text.`, cmd.ValidArgsFunction = cmdutil.CompletePRNumbers _ = cmd.RegisterFlagCompletionFunc("repo", cmdutil.CompleteRepoNames) + // Subcommands for viewing and replying to comments. + cmd.AddCommand(newCmdCommentList(streams)) + cmd.AddCommand(newCmdCommentReply(streams)) + return cmd } diff --git a/internal/cmd/pr/comment_list.go b/internal/cmd/pr/comment_list.go new file mode 100644 index 0000000..b9a210e --- /dev/null +++ b/internal/cmd/pr/comment_list.go @@ -0,0 +1,192 @@ +package pr + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/git" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" +) + +type commentListOptions struct { + streams *iostreams.IOStreams + selector string // PR number or empty for current branch + repo string + jsonOut bool +} + +// newCmdCommentList creates the "pr comment list" command +func newCmdCommentList(streams *iostreams.IOStreams) *cobra.Command { + opts := &commentListOptions{ + streams: streams, + } + + cmd := &cobra.Command{ + Use: "list []", + Aliases: []string{"ls", "view"}, + Short: "View comments on a pull request", + Long: `View the comments on a pull request, including threaded replies. + +With no argument, the pull request for the current branch is used. Replies are +shown indented beneath the comment they respond to, and inline (code) comments +show their file path and line.`, + Example: ` # View comments on pull request #123 + bb pr comment list 123 + + # View comments for the current branch's PR + bb pr comment list + + # View comments in a specific repository + bb pr comment list 123 --repo workspace/repo + + # Output as JSON + bb pr comment list 123 --json`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + opts.selector = args[0] + } + if opts.repo == "" { + opts.repo, _ = cmd.InheritedFlags().GetString("repo") + } + return runCommentList(opts) + }, + } + + cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Output in JSON format") + cmd.Flags().StringVarP(&opts.repo, "repo", "R", "", "Repository in WORKSPACE/REPO format") + + cmd.ValidArgsFunction = cmdutil.CompletePRNumbers + _ = cmd.RegisterFlagCompletionFunc("repo", cmdutil.CompleteRepoNames) + + return cmd +} + +func runCommentList(opts *commentListOptions) error { + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) + if err != nil { + return err + } + + client, err := cmdutil.GetAPIClient() + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + prNum, err := resolveCommentPRNumber(ctx, workspace, repoSlug, opts.selector) + if err != nil { + return err + } + + comments, err := client.ListAllPRComments(ctx, workspace, repoSlug, int64(prNum)) + if err != nil { + return fmt.Errorf("failed to list comments: %w", err) + } + + if opts.jsonOut { + return cmdutil.PrintJSON(opts.streams, comments) + } + + return displayComments(opts.streams, comments, prNum) +} + +// resolveCommentPRNumber resolves a PR number from an explicit selector or, when +// empty, from the open pull request for the current branch. +func resolveCommentPRNumber(ctx context.Context, workspace, repoSlug, selector string) (int, error) { + if selector == "" { + branch, err := git.GetCurrentBranch() + if err != nil { + return 0, fmt.Errorf("could not determine current branch: %w", err) + } + return findPRForBranch(ctx, workspace, repoSlug, branch) + } + + num, err := strconv.Atoi(selector) + if err != nil { + return 0, fmt.Errorf("invalid pull request number: %s", selector) + } + if num <= 0 { + return 0, fmt.Errorf("invalid pull request number: must be a positive integer") + } + return num, nil +} + +// displayComments renders comments as a thread: top-level comments in +// chronological order, each followed by its (recursively nested) replies. +func displayComments(streams *iostreams.IOStreams, comments []api.PRComment, prNum int) error { + if len(comments) == 0 { + fmt.Fprintf(streams.Out, "No comments on pull request #%d\n", prNum) + return nil + } + + // Group replies by their parent comment ID; collect top-level comments. + childrenByParent := make(map[int64][]api.PRComment) + var roots []api.PRComment + for _, c := range comments { + if c.Parent != nil && c.Parent.ID != 0 { + childrenByParent[c.Parent.ID] = append(childrenByParent[c.Parent.ID], c) + } else { + roots = append(roots, c) + } + } + + byCreated := func(cs []api.PRComment) func(i, j int) bool { + return func(i, j int) bool { return cs[i].CreatedOn.Before(cs[j].CreatedOn) } + } + sort.SliceStable(roots, byCreated(roots)) + for id := range childrenByParent { + sort.SliceStable(childrenByParent[id], byCreated(childrenByParent[id])) + } + + bold := streams.ColorFunc(iostreams.Bold) + cyan := streams.ColorFunc(iostreams.Cyan) + yellow := streams.ColorFunc(iostreams.Yellow) + + fmt.Fprintf(streams.Out, "%s\n\n", bold(fmt.Sprintf("Comments on pull request #%d (%d total)", prNum, len(comments)))) + + var render func(c api.PRComment, depth int) + render = func(c api.PRComment, depth int) { + indent := strings.Repeat(" ", depth) + + author := cmdutil.GetUserDisplayName(&c.User) + fmt.Fprintf(streams.Out, "%s%s\n", indent, cyan(fmt.Sprintf("@%s · %s · id %d", author, cmdutil.TimeAgo(c.CreatedOn), c.ID))) + + if c.Inline != nil && c.Inline.Path != "" { + loc := c.Inline.Path + if c.Inline.To > 0 { + loc = fmt.Sprintf("%s:%d", c.Inline.Path, c.Inline.To) + } + fmt.Fprintf(streams.Out, "%s%s\n", indent, yellow(loc)) + } + + body := strings.TrimSpace(c.Content.Raw) + if body == "" { + body = "(no content)" + } + for _, line := range strings.Split(body, "\n") { + fmt.Fprintf(streams.Out, "%s %s\n", indent, line) + } + fmt.Fprintln(streams.Out) + + for _, child := range childrenByParent[c.ID] { + render(child, depth+1) + } + } + + for _, r := range roots { + render(r, 0) + } + + return nil +} diff --git a/internal/cmd/pr/comment_reply.go b/internal/cmd/pr/comment_reply.go new file mode 100644 index 0000000..99da3f1 --- /dev/null +++ b/internal/cmd/pr/comment_reply.go @@ -0,0 +1,113 @@ +package pr + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/rbansal42/bitbucket-cli/internal/cmdutil" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" +) + +type commentReplyOptions struct { + streams *iostreams.IOStreams + repo string + body string + commentID int64 +} + +// newCmdCommentReply creates the "pr comment reply" command +func newCmdCommentReply(streams *iostreams.IOStreams) *cobra.Command { + opts := &commentReplyOptions{ + streams: streams, + } + + cmd := &cobra.Command{ + Use: "reply --comment ", + Short: "Reply to a comment on a pull request", + Long: `Reply to an existing comment on a pull request, creating a threaded reply. + +The parent comment is identified by its ID (shown by "bb pr comment list"). +If the reply body is not provided via --body, an editor is opened for you to +enter the reply text.`, + Example: ` # Reply to comment 456 on pull request #123 (opens editor) + bb pr comment reply 123 --comment 456 + + # Reply with an inline body + bb pr comment reply 123 --comment 456 --body "Good point, fixed." + + # Reply to a comment in a specific repository + bb pr comment reply 123 --comment 456 --repo workspace/repo --body "Done"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if opts.repo == "" { + opts.repo, _ = cmd.InheritedFlags().GetString("repo") + } + return runCommentReply(opts, args) + }, + } + + cmd.Flags().Int64VarP(&opts.commentID, "comment", "c", 0, "ID of the comment to reply to (required)") + cmd.Flags().StringVarP(&opts.body, "body", "b", "", "Reply body text") + cmd.Flags().StringVarP(&opts.repo, "repo", "R", "", "Repository in WORKSPACE/REPO format") + + _ = cmd.MarkFlagRequired("comment") + + cmd.ValidArgsFunction = cmdutil.CompletePRNumbers + _ = cmd.RegisterFlagCompletionFunc("repo", cmdutil.CompleteRepoNames) + + return cmd +} + +func runCommentReply(opts *commentReplyOptions, args []string) error { + prNum, err := parsePRNumber(args) + if err != nil { + return err + } + + if opts.commentID <= 0 { + return fmt.Errorf("a valid --comment is required") + } + + workspace, repoSlug, err := cmdutil.ParseRepository(opts.repo) + if err != nil { + return err + } + + // If no body provided, open editor + if opts.body == "" { + body, err := openEditor("") + if err != nil { + return fmt.Errorf("failed to get reply: %w", err) + } + if body == "" { + return fmt.Errorf("reply body is required") + } + opts.body = body + } + + client, err := cmdutil.GetAPIClient() + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + comment, err := client.ReplyToPRComment(ctx, workspace, repoSlug, int64(prNum), opts.commentID, opts.body) + if err != nil { + return fmt.Errorf("failed to reply to comment: %w", err) + } + + // Print the URL to the new reply + if comment.Links.HTML.Href != "" { + fmt.Fprintln(opts.streams.Out, comment.Links.HTML.Href) + } else { + fmt.Fprintf(opts.streams.Out, "https://bitbucket.org/%s/%s/pull-requests/%d#comment-%d\n", + workspace, repoSlug, prNum, comment.ID) + } + + return nil +} diff --git a/internal/cmd/pr/comment_test.go b/internal/cmd/pr/comment_test.go new file mode 100644 index 0000000..104e336 --- /dev/null +++ b/internal/cmd/pr/comment_test.go @@ -0,0 +1,115 @@ +package pr + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/rbansal42/bitbucket-cli/internal/api" + "github.com/rbansal42/bitbucket-cli/internal/iostreams" +) + +// mkComment builds a PRComment for tests. A non-zero parentID marks it as a reply. +func mkComment(id, parentID int64, author, body string, created time.Time) api.PRComment { + var c api.PRComment + c.ID = id + c.Content.Raw = body + c.User.DisplayName = author + c.CreatedOn = created + if parentID != 0 { + c.Parent = &struct { + ID int64 `json:"id"` + }{ID: parentID} + } + return c +} + +func TestDisplayCommentsThreading(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + // Deliberately out of order and with the reply before its parent in the slice. + comments := []api.PRComment{ + mkComment(3, 1, "Carol", "reply to A", t0.Add(2*time.Hour)), + mkComment(2, 0, "Bob", "top level B", t0.Add(time.Hour)), + mkComment(1, 0, "Alice", "top level A", t0), + } + + streams := iostreams.New() + var buf bytes.Buffer + streams.Out = &buf + + if err := displayComments(streams, comments, 42); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + + // Header shows the total comment count. + if !strings.Contains(out, "pull request #42 (3 total)") { + t.Errorf("expected header with total count, got:\n%s", out) + } + + // The reply body is nested one level deeper (4-space indent) than a + // top-level body (2-space indent). This is color-independent. + if !strings.Contains(out, "\n reply to A") { + t.Errorf("expected reply body indented under its parent, got:\n%s", out) + } + if !strings.Contains(out, "\n top level A") { + t.Errorf("expected top-level body at base indent, got:\n%s", out) + } + + // Ordering: top-level comments chronological (A before B), and a reply + // rendered immediately under its parent (A) rather than under B. + idxA := strings.Index(out, "top level A") + idxReply := strings.Index(out, "reply to A") + idxB := strings.Index(out, "top level B") + if !(idxA >= 0 && idxReply > idxA && idxB > idxReply) { + t.Errorf("expected order A, reply-to-A, B; got A=%d reply=%d B=%d\n%s", idxA, idxReply, idxB, out) + } +} + +func TestDisplayCommentsEmpty(t *testing.T) { + streams := iostreams.New() + var buf bytes.Buffer + streams.Out = &buf + + if err := displayComments(streams, nil, 7); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(buf.String(), "No comments on pull request #7") { + t.Errorf("expected empty-state message, got %q", buf.String()) + } +} + +func TestResolveCommentPRNumber(t *testing.T) { + tests := []struct { + name string + selector string + want int + wantErr bool + }{ + {name: "valid number", selector: "123", want: 123}, + {name: "zero is invalid", selector: "0", wantErr: true}, + {name: "negative is invalid", selector: "-5", wantErr: true}, + {name: "non-numeric is invalid", selector: "abc", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveCommentPRNumber(nil, "workspace", "repo", tt.selector) + if tt.wantErr { + if err == nil { + t.Errorf("expected error for selector %q, got nil", tt.selector) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("resolveCommentPRNumber(%q) = %d; want %d", tt.selector, got, tt.want) + } + }) + } +}