diff --git a/packages/sdk/src/api-client.ts b/packages/sdk/src/api-client.ts index b64e3357..6e7a2385 100644 --- a/packages/sdk/src/api-client.ts +++ b/packages/sdk/src/api-client.ts @@ -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 { + async listIssues(params?: ListIssuesParams): Promise { 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 { + return this.fetch(`/api/comments/${commentId}`, { + method: "PUT", + body: JSON.stringify({ content }), + }); + } + + async deleteComment(commentId: string): Promise { + await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" }); + } + // Agents async listAgents(params?: { workspace_id?: string }): Promise { const search = new URLSearchParams(); diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index b9d42769..15cfdc08 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -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 { diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index 54242572..2b8702ad 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -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; +} diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index ee40686b..078f31f5 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -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) diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 89297927..77affa1d 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -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) } diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 0ead1c48..88b348fb 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -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 { diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index 21922612..b9185d99 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -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( diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index beef26b8..172ee9f2 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -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 *;