feat(server): add comment CRUD endpoints and issue filter/update enhancements
- Add UpdateComment and DeleteComment handlers with /api/comments/{commentId} routes
- Add broadcast for comment create/update/delete WebSocket events
- Support status, priority, and assignee_id filters on ListIssues
- Extend UpdateIssue to handle due_date, acceptance_criteria, context_refs, repository
- Properly distinguish "field not sent" vs "field sent as null" in UpdateIssue
- Add corresponding SDK methods and TypeScript types
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fff7753a0c
commit
cc2281416e
8 changed files with 237 additions and 27 deletions
|
|
@ -6,6 +6,7 @@ import type {
|
|||
UpdateMeRequest,
|
||||
CreateMemberRequest,
|
||||
UpdateMemberRequest,
|
||||
ListIssuesParams,
|
||||
Agent,
|
||||
CreateAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
|
|
@ -97,12 +98,15 @@ export class ApiClient {
|
|||
}
|
||||
|
||||
// Issues
|
||||
async listIssues(params?: { limit?: number; offset?: number; workspace_id?: string }): Promise<ListIssuesResponse> {
|
||||
async listIssues(params?: ListIssuesParams): Promise<ListIssuesResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.limit) search.set("limit", String(params.limit));
|
||||
if (params?.offset) search.set("offset", String(params.offset));
|
||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||
if (wsId) search.set("workspace_id", wsId);
|
||||
if (params?.status) search.set("status", params.status);
|
||||
if (params?.priority) search.set("priority", params.priority);
|
||||
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
|
||||
return this.fetch(`/api/issues?${search}`);
|
||||
}
|
||||
|
||||
|
|
@ -142,6 +146,17 @@ export class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
async updateComment(commentId: string, content: string): Promise<Comment> {
|
||||
return this.fetch(`/api/comments/${commentId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteComment(commentId: string): Promise<void> {
|
||||
await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Agents
|
||||
async listAgents(params?: { workspace_id?: string }): Promise<Agent[]> {
|
||||
const search = new URLSearchParams();
|
||||
|
|
|
|||
|
|
@ -23,6 +23,19 @@ export interface UpdateIssueRequest {
|
|||
assignee_type?: IssueAssigneeType | null;
|
||||
assignee_id?: string | null;
|
||||
position?: number;
|
||||
due_date?: string | null;
|
||||
acceptance_criteria?: string[];
|
||||
context_refs?: string[];
|
||||
repository?: { url: string; branch?: string; path?: string } | null;
|
||||
}
|
||||
|
||||
export interface ListIssuesParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
workspace_id?: string;
|
||||
status?: IssueStatus;
|
||||
priority?: IssuePriority;
|
||||
assignee_id?: string;
|
||||
}
|
||||
|
||||
export interface ListIssuesResponse {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import type { Issue } from "./issue.js";
|
||||
import type { Agent } from "./agent.js";
|
||||
import type { InboxItem } from "./inbox.js";
|
||||
import type { Comment } from "./comment.js";
|
||||
|
||||
// WebSocket event types (matching Go server protocol/events.go)
|
||||
export type WSEventType =
|
||||
| "issue:created"
|
||||
| "issue:updated"
|
||||
| "issue:deleted"
|
||||
| "comment:created"
|
||||
| "comment:updated"
|
||||
| "comment:deleted"
|
||||
| "agent:status"
|
||||
| "task:dispatch"
|
||||
| "task:progress"
|
||||
|
|
@ -40,3 +44,16 @@ export interface AgentStatusPayload {
|
|||
export interface InboxNewPayload {
|
||||
item: InboxItem;
|
||||
}
|
||||
|
||||
export interface CommentCreatedPayload {
|
||||
comment: Comment;
|
||||
}
|
||||
|
||||
export interface CommentUpdatedPayload {
|
||||
comment: Comment;
|
||||
}
|
||||
|
||||
export interface CommentDeletedPayload {
|
||||
comment_id: string;
|
||||
issue_id: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,12 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub) chi.Router {
|
|||
})
|
||||
})
|
||||
|
||||
// Comments
|
||||
r.Route("/api/comments/{commentId}", func(r chi.Router) {
|
||||
r.Put("/", h.UpdateComment)
|
||||
r.Delete("/", h.DeleteComment)
|
||||
})
|
||||
|
||||
// Agents
|
||||
r.Route("/api/agents", func(r chi.Router) {
|
||||
r.Get("/", h.ListAgents)
|
||||
|
|
|
|||
|
|
@ -96,5 +96,58 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, commentToResponse(comment))
|
||||
resp := commentToResponse(comment)
|
||||
h.broadcast("comment:created", map[string]any{"comment": resp})
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||
commentId := chi.URLParam(r, "commentId")
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Content == "" {
|
||||
writeError(w, http.StatusBadRequest, "content is required")
|
||||
return
|
||||
}
|
||||
|
||||
comment, err := h.Queries.UpdateComment(r.Context(), db.UpdateCommentParams{
|
||||
ID: parseUUID(commentId),
|
||||
Content: req.Content,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update comment")
|
||||
return
|
||||
}
|
||||
|
||||
resp := commentToResponse(comment)
|
||||
h.broadcast("comment:updated", map[string]any{"comment": resp})
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
|
||||
commentId := chi.URLParam(r, "commentId")
|
||||
|
||||
// Get the comment first to know the issue_id for the broadcast
|
||||
comment, err := h.Queries.GetComment(r.Context(), parseUUID(commentId))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "comment not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Queries.DeleteComment(r.Context(), parseUUID(commentId)); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete comment")
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcast("comment:deleted", map[string]any{
|
||||
"comment_id": commentId,
|
||||
"issue_id": uuidToString(comment.IssueID),
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ package handler
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
|
@ -97,10 +99,27 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// Parse optional filter params
|
||||
var statusFilter pgtype.Text
|
||||
if s := r.URL.Query().Get("status"); s != "" {
|
||||
statusFilter = pgtype.Text{String: s, Valid: true}
|
||||
}
|
||||
var priorityFilter pgtype.Text
|
||||
if p := r.URL.Query().Get("priority"); p != "" {
|
||||
priorityFilter = pgtype.Text{String: p, Valid: true}
|
||||
}
|
||||
var assigneeFilter pgtype.UUID
|
||||
if a := r.URL.Query().Get("assignee_id"); a != "" {
|
||||
assigneeFilter = parseUUID(a)
|
||||
}
|
||||
|
||||
issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
Status: statusFilter,
|
||||
Priority: priorityFilter,
|
||||
AssigneeID: assigneeFilter,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list issues")
|
||||
|
|
@ -249,31 +268,52 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
type UpdateIssueRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status *string `json:"status"`
|
||||
Priority *string `json:"priority"`
|
||||
AssigneeType *string `json:"assignee_type"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
Position *float64 `json:"position"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status *string `json:"status"`
|
||||
Priority *string `json:"priority"`
|
||||
AssigneeType *string `json:"assignee_type"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
Position *float64 `json:"position"`
|
||||
DueDate *string `json:"due_date"`
|
||||
AcceptanceCriteria *[]any `json:"acceptance_criteria"`
|
||||
ContextRefs *[]any `json:"context_refs"`
|
||||
Repository *any `json:"repository"`
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, ok := h.loadIssueForUser(w, r, id); !ok {
|
||||
current, ok := h.loadIssueForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Read body as raw bytes so we can detect which fields were explicitly sent
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "failed to read request body")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateIssueRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Track which fields were explicitly present in JSON (even if null)
|
||||
var rawFields map[string]json.RawMessage
|
||||
json.Unmarshal(bodyBytes, &rawFields)
|
||||
|
||||
// Pre-fill nullable fields (bare sqlc.narg) with current values
|
||||
params := db.UpdateIssueParams{
|
||||
ID: parseUUID(id),
|
||||
ID: current.ID,
|
||||
AssigneeType: current.AssigneeType,
|
||||
AssigneeID: current.AssigneeID,
|
||||
DueDate: current.DueDate,
|
||||
}
|
||||
|
||||
// COALESCE fields — only set when explicitly provided
|
||||
if req.Title != nil {
|
||||
params.Title = pgtype.Text{String: *req.Title, Valid: true}
|
||||
}
|
||||
|
|
@ -286,15 +326,49 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
if req.Priority != nil {
|
||||
params.Priority = pgtype.Text{String: *req.Priority, Valid: true}
|
||||
}
|
||||
if req.AssigneeType != nil {
|
||||
params.AssigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true}
|
||||
}
|
||||
if req.AssigneeID != nil {
|
||||
params.AssigneeID = parseUUID(*req.AssigneeID)
|
||||
}
|
||||
if req.Position != nil {
|
||||
params.Position = pgtype.Float8{Float64: *req.Position, Valid: true}
|
||||
}
|
||||
if req.AcceptanceCriteria != nil {
|
||||
ac, _ := json.Marshal(*req.AcceptanceCriteria)
|
||||
params.AcceptanceCriteria = ac
|
||||
}
|
||||
if req.ContextRefs != nil {
|
||||
cr, _ := json.Marshal(*req.ContextRefs)
|
||||
params.ContextRefs = cr
|
||||
}
|
||||
if req.Repository != nil {
|
||||
repo, _ := json.Marshal(*req.Repository)
|
||||
params.Repository = repo
|
||||
}
|
||||
|
||||
// Nullable fields — only override when explicitly present in JSON
|
||||
if _, ok := rawFields["assignee_type"]; ok {
|
||||
if req.AssigneeType != nil {
|
||||
params.AssigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true}
|
||||
} else {
|
||||
params.AssigneeType = pgtype.Text{Valid: false} // explicit null = unassign
|
||||
}
|
||||
}
|
||||
if _, ok := rawFields["assignee_id"]; ok {
|
||||
if req.AssigneeID != nil {
|
||||
params.AssigneeID = parseUUID(*req.AssigneeID)
|
||||
} else {
|
||||
params.AssigneeID = pgtype.UUID{Valid: false} // explicit null = unassign
|
||||
}
|
||||
}
|
||||
if _, ok := rawFields["due_date"]; ok {
|
||||
if req.DueDate != nil && *req.DueDate != "" {
|
||||
t, err := time.Parse(time.RFC3339, *req.DueDate)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid due_date format, expected RFC3339")
|
||||
return
|
||||
}
|
||||
params.DueDate = pgtype.Timestamptz{Time: t, Valid: true}
|
||||
} else {
|
||||
params.DueDate = pgtype.Timestamptz{Valid: false} // explicit null = clear date
|
||||
}
|
||||
}
|
||||
|
||||
issue, err := h.Queries.UpdateIssue(r.Context(), params)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -123,6 +123,9 @@ func (q *Queries) GetIssue(ctx context.Context, id pgtype.UUID) (Issue, error) {
|
|||
const listIssues = `-- name: ListIssues :many
|
||||
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, repository, position, due_date, created_at, updated_at FROM issue
|
||||
WHERE workspace_id = $1
|
||||
AND ($4::text IS NULL OR status = $4)
|
||||
AND ($5::text IS NULL OR priority = $5)
|
||||
AND ($6::uuid IS NULL OR assignee_id = $6)
|
||||
ORDER BY position ASC, created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
|
@ -131,10 +134,20 @@ type ListIssuesParams struct {
|
|||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
Status pgtype.Text `json:"status"`
|
||||
Priority pgtype.Text `json:"priority"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue, error) {
|
||||
rows, err := q.db.Query(ctx, listIssues, arg.WorkspaceID, arg.Limit, arg.Offset)
|
||||
rows, err := q.db.Query(ctx, listIssues,
|
||||
arg.WorkspaceID,
|
||||
arg.Limit,
|
||||
arg.Offset,
|
||||
arg.Status,
|
||||
arg.Priority,
|
||||
arg.AssigneeID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -181,20 +194,28 @@ UPDATE issue SET
|
|||
assignee_type = $6,
|
||||
assignee_id = $7,
|
||||
position = COALESCE($8, position),
|
||||
due_date = $9,
|
||||
acceptance_criteria = COALESCE($10, acceptance_criteria),
|
||||
context_refs = COALESCE($11, context_refs),
|
||||
repository = COALESCE($12, repository),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, repository, position, due_date, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateIssueParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Title pgtype.Text `json:"title"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Status pgtype.Text `json:"status"`
|
||||
Priority pgtype.Text `json:"priority"`
|
||||
AssigneeType pgtype.Text `json:"assignee_type"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
Position pgtype.Float8 `json:"position"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Title pgtype.Text `json:"title"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Status pgtype.Text `json:"status"`
|
||||
Priority pgtype.Text `json:"priority"`
|
||||
AssigneeType pgtype.Text `json:"assignee_type"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
Position pgtype.Float8 `json:"position"`
|
||||
DueDate pgtype.Timestamptz `json:"due_date"`
|
||||
AcceptanceCriteria []byte `json:"acceptance_criteria"`
|
||||
ContextRefs []byte `json:"context_refs"`
|
||||
Repository []byte `json:"repository"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue, error) {
|
||||
|
|
@ -207,6 +228,10 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue
|
|||
arg.AssigneeType,
|
||||
arg.AssigneeID,
|
||||
arg.Position,
|
||||
arg.DueDate,
|
||||
arg.AcceptanceCriteria,
|
||||
arg.ContextRefs,
|
||||
arg.Repository,
|
||||
)
|
||||
var i Issue
|
||||
err := row.Scan(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
-- name: ListIssues :many
|
||||
SELECT * FROM issue
|
||||
WHERE workspace_id = $1
|
||||
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
|
||||
AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
|
||||
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'))
|
||||
ORDER BY position ASC, created_at DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
|
||||
|
|
@ -27,6 +30,10 @@ UPDATE issue SET
|
|||
assignee_type = sqlc.narg('assignee_type'),
|
||||
assignee_id = sqlc.narg('assignee_id'),
|
||||
position = COALESCE(sqlc.narg('position'), position),
|
||||
due_date = sqlc.narg('due_date'),
|
||||
acceptance_criteria = COALESCE(sqlc.narg('acceptance_criteria'), acceptance_criteria),
|
||||
context_refs = COALESCE(sqlc.narg('context_refs'), context_refs),
|
||||
repository = COALESCE(sqlc.narg('repository'), repository),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue