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

@ -166,6 +166,7 @@ vi.mock("@/shared/api", () => ({
getActiveTasksForIssue: vi.fn().mockResolvedValue({ tasks: [] }), getActiveTasksForIssue: vi.fn().mockResolvedValue({ tasks: [] }),
listTasksByIssue: vi.fn().mockResolvedValue([]), listTasksByIssue: vi.fn().mockResolvedValue([]),
listTaskMessages: vi.fn().mockResolvedValue([]), listTaskMessages: vi.fn().mockResolvedValue([]),
listChildIssues: vi.fn().mockResolvedValue({ issues: [] }),
}, },
})); }));

View file

@ -13,6 +13,7 @@ import {
Link2, Link2,
MoreHorizontal, MoreHorizontal,
PanelRight, PanelRight,
Plus,
Trash2, Trash2,
UserMinus, UserMinus,
Users, Users,
@ -57,7 +58,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"; import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar"; import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
import { ActorAvatar } from "@/components/common/actor-avatar"; import { ActorAvatar } from "@/components/common/actor-avatar";
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types"; import type { Issue, UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config"; import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components"; import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components";
import { CommentCard } from "./comment-card"; import { CommentCard } from "./comment-card";
@ -75,6 +76,7 @@ import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions";
import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers"; import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers";
import { ReactionBar } from "@/components/common/reaction-bar"; import { ReactionBar } from "@/components/common/reaction-bar";
import { useFileUpload } from "@/shared/hooks/use-file-upload"; import { useFileUpload } from "@/shared/hooks/use-file-upload";
import { useModalStore } from "@/features/modals";
import { timeAgo } from "@/shared/utils"; import { timeAgo } from "@/shared/utils";
function shortDate(date: string | null): string { function shortDate(date: string | null): string {
@ -225,6 +227,38 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber, subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
} = useIssueSubscribers(id, user?.id); } = useIssueSubscribers(id, user?.id);
// Sub-issue state — derive from store when possible, fetch otherwise
const [parentIssue, setParentIssue] = useState<Issue | null>(null);
const [childIssues, setChildIssues] = useState<Issue[]>([]);
// Fetch parent issue when parent_issue_id changes
useEffect(() => {
if (!issue?.parent_issue_id) {
setParentIssue(null);
return;
}
// Try store first, then fetch
const storeParent = allIssues.find((i) => i.id === issue.parent_issue_id);
if (storeParent) {
setParentIssue(storeParent);
} else {
api.getIssue(issue.parent_issue_id).then(setParentIssue).catch(() => setParentIssue(null));
}
}, [issue?.parent_issue_id, allIssues]);
// Fetch child issues once, then keep in sync via store
const childIssuesFromStore = allIssues.filter((i) => i.parent_issue_id === id);
useEffect(() => {
if (!issue) return;
// If store has children, use them directly
if (childIssuesFromStore.length > 0) {
setChildIssues(childIssuesFromStore);
return;
}
// Fetch from API (children may not be in the store yet, e.g. deep-linked)
api.listChildIssues(issue.id).then((r) => setChildIssues(r.issues)).catch(() => setChildIssues([]));
}, [issue?.id, childIssuesFromStore.length]);
const loading = issueLoading; const loading = issueLoading;
// Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId) // Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId)
@ -377,6 +411,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" /> <ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
</> </>
)} )}
{parentIssue && (
<>
<Link
href={`/issues/${parentIssue.id}`}
className="text-muted-foreground hover:text-foreground transition-colors truncate shrink-0"
>
{parentIssue.identifier}
</Link>
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
</>
)}
<span className="truncate text-muted-foreground"> <span className="truncate text-muted-foreground">
{issue.identifier} {issue.identifier}
</span> </span>
@ -547,6 +592,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{/* Create sub-issue */}
<DropdownMenuItem onClick={() => {
useModalStore.getState().open("create-issue", {
parent_issue_id: issue.id,
parent_issue_identifier: issue.identifier,
});
}}>
<Plus className="h-3.5 w-3.5" />
Create sub-issue
</DropdownMenuItem>
{/* Copy link */} {/* Copy link */}
<DropdownMenuItem onClick={() => { <DropdownMenuItem onClick={() => {
navigator.clipboard.writeText(window.location.href); navigator.clipboard.writeText(window.location.href);
@ -1005,6 +1061,63 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</div>} </div>}
</div> </div>
{/* Parent issue */}
{parentIssue && (
<div>
<div className="text-xs font-medium mb-2 flex items-center gap-1">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground rotate-90" />
Parent issue
</div>
<div className="pl-2">
<Link
href={`/issues/${parentIssue.id}`}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 -mx-2 text-xs hover:bg-accent/50 transition-colors group"
>
<StatusIcon status={parentIssue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="text-muted-foreground shrink-0">{parentIssue.identifier}</span>
<span className="truncate group-hover:text-foreground">{parentIssue.title}</span>
</Link>
</div>
</div>
)}
{/* Sub-issues */}
<div>
<div className="text-xs font-medium mb-2 flex items-center gap-1">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground rotate-90" />
Sub-issues
{childIssues.length > 0 && (
<span className="text-muted-foreground">{childIssues.length}</span>
)}
<button
type="button"
className="ml-auto p-0.5 rounded hover:bg-accent/60 transition-colors text-muted-foreground hover:text-foreground"
onClick={() => useModalStore.getState().open("create-issue", {
parent_issue_id: issue.id,
parent_issue_identifier: issue.identifier,
})}
>
<Plus className="h-3 w-3" />
</button>
</div>
<div className="pl-2 space-y-0.5">
{childIssues.map((child) => (
<Link
key={child.id}
href={`/issues/${child.id}`}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 -mx-2 text-xs hover:bg-accent/50 transition-colors group"
>
<StatusIcon status={child.status} className="h-3.5 w-3.5 shrink-0" />
<span className="text-muted-foreground shrink-0">{child.identifier}</span>
<span className="truncate group-hover:text-foreground">{child.title}</span>
</Link>
))}
{childIssues.length === 0 && (
<span className="text-xs text-muted-foreground px-2">No sub-issues</span>
)}
</div>
</div>
{/* Details section */} {/* Details section */}
<div> <div>
<button <button

View file

@ -142,6 +142,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
assignee_id: assigneeId, assignee_id: assigneeId,
due_date: dueDate || undefined, due_date: dueDate || undefined,
attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined, attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined,
parent_issue_id: (data?.parent_issue_id as string) || undefined,
}); });
clearDraft(); clearDraft();
onClose(); onClose();
@ -196,7 +197,13 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
<div className="flex items-center gap-1.5 text-xs"> <div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">{workspaceName}</span> <span className="text-muted-foreground">{workspaceName}</span>
<ChevronRight className="size-3 text-muted-foreground/50" /> <ChevronRight className="size-3 text-muted-foreground/50" />
<span className="font-medium">New issue</span> {typeof data?.parent_issue_identifier === "string" && (
<>
<span className="text-muted-foreground">{data.parent_issue_identifier}</span>
<ChevronRight className="size-3 text-muted-foreground/50" />
</>
)}
<span className="font-medium">{data?.parent_issue_id ? "New sub-issue" : "New issue"}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Tooltip> <Tooltip>

