From 6ce2424e8e0d764cecfb11fec0ad5b7342bb3f63 Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Fri, 20 Mar 2026 15:16:09 +0100 Subject: [PATCH] feat: add Confluence page comment commands Add `atl confluence comment list|add|edit` commands for managing footer comments on Confluence pages via the v2 API. - list: view all footer comments on a page - add: create a comment (supports --reply-to for threaded replies) - edit: update an existing comment by ID - All commands support --json output and --body-file input - Add OAuth scopes: read:comment:confluence, write:comment:confluence --- internal/api/confluence.go | 169 +++++++++++++++++++++ internal/auth/oauth.go | 3 + internal/cmd/confluence/comment/add.go | 110 ++++++++++++++ internal/cmd/confluence/comment/comment.go | 38 +++++ internal/cmd/confluence/comment/edit.go | 104 +++++++++++++ internal/cmd/confluence/comment/list.go | 153 +++++++++++++++++++ internal/cmd/confluence/comment/util.go | 22 +++ internal/cmd/confluence/confluence.go | 2 + 8 files changed, 601 insertions(+) create mode 100644 internal/cmd/confluence/comment/add.go create mode 100644 internal/cmd/confluence/comment/comment.go create mode 100644 internal/cmd/confluence/comment/edit.go create mode 100644 internal/cmd/confluence/comment/list.go create mode 100644 internal/cmd/confluence/comment/util.go diff --git a/internal/api/confluence.go b/internal/api/confluence.go index 5d7788f..af4930f 100644 --- a/internal/api/confluence.go +++ b/internal/api/confluence.go @@ -886,6 +886,175 @@ func (s *ConfluenceService) UpdateTemplate(ctx context.Context, templateID, name return &template, nil } +// FooterComment represents a Confluence page footer comment. +type FooterComment struct { + ID string `json:"id"` + Status string `json:"status"` + Title string `json:"title,omitempty"` + PageID string `json:"pageId,omitempty"` + ParentID string `json:"parentCommentId,omitempty"` + Version *FooterCommentVersion `json:"version,omitempty"` + Body *PageBody `json:"body,omitempty"` + AuthorID string `json:"authorId,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` +} + +// FooterCommentVersion represents version info for a comment. +type FooterCommentVersion struct { + Number int `json:"number"` + CreatedAt string `json:"createdAt,omitempty"` + AuthorID string `json:"authorId,omitempty"` +} + +// FooterCommentsResponse represents a paginated list of footer comments. +type FooterCommentsResponse struct { + Results []*FooterComment `json:"results"` + Links *PaginationLinks `json:"_links,omitempty"` +} + +// CreateFooterCommentRequest represents a request to create a footer comment. +type CreateFooterCommentRequest struct { + PageID string `json:"pageId"` + ParentCommentID string `json:"parentCommentId,omitempty"` + Body struct { + Representation string `json:"representation"` + Value string `json:"value"` + } `json:"body"` +} + +// UpdateFooterCommentRequest represents a request to update a footer comment. +type UpdateFooterCommentRequest struct { + Version struct { + Number int `json:"number"` + } `json:"version"` + Body struct { + Representation string `json:"representation"` + Value string `json:"value"` + } `json:"body"` +} + +// GetPageFooterComments gets footer comments on a page. +// Uses v2 API: GET /pages/{id}/footer-comments +func (s *ConfluenceService) GetPageFooterComments(ctx context.Context, pageID string, limit int, cursor string) (*FooterCommentsResponse, error) { + path := fmt.Sprintf("%s/pages/%s/footer-comments", s.baseURL(), pageID) + + params := url.Values{} + params.Set("body-format", "storage") + if limit > 0 { + params.Set("limit", strconv.Itoa(capLimit(limit, ConfluenceMaxLimit))) + } + if cursor != "" { + params.Set("cursor", cursor) + } + + var result FooterCommentsResponse + if err := s.client.Get(ctx, path+"?"+params.Encode(), &result); err != nil { + return nil, err + } + + return &result, nil +} + +// GetPageFooterCommentsAll gets all footer comments on a page by following pagination. +func (s *ConfluenceService) GetPageFooterCommentsAll(ctx context.Context, pageID string) ([]*FooterComment, error) { + var all []*FooterComment + cursor := "" + + for { + result, err := s.GetPageFooterComments(ctx, pageID, 100, cursor) + if err != nil { + return nil, err + } + all = append(all, result.Results...) + + if result.Links == nil || result.Links.Next == "" { + break + } + cursor = extractCursor(result.Links.Next) + if cursor == "" { + break + } + } + + return all, nil +} + +// GetFooterComment gets a single footer comment by ID. +// Uses v2 API: GET /footer-comments/{id} +func (s *ConfluenceService) GetFooterComment(ctx context.Context, commentID string) (*FooterComment, error) { + path := fmt.Sprintf("%s/footer-comments/%s", s.baseURL(), commentID) + + params := url.Values{} + params.Set("body-format", "storage") + + var comment FooterComment + if err := s.client.Get(ctx, path+"?"+params.Encode(), &comment); err != nil { + return nil, err + } + + return &comment, nil +} + +// GetFooterCommentChildren gets child comments (replies) of a footer comment. +// Uses v2 API: GET /footer-comments/{id}/children +func (s *ConfluenceService) GetFooterCommentChildren(ctx context.Context, commentID string, limit int, cursor string) (*FooterCommentsResponse, error) { + path := fmt.Sprintf("%s/footer-comments/%s/children", s.baseURL(), commentID) + + params := url.Values{} + params.Set("body-format", "storage") + if limit > 0 { + params.Set("limit", strconv.Itoa(capLimit(limit, ConfluenceMaxLimit))) + } + if cursor != "" { + params.Set("cursor", cursor) + } + + var result FooterCommentsResponse + if err := s.client.Get(ctx, path+"?"+params.Encode(), &result); err != nil { + return nil, err + } + + return &result, nil +} + +// CreateFooterComment creates a footer comment on a page. +// Uses v2 API: POST /footer-comments +func (s *ConfluenceService) CreateFooterComment(ctx context.Context, pageID, body, parentCommentID string) (*FooterComment, error) { + path := fmt.Sprintf("%s/footer-comments", s.baseURL()) + + reqBody := CreateFooterCommentRequest{ + PageID: pageID, + ParentCommentID: parentCommentID, + } + reqBody.Body.Representation = "storage" + reqBody.Body.Value = body + + var comment FooterComment + if err := s.client.Post(ctx, path, reqBody, &comment); err != nil { + return nil, err + } + + return &comment, nil +} + +// UpdateFooterComment updates a footer comment. +// Uses v2 API: PUT /footer-comments/{id} +func (s *ConfluenceService) UpdateFooterComment(ctx context.Context, commentID, body string, version int) (*FooterComment, error) { + path := fmt.Sprintf("%s/footer-comments/%s", s.baseURL(), commentID) + + reqBody := UpdateFooterCommentRequest{} + reqBody.Version.Number = version + 1 + reqBody.Body.Representation = "storage" + reqBody.Body.Value = body + + var comment FooterComment + if err := s.client.Put(ctx, path, reqBody, &comment); err != nil { + return nil, err + } + + return &comment, nil +} + // ConfluenceAttachment represents a file attachment on a Confluence page. type ConfluenceAttachment struct { ID string `json:"id"` diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 0f6ade1..866a1cf 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -62,6 +62,9 @@ func DefaultScopes() []string { "read:content.metadata:confluence", "read:content-details:confluence", "read:hierarchical-content:confluence", + // Confluence comment scopes (v2 API) + "read:comment:confluence", + "write:comment:confluence", // Confluence attachment scopes (v2 API) "read:attachment:confluence", "write:attachment:confluence", diff --git a/internal/cmd/confluence/comment/add.go b/internal/cmd/confluence/comment/add.go new file mode 100644 index 0000000..371f189 --- /dev/null +++ b/internal/cmd/confluence/comment/add.go @@ -0,0 +1,110 @@ +package comment + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/atl-cli/internal/api" + "github.com/enthus-appdev/atl-cli/internal/iostreams" + "github.com/enthus-appdev/atl-cli/internal/output" +) + +// AddOptions holds the options for the add command. +type AddOptions struct { + IO *iostreams.IOStreams + PageID string + Body string + BodyFile string + ReplyTo string + JSON bool +} + +// NewCmdAdd creates the add command. +func NewCmdAdd(ios *iostreams.IOStreams) *cobra.Command { + opts := &AddOptions{ + IO: ios, + } + + cmd := &cobra.Command{ + Use: "add ", + Short: "Add a comment to a page", + Long: `Add a new footer comment to a Confluence page. + +The body must be HTML (Confluence storage format). +Use --reply-to to create a threaded reply to an existing comment.`, + Example: ` # Add a comment + atl confluence comment add 1234567 --body "

