mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
2 Commits
feat/cli-v
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bc8dcc053 | ||
|
|
473ec17c65 |
@@ -185,6 +185,9 @@ vi.mock("@/shared/api", () => ({
|
||||
getActiveTaskForIssue: vi.fn().mockResolvedValue({ task: null }),
|
||||
listTasksByIssue: vi.fn().mockResolvedValue([]),
|
||||
listTaskMessages: vi.fn().mockResolvedValue([]),
|
||||
listDependencies: vi.fn().mockResolvedValue([]),
|
||||
createDependency: vi.fn().mockResolvedValue({}),
|
||||
deleteDependency: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Link2,
|
||||
MoreHorizontal,
|
||||
PanelRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
UserMinus,
|
||||
Users,
|
||||
@@ -57,7 +58,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
|
||||
import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
|
||||
import type { UpdateIssueRequest, IssueStatus, IssuePriority, IssueDependency, IssueDependencyType, TimelineEntry } from "@/shared/types";
|
||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components";
|
||||
import { CommentCard } from "./comment-card";
|
||||
@@ -125,11 +126,42 @@ function formatActivity(
|
||||
return "completed the task";
|
||||
case "task_failed":
|
||||
return "task failed";
|
||||
case "issue_relation_added": {
|
||||
const relType = details.relation_type ?? "";
|
||||
const identifier = details.related_issue_identifier ?? "?";
|
||||
const label = relationTypeLabel(relType);
|
||||
return `added relation: ${label} ${identifier}`;
|
||||
}
|
||||
case "issue_relation_removed": {
|
||||
const relType = details.relation_type ?? "";
|
||||
const identifier = details.related_issue_identifier ?? "?";
|
||||
const label = relationTypeLabel(relType);
|
||||
return `removed relation: ${label} ${identifier}`;
|
||||
}
|
||||
default:
|
||||
return entry.action ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
function relationTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case "blocks":
|
||||
return "blocks";
|
||||
case "blocked_by":
|
||||
return "blocked by";
|
||||
case "related":
|
||||
return "related to";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
function inverseRelType(type: string): string {
|
||||
if (type === "blocks") return "blocked_by";
|
||||
if (type === "blocked_by") return "blocks";
|
||||
return type;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property row
|
||||
@@ -194,7 +226,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [propertiesOpen, setPropertiesOpen] = useState(true);
|
||||
const [relationsOpen, setRelationsOpen] = useState(true);
|
||||
const [detailsOpen, setDetailsOpen] = useState(true);
|
||||
const [dependencies, setDependencies] = useState<IssueDependency[]>([]);
|
||||
const [addRelOpen, setAddRelOpen] = useState(false);
|
||||
const [relSearch, setRelSearch] = useState("");
|
||||
const [relType, setRelType] = useState<IssueDependencyType>("related");
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
@@ -240,6 +277,32 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
|
||||
const loading = issueLoading;
|
||||
|
||||
// Fetch issue dependencies
|
||||
const fetchDependencies = useCallback(() => {
|
||||
api.listDependencies(id).then(setDependencies).catch(() => {});
|
||||
}, [id]);
|
||||
useEffect(() => { fetchDependencies(); }, [fetchDependencies]);
|
||||
|
||||
const handleAddDependency = useCallback(async (targetIssueId: string, type: IssueDependencyType) => {
|
||||
try {
|
||||
await api.createDependency(id, targetIssueId, type);
|
||||
fetchDependencies();
|
||||
setAddRelOpen(false);
|
||||
setRelSearch("");
|
||||
} catch {
|
||||
toast.error("Failed to add relation");
|
||||
}
|
||||
}, [id, fetchDependencies]);
|
||||
|
||||
const handleRemoveDependency = useCallback(async (depId: string) => {
|
||||
try {
|
||||
await api.deleteDependency(id, depId);
|
||||
fetchDependencies();
|
||||
} catch {
|
||||
toast.error("Failed to remove relation");
|
||||
}
|
||||
}, [id, fetchDependencies]);
|
||||
|
||||
// Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId)
|
||||
useEffect(() => {
|
||||
if (!highlightCommentId || timeline.length === 0) return;
|
||||
@@ -871,6 +934,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const isStatusChange = entry.action === "status_changed";
|
||||
const isPriorityChange = entry.action === "priority_changed";
|
||||
const isDueDateChange = entry.action === "due_date_changed";
|
||||
const isRelationChange = entry.action === "issue_relation_added" || entry.action === "issue_relation_removed";
|
||||
|
||||
let leadIcon: React.ReactNode;
|
||||
if (isStatusChange && details.to) {
|
||||
@@ -879,6 +943,8 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-4 w-4 shrink-0" />;
|
||||
} else if (isDueDateChange) {
|
||||
leadIcon = <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
} else if (isRelationChange) {
|
||||
leadIcon = <Link2 className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
} else {
|
||||
leadIcon = <ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={16} />;
|
||||
}
|
||||
@@ -1021,6 +1087,102 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Relations section */}
|
||||
<div>
|
||||
<div className="flex items-center mb-2">
|
||||
<button
|
||||
className={`flex flex-1 items-center gap-1 text-xs font-medium transition-colors ${relationsOpen ? "" : "text-muted-foreground hover:text-foreground"}`}
|
||||
onClick={() => setRelationsOpen(!relationsOpen)}
|
||||
>
|
||||
<ChevronRight className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${relationsOpen ? "rotate-90" : ""}`} />
|
||||
Relations
|
||||
{dependencies.length > 0 && <span className="text-muted-foreground ml-1">({dependencies.length})</span>}
|
||||
</button>
|
||||
<Popover open={addRelOpen} onOpenChange={setAddRelOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button className="p-0.5 rounded hover:bg-accent/50 text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-72 p-0">
|
||||
<div className="p-2 border-b">
|
||||
<div className="flex gap-1 mb-2">
|
||||
{(["related", "blocks", "blocked_by"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`px-2 py-0.5 text-xs rounded-full transition-colors ${relType === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:text-foreground"}`}
|
||||
onClick={() => setRelType(t)}
|
||||
>
|
||||
{relationTypeLabel(t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search issues..." value={relSearch} onValueChange={setRelSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No issues found</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allIssues
|
||||
.filter((i) => i.id !== id)
|
||||
.filter((i) => !dependencies.some(
|
||||
(d) => (d.issue_id === id ? d.depends_on_issue_id : d.issue_id) === i.id
|
||||
))
|
||||
.slice(0, 20)
|
||||
.map((i) => (
|
||||
<CommandItem
|
||||
key={i.id}
|
||||
value={`${i.identifier} ${i.title}`}
|
||||
onSelect={() => handleAddDependency(i.id, relType)}
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0 mr-1.5">{i.identifier}</span>
|
||||
<span className="truncate">{i.title}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{relationsOpen && dependencies.length > 0 && (
|
||||
<div className="space-y-1 pl-2">
|
||||
{dependencies.map((dep) => {
|
||||
// Determine which side is the "other" issue
|
||||
const isSource = dep.issue_id === id;
|
||||
const otherIdentifier = isSource ? dep.depends_on_issue_identifier : dep.issue_identifier;
|
||||
const otherTitle = isSource ? dep.depends_on_issue_title : dep.issue_title;
|
||||
const otherIssueId = isSource ? dep.depends_on_issue_id : dep.issue_id;
|
||||
// Show the relation type from this issue's perspective
|
||||
const displayType = isSource ? dep.type : inverseRelType(dep.type);
|
||||
|
||||
return (
|
||||
<div key={dep.id} className="group flex items-center gap-1.5 text-xs rounded-md px-2 -mx-2 min-h-7 hover:bg-accent/50 transition-colors">
|
||||
<Link2 className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="shrink-0 text-muted-foreground">{relationTypeLabel(displayType)}</span>
|
||||
<Link href={`/issues/${otherIssueId}`} className="flex items-center gap-1 min-w-0 hover:underline">
|
||||
<span className="shrink-0 text-muted-foreground">{otherIdentifier}</span>
|
||||
<span className="truncate">{otherTitle}</span>
|
||||
</Link>
|
||||
<button
|
||||
className="ml-auto shrink-0 opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-accent transition-all text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemoveDependency(dep.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{relationsOpen && dependencies.length === 0 && (
|
||||
<div className="pl-2 text-xs text-muted-foreground">No relations</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details section */}
|
||||
<div>
|
||||
<button
|
||||
|
||||
@@ -35,6 +35,8 @@ import type {
|
||||
TimelineEntry,
|
||||
TaskMessagePayload,
|
||||
Attachment,
|
||||
IssueDependency,
|
||||
IssueDependencyType,
|
||||
} from "@/shared/types";
|
||||
import { type Logger, noopLogger } from "@/shared/logger";
|
||||
|
||||
@@ -226,6 +228,22 @@ export class ApiClient {
|
||||
return this.fetch(`/api/issues/${issueId}/timeline`);
|
||||
}
|
||||
|
||||
// Issue dependencies
|
||||
async listDependencies(issueId: string): Promise<IssueDependency[]> {
|
||||
return this.fetch(`/api/issues/${issueId}/dependencies`);
|
||||
}
|
||||
|
||||
async createDependency(issueId: string, dependsOnIssueId: string, type: IssueDependencyType): Promise<IssueDependency> {
|
||||
return this.fetch(`/api/issues/${issueId}/dependencies`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ depends_on_issue_id: dependsOnIssueId, type }),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteDependency(issueId: string, depId: string): Promise<void> {
|
||||
await this.fetch(`/api/issues/${issueId}/dependencies/${depId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async updateComment(commentId: string, content: string): Promise<Comment> {
|
||||
return this.fetch(`/api/comments/${commentId}`, {
|
||||
method: "PUT",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueReaction } from "./issue";
|
||||
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueReaction, IssueDependency, IssueDependencyType } from "./issue";
|
||||
export type {
|
||||
Agent,
|
||||
AgentStatus,
|
||||
|
||||
@@ -20,6 +20,19 @@ export interface IssueReaction {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type IssueDependencyType = "blocks" | "blocked_by" | "related";
|
||||
|
||||
export interface IssueDependency {
|
||||
id: string;
|
||||
issue_id: string;
|
||||
depends_on_issue_id: string;
|
||||
type: IssueDependencyType;
|
||||
issue_identifier: string;
|
||||
issue_title: string;
|
||||
depends_on_issue_identifier: string;
|
||||
depends_on_issue_title: string;
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
|
||||
@@ -220,6 +220,16 @@ func registerActivityListeners(bus *events.Bus, queries *db.Queries) {
|
||||
bus.Subscribe(protocol.EventTaskFailed, func(e events.Event) {
|
||||
handleTaskActivity(ctx, bus, queries, e, "task_failed")
|
||||
})
|
||||
|
||||
// issue_dependency:created — record "issue_relation_added" on both issues
|
||||
bus.Subscribe(protocol.EventIssueDependencyCreated, func(e events.Event) {
|
||||
handleDependencyActivity(ctx, bus, queries, e, "issue_relation_added")
|
||||
})
|
||||
|
||||
// issue_dependency:removed — record "issue_relation_removed" on both issues
|
||||
bus.Subscribe(protocol.EventIssueDependencyRemoved, func(e events.Event) {
|
||||
handleDependencyActivity(ctx, bus, queries, e, "issue_relation_removed")
|
||||
})
|
||||
}
|
||||
|
||||
// handleTaskActivity records an activity for task:completed or task:failed events.
|
||||
@@ -259,6 +269,81 @@ func handleTaskActivity(ctx context.Context, bus *events.Bus, queries *db.Querie
|
||||
publishActivityEvent(bus, e, activity)
|
||||
}
|
||||
|
||||
// handleDependencyActivity records activities on both issues when a dependency
|
||||
// is created or removed. Each issue gets an activity entry with details about
|
||||
// the related issue (identifier, title, relation type).
|
||||
func handleDependencyActivity(ctx context.Context, bus *events.Bus, queries *db.Queries, e events.Event, action string) {
|
||||
payload, ok := e.Payload.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
dep, _ := payload["dependency"].(db.IssueDependency)
|
||||
issue, _ := payload["issue"].(db.Issue)
|
||||
targetIssue, _ := payload["target_issue"].(db.Issue)
|
||||
issueIdentifier, _ := payload["issue_identifier"].(string)
|
||||
targetIdentifier, _ := payload["target_issue_identifier"].(string)
|
||||
|
||||
issueID := util.UUIDToString(dep.IssueID)
|
||||
targetIssueID := util.UUIDToString(dep.DependsOnIssueID)
|
||||
|
||||
// Activity on the source issue: "added relation: blocks MUL-456"
|
||||
srcDetails, _ := json.Marshal(map[string]string{
|
||||
"related_issue_id": targetIssueID,
|
||||
"related_issue_identifier": targetIdentifier,
|
||||
"related_issue_title": targetIssue.Title,
|
||||
"relation_type": dep.Type,
|
||||
})
|
||||
srcActivity, err := queries.CreateActivity(ctx, db.CreateActivityParams{
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
IssueID: dep.IssueID,
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
Action: action,
|
||||
Details: srcDetails,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("activity: failed to record dependency activity on source issue",
|
||||
"issue_id", issueID, "action", action, "error", err)
|
||||
} else {
|
||||
publishActivityEvent(bus, e, srcActivity)
|
||||
}
|
||||
|
||||
// Activity on the target issue: "added relation: blocked_by MUL-123"
|
||||
inverseType := inverseRelationType(dep.Type)
|
||||
targetDetails, _ := json.Marshal(map[string]string{
|
||||
"related_issue_id": issueID,
|
||||
"related_issue_identifier": issueIdentifier,
|
||||
"related_issue_title": issue.Title,
|
||||
"relation_type": inverseType,
|
||||
})
|
||||
targetActivity, err := queries.CreateActivity(ctx, db.CreateActivityParams{
|
||||
WorkspaceID: targetIssue.WorkspaceID,
|
||||
IssueID: dep.DependsOnIssueID,
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
Action: action,
|
||||
Details: targetDetails,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("activity: failed to record dependency activity on target issue",
|
||||
"issue_id", targetIssueID, "action", action, "error", err)
|
||||
} else {
|
||||
publishActivityEvent(bus, e, targetActivity)
|
||||
}
|
||||
}
|
||||
|
||||
// inverseRelationType returns the inverse of a dependency relation type.
|
||||
func inverseRelationType(t string) string {
|
||||
switch t {
|
||||
case "blocks":
|
||||
return "blocked_by"
|
||||
case "blocked_by":
|
||||
return "blocks"
|
||||
default:
|
||||
return t // "related" is symmetric
|
||||
}
|
||||
}
|
||||
|
||||
// publishActivityEvent sends an activity:created event for WS broadcasting.
|
||||
// Payload matches frontend ActivityCreatedPayload: { issue_id, entry: TimelineEntry }
|
||||
func publishActivityEvent(bus *events.Bus, original events.Event, activity db.ActivityLog) {
|
||||
|
||||
@@ -175,6 +175,9 @@ 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("/dependencies", h.ListIssueDependencies)
|
||||
r.Post("/dependencies", h.CreateIssueDependency)
|
||||
r.Delete("/dependencies/{depId}", h.DeleteIssueDependency)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
209
server/internal/handler/issue_dependency.go
Normal file
209
server/internal/handler/issue_dependency.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// IssueDependencyResponse is the JSON response for an issue dependency.
|
||||
type IssueDependencyResponse struct {
|
||||
ID string `json:"id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
DependsOnIssueID string `json:"depends_on_issue_id"`
|
||||
Type string `json:"type"`
|
||||
|
||||
// Enriched fields for the related issue
|
||||
IssueIdentifier string `json:"issue_identifier"`
|
||||
IssueTitle string `json:"issue_title"`
|
||||
DependsOnIssueIdentifier string `json:"depends_on_issue_identifier"`
|
||||
DependsOnIssueTitle string `json:"depends_on_issue_title"`
|
||||
}
|
||||
|
||||
// ListIssueDependencies returns all dependencies for a given issue.
|
||||
func (h *Handler) ListIssueDependencies(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
issue, ok := h.loadIssueForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
deps, err := h.Queries.ListIssueDependencies(r.Context(), issue.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list dependencies")
|
||||
return
|
||||
}
|
||||
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
|
||||
result := make([]IssueDependencyResponse, 0, len(deps))
|
||||
for _, d := range deps {
|
||||
resp := IssueDependencyResponse{
|
||||
ID: uuidToString(d.ID),
|
||||
IssueID: uuidToString(d.IssueID),
|
||||
DependsOnIssueID: uuidToString(d.DependsOnIssueID),
|
||||
Type: d.Type,
|
||||
}
|
||||
|
||||
// Enrich with issue identifiers and titles
|
||||
if srcIssue, err := h.Queries.GetIssue(r.Context(), d.IssueID); err == nil {
|
||||
resp.IssueIdentifier = prefix + "-" + strconv.Itoa(int(srcIssue.Number))
|
||||
resp.IssueTitle = srcIssue.Title
|
||||
}
|
||||
if depIssue, err := h.Queries.GetIssue(r.Context(), d.DependsOnIssueID); err == nil {
|
||||
resp.DependsOnIssueIdentifier = prefix + "-" + strconv.Itoa(int(depIssue.Number))
|
||||
resp.DependsOnIssueTitle = depIssue.Title
|
||||
}
|
||||
|
||||
result = append(result, resp)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// CreateIssueDependency creates a new dependency between two issues.
|
||||
func (h *Handler) CreateIssueDependency(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
issue, ok := h.loadIssueForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DependsOnIssueID string `json:"depends_on_issue_id"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.DependsOnIssueID == "" || req.Type == "" {
|
||||
writeError(w, http.StatusBadRequest, "depends_on_issue_id and type are required")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate relation type
|
||||
switch req.Type {
|
||||
case "blocks", "blocked_by", "related":
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "type must be one of: blocks, blocked_by, related")
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent self-reference
|
||||
if uuidToString(issue.ID) == req.DependsOnIssueID {
|
||||
writeError(w, http.StatusBadRequest, "cannot create a dependency to the same issue")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the target issue exists and belongs to the same workspace
|
||||
targetIssue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
||||
ID: parseUUID(req.DependsOnIssueID),
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "target issue not found")
|
||||
return
|
||||
}
|
||||
|
||||
dep, err := h.Queries.CreateIssueDependency(r.Context(), db.CreateIssueDependencyParams{
|
||||
IssueID: issue.ID,
|
||||
DependsOnIssueID: parseUUID(req.DependsOnIssueID),
|
||||
Type: req.Type,
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "dependency already exists")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to create dependency")
|
||||
return
|
||||
}
|
||||
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
issueIdentifier := prefix + "-" + strconv.Itoa(int(issue.Number))
|
||||
targetIdentifier := prefix + "-" + strconv.Itoa(int(targetIssue.Number))
|
||||
|
||||
userID := requestUserID(r)
|
||||
workspaceID := uuidToString(issue.WorkspaceID)
|
||||
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
||||
|
||||
// Publish event for activity listener
|
||||
h.publish(protocol.EventIssueDependencyCreated, workspaceID, actorType, actorID, map[string]any{
|
||||
"dependency": dep,
|
||||
"issue": issue,
|
||||
"target_issue": targetIssue,
|
||||
"issue_identifier": issueIdentifier,
|
||||
"target_issue_identifier": targetIdentifier,
|
||||
})
|
||||
|
||||
resp := IssueDependencyResponse{
|
||||
ID: uuidToString(dep.ID),
|
||||
IssueID: uuidToString(dep.IssueID),
|
||||
DependsOnIssueID: uuidToString(dep.DependsOnIssueID),
|
||||
Type: dep.Type,
|
||||
IssueIdentifier: issueIdentifier,
|
||||
IssueTitle: issue.Title,
|
||||
DependsOnIssueIdentifier: targetIdentifier,
|
||||
DependsOnIssueTitle: targetIssue.Title,
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// DeleteIssueDependency removes a dependency between two issues.
|
||||
func (h *Handler) DeleteIssueDependency(w http.ResponseWriter, r *http.Request) {
|
||||
issueIDStr := chi.URLParam(r, "id")
|
||||
issue, ok := h.loadIssueForUser(w, r, issueIDStr)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
depID := chi.URLParam(r, "depId")
|
||||
dep, err := h.Queries.GetIssueDependency(r.Context(), parseUUID(depID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "dependency not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the dependency belongs to this issue
|
||||
depIssueID := uuidToString(dep.IssueID)
|
||||
depTargetID := uuidToString(dep.DependsOnIssueID)
|
||||
issueID := uuidToString(issue.ID)
|
||||
if depIssueID != issueID && depTargetID != issueID {
|
||||
writeError(w, http.StatusNotFound, "dependency not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Look up both issues for activity details
|
||||
srcIssue, _ := h.Queries.GetIssue(r.Context(), dep.IssueID)
|
||||
targetIssue, _ := h.Queries.GetIssue(r.Context(), dep.DependsOnIssueID)
|
||||
|
||||
if err := h.Queries.DeleteIssueDependency(r.Context(), dep.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete dependency")
|
||||
return
|
||||
}
|
||||
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
srcIdentifier := prefix + "-" + strconv.Itoa(int(srcIssue.Number))
|
||||
targetIdentifier := prefix + "-" + strconv.Itoa(int(targetIssue.Number))
|
||||
|
||||
userID := requestUserID(r)
|
||||
workspaceID := uuidToString(issue.WorkspaceID)
|
||||
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
||||
|
||||
h.publish(protocol.EventIssueDependencyRemoved, workspaceID, actorType, actorID, map[string]any{
|
||||
"dependency": dep,
|
||||
"issue": srcIssue,
|
||||
"target_issue": targetIssue,
|
||||
"issue_identifier": srcIdentifier,
|
||||
"target_issue_identifier": targetIdentifier,
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
91
server/pkg/db/generated/issue_dependency.sql.go
Normal file
91
server/pkg/db/generated/issue_dependency.sql.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: issue_dependency.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createIssueDependency = `-- name: CreateIssueDependency :one
|
||||
INSERT INTO issue_dependency (issue_id, depends_on_issue_id, type)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, issue_id, depends_on_issue_id, type
|
||||
`
|
||||
|
||||
type CreateIssueDependencyParams struct {
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
DependsOnIssueID pgtype.UUID `json:"depends_on_issue_id"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateIssueDependency(ctx context.Context, arg CreateIssueDependencyParams) (IssueDependency, error) {
|
||||
row := q.db.QueryRow(ctx, createIssueDependency, arg.IssueID, arg.DependsOnIssueID, arg.Type)
|
||||
var i IssueDependency
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.IssueID,
|
||||
&i.DependsOnIssueID,
|
||||
&i.Type,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteIssueDependency = `-- name: DeleteIssueDependency :exec
|
||||
DELETE FROM issue_dependency WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteIssueDependency(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteIssueDependency, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getIssueDependency = `-- name: GetIssueDependency :one
|
||||
SELECT id, issue_id, depends_on_issue_id, type FROM issue_dependency WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetIssueDependency(ctx context.Context, id pgtype.UUID) (IssueDependency, error) {
|
||||
row := q.db.QueryRow(ctx, getIssueDependency, id)
|
||||
var i IssueDependency
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.IssueID,
|
||||
&i.DependsOnIssueID,
|
||||
&i.Type,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listIssueDependencies = `-- name: ListIssueDependencies :many
|
||||
SELECT id, issue_id, depends_on_issue_id, type FROM issue_dependency
|
||||
WHERE issue_id = $1 OR depends_on_issue_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ListIssueDependencies(ctx context.Context, issueID pgtype.UUID) ([]IssueDependency, error) {
|
||||
rows, err := q.db.Query(ctx, listIssueDependencies, issueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []IssueDependency{}
|
||||
for rows.Next() {
|
||||
var i IssueDependency
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.IssueID,
|
||||
&i.DependsOnIssueID,
|
||||
&i.Type,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
14
server/pkg/db/queries/issue_dependency.sql
Normal file
14
server/pkg/db/queries/issue_dependency.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- name: CreateIssueDependency :one
|
||||
INSERT INTO issue_dependency (issue_id, depends_on_issue_id, type)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteIssueDependency :exec
|
||||
DELETE FROM issue_dependency WHERE id = $1;
|
||||
|
||||
-- name: GetIssueDependency :one
|
||||
SELECT * FROM issue_dependency WHERE id = $1;
|
||||
|
||||
-- name: ListIssueDependencies :many
|
||||
SELECT * FROM issue_dependency
|
||||
WHERE issue_id = $1 OR depends_on_issue_id = $1;
|
||||
@@ -7,6 +7,10 @@ const (
|
||||
EventIssueUpdated = "issue:updated"
|
||||
EventIssueDeleted = "issue:deleted"
|
||||
|
||||
// Issue dependency events
|
||||
EventIssueDependencyCreated = "issue_dependency:created"
|
||||
EventIssueDependencyRemoved = "issue_dependency:removed"
|
||||
|
||||
// Comment events
|
||||
EventCommentCreated = "comment:created"
|
||||
EventCommentUpdated = "comment:updated"
|
||||
|
||||
Reference in New Issue
Block a user