diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go index 3d842015..9f374bbe 100644 --- a/server/cmd/multica/cmd_issue.go +++ b/server/cmd/multica/cmd_issue.go @@ -162,6 +162,9 @@ func init() { // issue comment list issueCommentListCmd.Flags().String("output", "table", "Output format: table or json") + issueCommentListCmd.Flags().Int("limit", 0, "Maximum number of comments to return (0 = all)") + issueCommentListCmd.Flags().Int("offset", 0, "Number of comments to skip") + issueCommentListCmd.Flags().String("since", "", "Only return comments created after this timestamp (RFC3339)") // issue runs issueRunsCmd.Flags().String("output", "table", "Output format: table or json") @@ -536,8 +539,24 @@ func runIssueCommentList(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() + params := url.Values{} + if v, _ := cmd.Flags().GetInt("limit"); v > 0 { + params.Set("limit", fmt.Sprintf("%d", v)) + } + if v, _ := cmd.Flags().GetInt("offset"); v > 0 { + params.Set("offset", fmt.Sprintf("%d", v)) + } + if v, _ := cmd.Flags().GetString("since"); v != "" { + params.Set("since", v) + } + + path := "/api/issues/" + args[0] + "/comments" + if len(params) > 0 { + path += "?" + params.Encode() + } + var comments []map[string]any - if err := client.GetJSON(ctx, "/api/issues/"+args[0]+"/comments", &comments); err != nil { + if err := client.GetJSON(ctx, path, &comments); err != nil { return fmt.Errorf("list comments: %w", err) } diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 95c00fa5..8bb808a5 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -5,6 +5,8 @@ import ( "encoding/json" "log/slog" "net/http" + "strconv" + "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" @@ -58,10 +60,76 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) { return } - comments, err := h.Queries.ListComments(r.Context(), db.ListCommentsParams{ - IssueID: issue.ID, - WorkspaceID: issue.WorkspaceID, - }) + // Parse optional pagination query params. + q := r.URL.Query() + var limit, offset int32 + var hasPagination bool + if v := q.Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + writeError(w, http.StatusBadRequest, "invalid limit parameter") + return + } + limit = int32(n) + hasPagination = true + } + if v := q.Get("offset"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + writeError(w, http.StatusBadRequest, "invalid offset parameter") + return + } + offset = int32(n) + hasPagination = true + } + + var sinceTime pgtype.Timestamptz + if v := q.Get("since"); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid since parameter; expected RFC3339 format") + return + } + sinceTime = pgtype.Timestamptz{Time: t, Valid: true} + } + + var comments []db.Comment + var err error + + switch { + case sinceTime.Valid && hasPagination: + if limit == 0 { + limit = 50 + } + comments, err = h.Queries.ListCommentsSincePaginated(r.Context(), db.ListCommentsSincePaginatedParams{ + IssueID: issue.ID, + WorkspaceID: issue.WorkspaceID, + CreatedAt: sinceTime, + Limit: limit, + Offset: offset, + }) + case sinceTime.Valid: + comments, err = h.Queries.ListCommentsSince(r.Context(), db.ListCommentsSinceParams{ + IssueID: issue.ID, + WorkspaceID: issue.WorkspaceID, + CreatedAt: sinceTime, + }) + case hasPagination: + if limit == 0 { + limit = 50 + } + comments, err = h.Queries.ListCommentsPaginated(r.Context(), db.ListCommentsPaginatedParams{ + IssueID: issue.ID, + WorkspaceID: issue.WorkspaceID, + Limit: limit, + Offset: offset, + }) + default: + comments, err = h.Queries.ListComments(r.Context(), db.ListCommentsParams{ + IssueID: issue.ID, + WorkspaceID: issue.WorkspaceID, + }) + } if err != nil { writeError(w, http.StatusInternalServerError, "failed to list comments") return @@ -80,6 +148,17 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) { resp[i] = commentToResponse(c, grouped[cid], groupedAtt[cid]) } + // Include total count in response header when paginating. + if hasPagination { + total, countErr := h.Queries.CountComments(r.Context(), db.CountCommentsParams{ + IssueID: issue.ID, + WorkspaceID: issue.WorkspaceID, + }) + if countErr == nil { + w.Header().Set("X-Total-Count", strconv.FormatInt(total, 10)) + } + } + writeJSON(w, http.StatusOK, resp) } diff --git a/server/pkg/db/generated/comment.sql.go b/server/pkg/db/generated/comment.sql.go index efaeb13c..c4b8e9b9 100644 --- a/server/pkg/db/generated/comment.sql.go +++ b/server/pkg/db/generated/comment.sql.go @@ -11,6 +11,23 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const countComments = `-- name: CountComments :one +SELECT count(*) FROM comment +WHERE issue_id = $1 AND workspace_id = $2 +` + +type CountCommentsParams struct { + IssueID pgtype.UUID `json:"issue_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) CountComments(ctx context.Context, arg CountCommentsParams) (int64, error) { + row := q.db.QueryRow(ctx, countComments, arg.IssueID, arg.WorkspaceID) + var count int64 + err := row.Scan(&count) + return count, err +} + const createComment = `-- name: CreateComment :one INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, parent_id) VALUES ($1, $2, $3, $4, $5, $6, $7) @@ -155,6 +172,151 @@ func (q *Queries) ListComments(ctx context.Context, arg ListCommentsParams) ([]C return items, nil } +const listCommentsPaginated = `-- name: ListCommentsPaginated :many +SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment +WHERE issue_id = $1 AND workspace_id = $2 +ORDER BY created_at ASC +LIMIT $3 OFFSET $4 +` + +type ListCommentsPaginatedParams struct { + IssueID pgtype.UUID `json:"issue_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListCommentsPaginated(ctx context.Context, arg ListCommentsPaginatedParams) ([]Comment, error) { + rows, err := q.db.Query(ctx, listCommentsPaginated, + arg.IssueID, + arg.WorkspaceID, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Comment{} + for rows.Next() { + var i Comment + if err := rows.Scan( + &i.ID, + &i.IssueID, + &i.AuthorType, + &i.AuthorID, + &i.Content, + &i.Type, + &i.CreatedAt, + &i.UpdatedAt, + &i.ParentID, + &i.WorkspaceID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listCommentsSince = `-- name: ListCommentsSince :many +SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment +WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3 +ORDER BY created_at ASC +` + +type ListCommentsSinceParams struct { + IssueID pgtype.UUID `json:"issue_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) ListCommentsSince(ctx context.Context, arg ListCommentsSinceParams) ([]Comment, error) { + rows, err := q.db.Query(ctx, listCommentsSince, arg.IssueID, arg.WorkspaceID, arg.CreatedAt) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Comment{} + for rows.Next() { + var i Comment + if err := rows.Scan( + &i.ID, + &i.IssueID, + &i.AuthorType, + &i.AuthorID, + &i.Content, + &i.Type, + &i.CreatedAt, + &i.UpdatedAt, + &i.ParentID, + &i.WorkspaceID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listCommentsSincePaginated = `-- name: ListCommentsSincePaginated :many +SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment +WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3 +ORDER BY created_at ASC +LIMIT $4 OFFSET $5 +` + +type ListCommentsSincePaginatedParams struct { + IssueID pgtype.UUID `json:"issue_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListCommentsSincePaginated(ctx context.Context, arg ListCommentsSincePaginatedParams) ([]Comment, error) { + rows, err := q.db.Query(ctx, listCommentsSincePaginated, + arg.IssueID, + arg.WorkspaceID, + arg.CreatedAt, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Comment{} + for rows.Next() { + var i Comment + if err := rows.Scan( + &i.ID, + &i.IssueID, + &i.AuthorType, + &i.AuthorID, + &i.Content, + &i.Type, + &i.CreatedAt, + &i.UpdatedAt, + &i.ParentID, + &i.WorkspaceID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateComment = `-- name: UpdateComment :one UPDATE comment SET content = $2, diff --git a/server/pkg/db/queries/comment.sql b/server/pkg/db/queries/comment.sql index e26d2730..20dd4b95 100644 --- a/server/pkg/db/queries/comment.sql +++ b/server/pkg/db/queries/comment.sql @@ -3,6 +3,27 @@ SELECT * FROM comment WHERE issue_id = $1 AND workspace_id = $2 ORDER BY created_at ASC; +-- name: ListCommentsPaginated :many +SELECT * FROM comment +WHERE issue_id = $1 AND workspace_id = $2 +ORDER BY created_at ASC +LIMIT $3 OFFSET $4; + +-- name: ListCommentsSince :many +SELECT * FROM comment +WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3 +ORDER BY created_at ASC; + +-- name: ListCommentsSincePaginated :many +SELECT * FROM comment +WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3 +ORDER BY created_at ASC +LIMIT $4 OFFSET $5; + +-- name: CountComments :one +SELECT count(*) FROM comment +WHERE issue_id = $1 AND workspace_id = $2; + -- name: GetComment :one SELECT * FROM comment WHERE id = $1;