View file

@ -196,6 +196,10 @@ export class ApiClient {
}); });
} }
async listChildIssues(id: string): Promise<{ issues: Issue[] }> {
return this.fetch(`/api/issues/${id}/children`);
}
async deleteIssue(id: string): Promise<void> { async deleteIssue(id: string): Promise<void> {
await this.fetch(`/api/issues/${id}`, { method: "DELETE" }); await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
} }

View file

@ -23,6 +23,7 @@ export interface UpdateIssueRequest {
assignee_id?: string | null; assignee_id?: string | null;
position?: number; position?: number;
due_date?: string | null; due_date?: string | null;
parent_issue_id?: string | null;
} }
export interface ListIssuesParams { export interface ListIssuesParams {

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.Post("/reactions", h.AddIssueReaction)
r.Delete("/reactions", h.RemoveIssueReaction) r.Delete("/reactions", h.RemoveIssueReaction)
r.Get("/attachments", h.ListAttachments) 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) 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 { type CreateIssueRequest struct {
Title string `json:"title"` Title string `json:"title"`
Description *string `json:"description"` Description *string `json:"description"`
@ -251,6 +272,15 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
var parentIssueID pgtype.UUID var parentIssueID pgtype.UUID
if req.ParentIssueID != nil { if req.ParentIssueID != nil {
parentIssueID = parseUUID(*req.ParentIssueID) 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 var dueDate pgtype.Timestamptz
@ -353,6 +383,7 @@ type UpdateIssueRequest struct {
AssigneeID *string `json:"assignee_id"` AssigneeID *string `json:"assignee_id"`
Position *float64 `json:"position"` Position *float64 `json:"position"`
DueDate *string `json:"due_date"` DueDate *string `json:"due_date"`
ParentIssueID *string `json:"parent_issue_id"`
} }
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
@ -387,6 +418,7 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
AssigneeType: prevIssue.AssigneeType, AssigneeType: prevIssue.AssigneeType,
AssigneeID: prevIssue.AssigneeID, AssigneeID: prevIssue.AssigneeID,
DueDate: prevIssue.DueDate, DueDate: prevIssue.DueDate,
ParentIssueID: prevIssue.ParentIssueID,
} }
// COALESCE fields — only set when explicitly provided // 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 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. // Enforce agent visibility: private agents can only be assigned by owner/admin.
if req.AssigneeType != nil && *req.AssigneeType == "agent" && req.AssigneeID != nil { if req.AssigneeType != nil && *req.AssigneeType == "agent" && req.AssigneeID != nil {
@ -658,6 +724,7 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
AssigneeType: prevIssue.AssigneeType, AssigneeType: prevIssue.AssigneeType,
AssigneeID: prevIssue.AssigneeID, AssigneeID: prevIssue.AssigneeID,
DueDate: prevIssue.DueDate, DueDate: prevIssue.DueDate,
ParentIssueID: prevIssue.ParentIssueID,
} }
if req.Updates.Title != nil { if req.Updates.Title != nil {

View file

@ -216,6 +216,51 @@ func (q *Queries) GetIssueInWorkspace(ctx context.Context, arg GetIssueInWorkspa
return i, err 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 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 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 WHERE workspace_id = $1
@ -345,6 +390,7 @@ UPDATE issue SET
assignee_id = $7, assignee_id = $7,
position = COALESCE($8, position), position = COALESCE($8, position),
due_date = $9, due_date = $9,
parent_issue_id = $10,
updated_at = now() updated_at = now()
WHERE id = $1 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 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
@ -360,6 +406,7 @@ type UpdateIssueParams struct {
AssigneeID pgtype.UUID `json:"assignee_id"` AssigneeID pgtype.UUID `json:"assignee_id"`
Position pgtype.Float8 `json:"position"` Position pgtype.Float8 `json:"position"`
DueDate pgtype.Timestamptz `json:"due_date"` DueDate pgtype.Timestamptz `json:"due_date"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
} }
func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue, error) { 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.AssigneeID,
arg.Position, arg.Position,
arg.DueDate, arg.DueDate,
arg.ParentIssueID,
) )
var i Issue var i Issue
err := row.Scan( err := row.Scan(

View file

@ -38,6 +38,7 @@ UPDATE issue SET
assignee_id = sqlc.narg('assignee_id'), assignee_id = sqlc.narg('assignee_id'),
position = COALESCE(sqlc.narg('position'), position), position = COALESCE(sqlc.narg('position'), position),
due_date = sqlc.narg('due_date'), due_date = sqlc.narg('due_date'),
parent_issue_id = sqlc.narg('parent_issue_id'),
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1
RETURNING *; RETURNING *;
@ -66,3 +67,8 @@ WHERE workspace_id = $1
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) 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('priority')::text IS NULL OR priority = sqlc.narg('priority'))
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')); 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;