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:
Bohan Jiang 2026-04-08 15:57:13 +08:00 committed by GitHub
parent 0dcaa60919
commit a8a8ff6eca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 267 additions and 19 deletions

View file

@ -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)
})
})

View file

@ -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 {

View file

@ -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(

View file

@ -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;