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

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