This looks good!

" + + # Add a comment from a file + atl confluence comment add 1234567 --body-file comment.html + + # Reply to an existing comment + atl confluence comment add 1234567 --body "

I agree

" --reply-to 9876543 + + # Output as JSON + atl confluence comment add 1234567 --body "

Comment

" --json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.PageID = args[0] + + if err := resolveBody(&opts.Body, opts.BodyFile); err != nil { + return err + } + if opts.Body == "" { + return fmt.Errorf("--body or --body-file is required") + } + + return runAdd(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Comment body in HTML (required)") + cmd.Flags().StringVar(&opts.BodyFile, "body-file", "", "Read comment body from file") + cmd.Flags().StringVar(&opts.ReplyTo, "reply-to", "", "Comment ID to reply to (creates threaded reply)") + cmd.Flags().BoolVarP(&opts.JSON, "json", "j", false, "Output as JSON") + + return cmd +} + +// AddCommentOutput represents the result of adding a comment. +type AddCommentOutput struct { + PageID string `json:"page_id"` + CommentID string `json:"comment_id"` + Action string `json:"action"` +} + +func runAdd(opts *AddOptions) error { + client, err := api.NewClientFromConfig() + if err != nil { + return err + } + + ctx := context.Background() + confluence := api.NewConfluenceService(client) + + comment, err := confluence.CreateFooterComment(ctx, opts.PageID, opts.Body, opts.ReplyTo) + if err != nil { + return fmt.Errorf("failed to add comment: %w", err) + } + + addOutput := &AddCommentOutput{ + PageID: opts.PageID, + CommentID: comment.ID, + Action: "added", + } + + if opts.JSON { + return output.JSON(opts.IO.Out, addOutput) + } + + action := "Added comment to" + if opts.ReplyTo != "" { + action = fmt.Sprintf("Replied to comment %s on", opts.ReplyTo) + } + fmt.Fprintf(opts.IO.Out, "%s page %s\n", action, opts.PageID) + fmt.Fprintf(opts.IO.Out, "Comment ID: %s\n", addOutput.CommentID) + + return nil +} diff --git a/internal/cmd/confluence/comment/comment.go b/internal/cmd/confluence/comment/comment.go new file mode 100644 index 0000000..723f18e --- /dev/null +++ b/internal/cmd/confluence/comment/comment.go @@ -0,0 +1,38 @@ +package comment + +import ( + "github.com/spf13/cobra" + + "github.com/enthus-appdev/atl-cli/internal/iostreams" +) + +// NewCmdComment creates the comment command group. +func NewCmdComment(ios *iostreams.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "comment", + Short: "Manage comments on Confluence pages", + Long: `Add, edit, or view footer comments on Confluence pages. + +Use subcommands to manage comments: + list - View comments on a page + add - Add a new comment + edit - Edit an existing comment`, + Example: ` # List comments on a page + atl confluence comment list 1234567 + + # Add a comment + atl confluence comment add 1234567 --body "

