feat(issues): add sub-issue support (#483)
* feat(issues): add sub-issue support
- Backend: Add ListChildIssues SQL query, add parent_issue_id to UpdateIssue,
add GET /api/issues/{id}/children endpoint
- Frontend: Display parent issue breadcrumb and link in issue detail sidebar,
show child issues list with status icons, add "Create sub-issue" action in
dropdown menu and sidebar, pass parent_issue_id through create issue modal
- Update test mocks for new API method
* fix(issues): add parent validation, cycle detection, and improve child refresh
- CreateIssue: validate parent issue exists in the same workspace
- UpdateIssue: validate parent exists, prevent self-referencing, detect
circular parent chains (up to 10 levels deep)
- Frontend: derive child issues from store when available instead of
refetching on every global issue count change
This commit is contained in:
parent
0dcaa60919
commit
a8a8ff6eca
9 changed files with 267 additions and 19 deletions
|
|
@ -177,6 +177,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
|||
r.Post("/reactions", h.AddIssueReaction)
|
||||
r.Delete("/reactions", h.RemoveIssueReaction)
|
||||
r.Get("/attachments", h.ListAttachments)
|
||||
r.Get("/children", h.ListChildIssues)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -190,6 +190,27 @@ func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) ListChildIssues(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
issue, ok := h.loadIssueForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
children, err := h.Queries.ListChildIssues(r.Context(), issue.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list child issues")
|
||||
return
|
||||
}
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
resp := make([]IssueResponse, len(children))
|
||||
for i, child := range children {
|
||||
resp[i] = issueToResponse(child, prefix)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"issues": resp,
|
||||
})
|
||||
}
|
||||
|
||||
type CreateIssueRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
|
|
@ -251,6 +272,15 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
var parentIssueID pgtype.UUID
|
||||
if req.ParentIssueID != nil {
|
||||
parentIssueID = parseUUID(*req.ParentIssueID)
|
||||
// Validate parent exists in the same workspace.
|
||||
parent, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
||||
ID: parentIssueID,
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
})
|
||||
if err != nil || !parent.ID.Valid {
|
||||
writeError(w, http.StatusBadRequest, "parent issue not found in this workspace")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var dueDate pgtype.Timestamptz
|
||||
|
|
@ -353,6 +383,7 @@ type UpdateIssueRequest struct {
|
|||
AssigneeID *string `json:"assignee_id"`
|
||||
Position *float64 `json:"position"`
|
||||
DueDate *string `json:"due_date"`
|
||||
ParentIssueID *string `json:"parent_issue_id"`
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -383,10 +414,11 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// Pre-fill nullable fields (bare sqlc.narg) with current values
|
||||
params := db.UpdateIssueParams{
|
||||
ID: prevIssue.ID,
|
||||
AssigneeType: prevIssue.AssigneeType,
|
||||
AssigneeID: prevIssue.AssigneeID,
|
||||
DueDate: prevIssue.DueDate,
|
||||
ID: prevIssue.ID,
|
||||
AssigneeType: prevIssue.AssigneeType,
|
||||
AssigneeID: prevIssue.AssigneeID,
|
||||
DueDate: prevIssue.DueDate,
|
||||
ParentIssueID: prevIssue.ParentIssueID,
|
||||
}
|
||||
|
||||
// COALESCE fields — only set when explicitly provided
|
||||
|
|
@ -432,6 +464,40 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
params.DueDate = pgtype.Timestamptz{Valid: false} // explicit null = clear date
|
||||
}
|
||||
}
|
||||
if _, ok := rawFields["parent_issue_id"]; ok {
|
||||
if req.ParentIssueID != nil {
|
||||
newParentID := parseUUID(*req.ParentIssueID)
|
||||
// Cannot set self as parent.
|
||||
if uuidToString(newParentID) == id {
|
||||
writeError(w, http.StatusBadRequest, "an issue cannot be its own parent")
|
||||
return
|
||||
}
|
||||
// Validate parent exists in the same workspace.
|
||||
if _, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
||||
ID: newParentID,
|
||||
WorkspaceID: prevIssue.WorkspaceID,
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "parent issue not found in this workspace")
|
||||
return
|
||||
}
|
||||
// Cycle detection: walk up from the new parent to ensure we don't reach this issue.
|
||||
cursor := newParentID
|
||||
for depth := 0; depth < 10; depth++ {
|
||||
ancestor, err := h.Queries.GetIssue(r.Context(), cursor)
|
||||
if err != nil || !ancestor.ParentIssueID.Valid {
|
||||
break
|
||||
}
|
||||
if uuidToString(ancestor.ParentIssueID) == id {
|
||||
writeError(w, http.StatusBadRequest, "circular parent relationship detected")
|
||||
return
|
||||
}
|
||||
cursor = ancestor.ParentIssueID
|
||||
}
|
||||
params.ParentIssueID = newParentID
|
||||
} else {
|
||||
params.ParentIssueID = pgtype.UUID{Valid: false} // explicit null = remove parent
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce agent visibility: private agents can only be assigned by owner/admin.
|
||||
if req.AssigneeType != nil && *req.AssigneeType == "agent" && req.AssigneeID != nil {
|
||||
|
|
@ -654,10 +720,11 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
params := db.UpdateIssueParams{
|
||||
ID: prevIssue.ID,
|
||||
AssigneeType: prevIssue.AssigneeType,
|
||||
AssigneeID: prevIssue.AssigneeID,
|
||||
DueDate: prevIssue.DueDate,
|
||||
ID: prevIssue.ID,
|
||||
AssigneeType: prevIssue.AssigneeType,
|
||||
AssigneeID: prevIssue.AssigneeID,
|
||||
DueDate: prevIssue.DueDate,
|
||||
ParentIssueID: prevIssue.ParentIssueID,
|
||||
}
|
||||
|
||||
if req.Updates.Title != nil {
|
||||
|
|
|
|||
|
|
@ -216,6 +216,51 @@ func (q *Queries) GetIssueInWorkspace(ctx context.Context, arg GetIssueInWorkspa
|
|||
return i, err
|
||||
}
|
||||
|
||||
const listChildIssues = `-- name: ListChildIssues :many
|
||||
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
|
||||
WHERE parent_issue_id = $1
|
||||
ORDER BY position ASC, created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListChildIssues(ctx context.Context, parentIssueID pgtype.UUID) ([]Issue, error) {
|
||||
rows, err := q.db.Query(ctx, listChildIssues, parentIssueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Issue{}
|
||||
for rows.Next() {
|
||||
var i Issue
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.Priority,
|
||||
&i.AssigneeType,
|
||||
&i.AssigneeID,
|
||||
&i.CreatorType,
|
||||
&i.CreatorID,
|
||||
&i.ParentIssueID,
|
||||
&i.AcceptanceCriteria,
|
||||
&i.ContextRefs,
|
||||
&i.Position,
|
||||
&i.DueDate,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Number,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
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, position, due_date, created_at, updated_at, number FROM issue
|
||||
WHERE workspace_id = $1
|
||||
|
|
@ -345,21 +390,23 @@ UPDATE issue SET
|
|||
assignee_id = $7,
|
||||
position = COALESCE($8, position),
|
||||
due_date = $9,
|
||||
parent_issue_id = $10,
|
||||
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, position, due_date, created_at, updated_at, number
|
||||
`
|
||||
|
||||
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"`
|
||||
DueDate pgtype.Timestamptz `json:"due_date"`
|
||||
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"`
|
||||
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue, error) {
|
||||
|
|
@ -373,6 +420,7 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue
|
|||
arg.AssigneeID,
|
||||
arg.Position,
|
||||
arg.DueDate,
|
||||
arg.ParentIssueID,
|
||||
)
|
||||
var i Issue
|
||||
err := row.Scan(
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ UPDATE issue SET
|
|||
assignee_id = sqlc.narg('assignee_id'),
|
||||
position = COALESCE(sqlc.narg('position'), position),
|
||||
due_date = sqlc.narg('due_date'),
|
||||
parent_issue_id = sqlc.narg('parent_issue_id'),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
|
@ -66,3 +67,8 @@ 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'));
|
||||
|
||||
-- name: ListChildIssues :many
|
||||
SELECT * FROM issue
|
||||
WHERE parent_issue_id = $1
|
||||
ORDER BY position ASC, created_at DESC;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue