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
169 changes: 169 additions & 0 deletions internal/api/confluence.go
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,175 @@
return &template, nil
}

// FooterComment represents a Confluence page footer comment.
type FooterComment struct {
ID string `json:"id"`

Check failure on line 891 in internal/api/confluence.go

View workflow job for this annotation

GitHub Actions / Lint

File is not properly formatted (goimports)
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"`
}
Comment on lines +915 to +934

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The CreateFooterCommentRequest and UpdateFooterCommentRequest structs use anonymous structs for Body and Version. This leads to code duplication for the Body struct. To improve maintainability and clarity, consider extracting these into named types. For example, a CommentRequestBody could be defined and reused, and a CommentUpdateVersion could be defined for the update request.


// 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"`
Expand Down
3 changes: 3 additions & 0 deletions internal/auth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
110 changes: 110 additions & 0 deletions internal/cmd/confluence/comment/add.go
Original file line number Diff line number Diff line change
@@ -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 <page-id>",
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 "<p>This looks good!</p>"

# 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 "<p>I agree</p>" --reply-to 9876543

# Output as JSON
atl confluence comment add 1234567 --body "<p>Comment</p>" --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"`
}
Comment on lines +71 to +76

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The AddCommentOutput struct is defined here but is also used by the edit command. To better reflect its shared nature and improve code organization, consider moving this struct to a more central location (e.g., internal/cmd/confluence/comment/comment.go) and renaming it to something more generic like CommentActionOutput.


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
}
38 changes: 38 additions & 0 deletions internal/cmd/confluence/comment/comment.go
Original file line number Diff line number Diff line change
@@ -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 "<p>This looks good!</p>"

# Reply to an existing comment
atl confluence comment add 1234567 --body "<p>I agree</p>" --reply-to 9876543

# Edit a comment
atl confluence comment edit --id 9876543 --body "<p>Updated text</p>"`,
}

cmd.AddCommand(NewCmdList(ios))
cmd.AddCommand(NewCmdAdd(ios))
cmd.AddCommand(NewCmdEdit(ios))

return cmd
}
Loading
Loading