This looks good!

" + + # Reply to an existing comment + atl confluence comment add 1234567 --body "

I agree

" --reply-to 9876543 + + # Edit a comment + atl confluence comment edit --id 9876543 --body "

Updated text

"`, + } + + cmd.AddCommand(NewCmdList(ios)) + cmd.AddCommand(NewCmdAdd(ios)) + cmd.AddCommand(NewCmdEdit(ios)) + + return cmd +} diff --git a/internal/cmd/confluence/comment/edit.go b/internal/cmd/confluence/comment/edit.go new file mode 100644 index 0000000..4b03828 --- /dev/null +++ b/internal/cmd/confluence/comment/edit.go @@ -0,0 +1,104 @@ +package comment + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/atl-cli/internal/api" + "github.com/enthus-appdev/atl-cli/internal/iostreams" + "github.com/enthus-appdev/atl-cli/internal/output" +) + +// EditOptions holds the options for the edit command. +type EditOptions struct { + IO *iostreams.IOStreams + CommentID string + Body string + BodyFile string + JSON bool +} + +// NewCmdEdit creates the edit command. +func NewCmdEdit(ios *iostreams.IOStreams) *cobra.Command { + opts := &EditOptions{ + IO: ios, + } + + cmd := &cobra.Command{ + Use: "edit", + Short: "Edit a comment on a page", + Long: `Edit an existing footer comment on a Confluence page. + +Requires the comment ID which can be found using 'atl confluence comment list'.`, + Example: ` # Edit a comment + atl confluence comment edit --id 9876543 --body "

