Compare commits

..

3 Commits

Author SHA1 Message Date
Jiayuan
9bc8dcc053 feat(issues): add Relations section in sidebar for managing issue dependencies
Users can now create, view, and remove issue dependencies directly from
the issue detail sidebar. The Relations section shows existing links with
type labels (blocks, blocked by, related to) and a searchable popover
for adding new relations. Activity entries appear on both issues when
relations change.
2026-04-04 00:05:15 +08:00
Jiayuan
473ec17c65 feat(activity): add issue relation activities to timeline
When issue dependencies are created or removed, both the source and
target issues now receive activity entries in their timeline. This
enables bi-directional visibility — e.g., issue A's timeline shows
"added relation: blocks MUL-456" while issue B shows "added relation:
blocked by MUL-123".

Changes:
- Add CRUD SQL queries and handler endpoints for issue_dependency
- Add issue_dependency:created/removed event types
- Add activity listener that records activities on both issues
- Render relation activities in the frontend with Link2 icon
2026-04-03 23:51:36 +08:00
Bohan Jiang
fc6405e4be fix(trigger): allow on_comment when thread root @mentions assignee agent (#382)
When a member-started thread root @mentions the assignee agent, replies
in that thread should trigger on_comment — the thread is a conversation
with the agent, not a member-to-member chat.

Previously isReplyToMemberThread only checked the reply content for
assignee mentions. Now it also checks the parent (thread root) content.
This fixes a gap where path 1 (on_comment) suppressed the trigger and
path 2 (on_mention) skipped the assignee, leaving no trigger path.
2026-04-03 15:07:39 +08:00
15 changed files with 665 additions and 9 deletions

View File

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

View File

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

View File

@@ -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",

View File

@@ -36,7 +36,7 @@ export interface AgentTrigger {
id: string;
type: AgentTriggerType;
enabled: boolean;
config: Record<string, unknown> | null;
config: Record<string, unknown>;
}
export interface AgentTask {

View File

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

View File

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

View File

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

View File

@@ -230,6 +230,20 @@ func TestCommentTriggerOnComment(t *testing.T) {
t.Errorf("expected 1 pending task (assignee mentioned in member thread), got %d", n)
}
})
t.Run("reply to member thread that @mentioned assignee triggers without re-mention", func(t *testing.T) {
clearTasks(t, issueID)
// Member starts a thread that @mentions the assignee agent.
content := fmt.Sprintf("[@Agent](mention://agent/%s) can you review this?", agentID)
threadID := postComment(t, issueID, content, nil)
// Clear the task created by the top-level mention.
clearTasks(t, issueID)
// Reply in the thread WITHOUT re-mentioning the assignee.
postComment(t, issueID, "Here is more context for you", strPtr(threadID))
if n := countPendingTasks(t, issueID); n != 1 {
t.Errorf("expected 1 pending task (assignee mentioned in thread root), got %d", n)
}
})
}
// TestCommentTriggerAtAllSuppression verifies that @all mentions do not

View File

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

View File

@@ -227,6 +227,8 @@ func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.I
// continuing a human conversation — not requesting work from the assigned agent.
// Replying to an agent-started thread, or explicitly @mentioning the assignee
// in the reply, still triggers on_comment as expected.
// If the parent (thread root) itself @mentions the assignee, the thread is
// considered a conversation with the agent, so replies are allowed to trigger.
func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issue db.Issue) bool {
if parent == nil {
return false // Not a reply — normal top-level comment
@@ -235,14 +237,22 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu
return false // Thread started by an agent — allow trigger
}
// Thread was started by a member. Suppress on_comment unless the reply
// explicitly @mentions the assignee agent.
// or the parent explicitly @mentions the assignee agent.
if !issue.AssigneeID.Valid {
return true // No assignee to mention
}
assigneeID := uuidToString(issue.AssigneeID)
// Check current comment mentions.
for _, m := range util.ParseMentions(content) {
if m.ID == assigneeID {
return false // Assignee explicitly mentioned — allow trigger
return false // Assignee explicitly mentioned in reply — allow trigger
}
}
// Check parent (thread root) mentions — if the thread was started by
// mentioning the assignee, replies continue that conversation.
for _, m := range util.ParseMentions(parent.Content) {
if m.ID == assigneeID {
return false // Assignee mentioned in thread root — allow trigger
}
}
return true // Reply to member thread without mentioning agent — suppress

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

View File

@@ -117,8 +117,20 @@ func TestIsReplyToMemberThread(t *testing.T) {
h := &Handler{}
issue := issueWithAgentAssignee()
memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID)}
agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID)}
memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID), Content: "plain thread starter"}
agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID), Content: "agent thread starter"}
// Member-started thread root that @mentions the assignee agent.
memberParentMentioningAssignee := &db.Comment{
AuthorType: "member",
AuthorID: testUUID(memberID),
Content: fmt.Sprintf("[@Agent](mention://agent/%s) can you look at this?", agentAssigneeID),
}
// Member-started thread root that @mentions a non-assignee agent.
memberParentMentioningOther := &db.Comment{
AuthorType: "member",
AuthorID: testUUID(memberID),
Content: fmt.Sprintf("[@Other](mention://agent/%s) what do you think?", otherAgentID),
}
tests := []struct {
name string
@@ -168,6 +180,18 @@ func TestIsReplyToMemberThread(t *testing.T) {
content: fmt.Sprintf("[@Other](mention://agent/%s) take a look", otherAgentID),
want: true,
},
{
name: "reply to member thread that @mentioned assignee, no re-mention → allow",
parent: memberParentMentioningAssignee,
content: "here is more context for you",
want: false,
},
{
name: "reply to member thread that @mentioned other agent, no re-mention → suppress",
parent: memberParentMentioningOther,
content: "here is more context",
want: true, // parent mentioned other agent, not assignee — still suppress on_comment
},
}
for _, tt := range tests {
@@ -188,8 +212,13 @@ func TestOnCommentTriggerDecision(t *testing.T) {
h := &Handler{}
issue := issueWithAgentAssignee()
memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID)}
agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID)}
memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID), Content: "plain thread starter"}
agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID), Content: "agent thread starter"}
memberParentMentioningAssignee := &db.Comment{
AuthorType: "member",
AuthorID: testUUID(memberID),
Content: fmt.Sprintf("[@Agent](mention://agent/%s) help me", agentAssigneeID),
}
// Simulates the combined check from CreateComment:
// !commentMentionsOthersButNotAssignee && !isReplyToMemberThread
@@ -213,6 +242,7 @@ func TestOnCommentTriggerDecision(t *testing.T) {
{"reply member thread, no mention", memberParent, "agreed", false},
{"reply member thread, mention other member", memberParent, fmt.Sprintf("[@Bob](mention://member/%s) ok", memberID), false},
{"reply member thread, mention assignee", memberParent, fmt.Sprintf("[@Agent](mention://agent/%s) help", agentAssigneeID), true},
{"reply member thread that @mentioned assignee, no re-mention", memberParentMentioningAssignee, "here is more info", true},
{"top-level, @all broadcast", nil, "[@All](mention://all/all) heads up team", false},
{"reply agent thread, @all broadcast", agentParent, "[@All](mention://all/all) update for everyone", false},
{"reply member thread, @all broadcast", memberParent, "[@All](mention://all/all) fyi", false},

View 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
}

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

View File

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