Updated text

" + + # Edit from file + atl confluence comment edit --id 9876543 --body-file updated.html + + # Output as JSON + atl confluence comment edit --id 9876543 --body "

Text

" --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.CommentID == "" { + return fmt.Errorf("--id is required\n\nUse 'atl confluence comment list ' to see comment IDs") + } + if err := resolveBody(&opts.Body, opts.BodyFile); err != nil { + return err + } + if opts.Body == "" { + return fmt.Errorf("--body or --body-file is required") + } + + return runEdit(opts) + }, + } + + cmd.Flags().StringVar(&opts.CommentID, "id", "", "Comment ID to edit (required)") + cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "New comment body in HTML (required)") + cmd.Flags().StringVar(&opts.BodyFile, "body-file", "", "Read comment body from file") + cmd.Flags().BoolVarP(&opts.JSON, "json", "j", false, "Output as JSON") + + return cmd +} + +func runEdit(opts *EditOptions) error { + client, err := api.NewClientFromConfig() + if err != nil { + return err + } + + ctx := context.Background() + confluence := api.NewConfluenceService(client) + + // Get existing comment to determine current version + existing, err := confluence.GetFooterComment(ctx, opts.CommentID) + if err != nil { + return fmt.Errorf("failed to get comment: %w", err) + } + + version := 0 + if existing.Version != nil { + version = existing.Version.Number + } + + comment, err := confluence.UpdateFooterComment(ctx, opts.CommentID, opts.Body, version) + if err != nil { + return fmt.Errorf("failed to edit comment: %w", err) + } + + editOutput := &AddCommentOutput{ + PageID: existing.PageID, + CommentID: comment.ID, + Action: "edited", + } + + if opts.JSON { + return output.JSON(opts.IO.Out, editOutput) + } + + fmt.Fprintf(opts.IO.Out, "Edited comment %s\n", editOutput.CommentID) + + return nil +} diff --git a/internal/cmd/confluence/comment/list.go b/internal/cmd/confluence/comment/list.go new file mode 100644 index 0000000..f34e5dd --- /dev/null +++ b/internal/cmd/confluence/comment/list.go @@ -0,0 +1,153 @@ +package comment + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/atl-cli/internal/api" + "github.com/enthus-appdev/atl-cli/internal/iostreams" + "github.com/enthus-appdev/atl-cli/internal/output" +) + +// ListOptions holds the options for the list command. +type ListOptions struct { + IO *iostreams.IOStreams + PageID string + JSON bool +} + +// NewCmdList creates the list command. +func NewCmdList(ios *iostreams.IOStreams) *cobra.Command { + opts := &ListOptions{ + IO: ios, + } + + cmd := &cobra.Command{ + Use: "list ", + Aliases: []string{"ls"}, + Short: "List comments on a page", + Long: `View all footer comments on a Confluence page.`, + Example: ` # List comments on a page + atl confluence comment list 1234567 + + # Output as JSON + atl confluence comment list 1234567 --json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.PageID = args[0] + return runList(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.JSON, "json", "j", false, "Output as JSON") + + return cmd +} + +// CommentListOutput represents the list of comments. +type CommentListOutput struct { + PageID string `json:"page_id"` + Comments []*CommentOutput `json:"comments"` + Total int `json:"total"` +} + +// CommentOutput represents a single comment. +type CommentOutput struct { + ID string `json:"id"` + AuthorID string `json:"author_id"` + Body string `json:"body"` + ParentID string `json:"parent_id,omitempty"` + CreatedAt string `json:"created_at"` + Version int `json:"version"` +} + +func runList(opts *ListOptions) error { + client, err := api.NewClientFromConfig() + if err != nil { + return err + } + + ctx := context.Background() + confluence := api.NewConfluenceService(client) + + comments, err := confluence.GetPageFooterCommentsAll(ctx, opts.PageID) + if err != nil { + return fmt.Errorf("failed to get comments: %w", err) + } + + listOutput := &CommentListOutput{ + PageID: opts.PageID, + Comments: make([]*CommentOutput, 0, len(comments)), + Total: len(comments), + } + + for _, c := range comments { + comment := &CommentOutput{ + ID: c.ID, + AuthorID: c.AuthorID, + ParentID: c.ParentID, + CreatedAt: formatTime(c.CreatedAt), + } + if c.Version != nil { + comment.Version = c.Version.Number + } + if c.Body != nil && c.Body.Storage != nil { + comment.Body = c.Body.Storage.Value + } + listOutput.Comments = append(listOutput.Comments, comment) + } + + if opts.JSON { + return output.JSON(opts.IO.Out, listOutput) + } + + if len(listOutput.Comments) == 0 { + fmt.Fprintf(opts.IO.Out, "No comments on page %s\n", opts.PageID) + return nil + } + + fmt.Fprintf(opts.IO.Out, "# Comments on page %s (%d total)\n\n", opts.PageID, listOutput.Total) + + for i, c := range listOutput.Comments { + if i > 0 { + fmt.Fprintln(opts.IO.Out, "---") + } + prefix := "" + if c.ParentID != "" { + prefix = fmt.Sprintf(" (reply to %s)", c.ParentID) + } + fmt.Fprintf(opts.IO.Out, "**%s** (%s) [ID: %s]%s\n\n", c.AuthorID, c.CreatedAt, c.ID, prefix) + fmt.Fprintln(opts.IO.Out, stripHTML(c.Body)) + fmt.Fprintln(opts.IO.Out) + } + + return nil +} + +func formatTime(t string) string { + if len(t) >= 19 { + return t[:10] + " " + t[11:19] + } + return t +} + +// stripHTML removes basic HTML tags for plain-text display. +func stripHTML(s string) string { + // Simple tag stripping for display purposes + result := s + for { + start := strings.Index(result, "<") + if start == -1 { + break + } + end := strings.Index(result[start:], ">") + if end == -1 { + break + } + result = result[:start] + result[start+end+1:] + } + return strings.TrimSpace(result) +} diff --git a/internal/cmd/confluence/comment/util.go b/internal/cmd/confluence/comment/util.go new file mode 100644 index 0000000..d59659d --- /dev/null +++ b/internal/cmd/confluence/comment/util.go @@ -0,0 +1,22 @@ +package comment + +import ( + "fmt" + "os" +) + +// resolveBody reads body content from a file if bodyFile is set. +// Returns an error if both body and bodyFile are provided. +func resolveBody(body *string, bodyFile string) error { + if *body != "" && bodyFile != "" { + return fmt.Errorf("--body and --body-file are mutually exclusive") + } + if bodyFile != "" { + data, err := os.ReadFile(bodyFile) + if err != nil { + return fmt.Errorf("failed to read body file: %w", err) + } + *body = string(data) + } + return nil +} diff --git a/internal/cmd/confluence/confluence.go b/internal/cmd/confluence/confluence.go index 87714e5..faf61a5 100644 --- a/internal/cmd/confluence/confluence.go +++ b/internal/cmd/confluence/confluence.go @@ -3,6 +3,7 @@ package confluence import ( "github.com/spf13/cobra" + "github.com/enthus-appdev/atl-cli/internal/cmd/confluence/comment" "github.com/enthus-appdev/atl-cli/internal/cmd/confluence/page" "github.com/enthus-appdev/atl-cli/internal/cmd/confluence/space" "github.com/enthus-appdev/atl-cli/internal/cmd/confluence/template" @@ -18,6 +19,7 @@ func NewCmdConfluence(ios *iostreams.IOStreams) *cobra.Command { Long: `Read and manage Confluence pages, spaces, and templates.`, } + cmd.AddCommand(comment.NewCmdComment(ios)) cmd.AddCommand(page.NewCmdPage(ios)) cmd.AddCommand(space.NewCmdSpace(ios)) cmd.AddCommand(template.NewCmdTemplate(ios))