Compare commits

...

4 Commits

Author SHA1 Message Date
Naiyuan Qing
12fd6b69eb refactor(comments): extract triggerTasksForComment to unify Create/Edit trigger paths
Create and Edit duplicated the same three trigger paths (assignee,
squad leader, mentioned agents). A fourth path would need changes
in two places. Extract into a shared function so the composition is:
  Create: trigger() + unresolve()
  Edit:   cancel()  + trigger()
  Delete: cancel()

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 14:05:12 +08:00
Naiyuan Qing
bd889e6c74 refactor(comments): simplify edit post-processing to cancel-all + re-trigger
Replace handleEditMentionDiff (120-line mention diff) with a simpler
model: when content changes, cancel all tasks triggered by this comment,
then re-run the same three trigger paths as CreateComment (assignee,
squad leader, mentions). Fixes gap where assignee/squad-leader tasks
were not cancelled or re-triggered on edit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 13:51:17 +08:00
Naiyuan Qing
3ab7a4ee94 fix(sqlc): regenerate with v1.31.1 + add mention diff integration tests
Fixes sqlc version downgrade (v1.31.1 → v1.30.0) that was introduced
when the original PR was authored with a local v1.30.0 binary.
Regenerated all sqlc output with v1.31.1 to match main.

Adds integration tests for handleEditMentionDiff covering: edit adds
mention → task enqueued, edit removes mention → task cancelled, edit
changes content with same mentions → cancel + re-trigger.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 11:39:06 +08:00
Naiyuan Qing
1e9314bfdb feat(comments): align UpdateComment post-processing with CreateComment (#2965 follow-up)
Part 1 — PR #2965 code review follow-ups:
- Fix sqlc Column3 naming → AttachmentIds via sqlc.arg(attachment_ids)
- Return 500 on ReplaceCommentAttachments failure instead of logging + 200
- Remove optional marker from onEdit attachmentIds (always passed)
- Add optimistic update for attachments in useUpdateComment
- Extract useEditAttachmentState hook from CommentRow/CommentCardImpl
- Add integration tests for attachment replacement scenarios

Part 2 — Edit-comment logic alignment:
- Add ExpandIssueIdentifiers to UpdateComment (bare identifiers now expand)
- Add handleEditMentionDiff: diff old vs new agent/squad mentions on edit,
  cancel tasks for removed mentions, enqueue tasks for added mentions,
  cancel + re-trigger when content changes but mentions are unchanged

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 11:29:51 +08:00
8 changed files with 510 additions and 253 deletions

View File

@@ -611,13 +611,18 @@ export function useCreateComment(issueId: string) {
export function useUpdateComment(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ commentId, content, attachmentIds }: { commentId: string; content: string; attachmentIds?: string[] }) =>
mutationFn: ({ commentId, content, attachmentIds }: { commentId: string; content: string; attachmentIds: string[] }) =>
api.updateComment(commentId, content, attachmentIds),
onMutate: async ({ commentId, content }) => {
onMutate: async ({ commentId, content, attachmentIds }) => {
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
const kept = new Set(attachmentIds);
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) =>
old?.map((e) => (e.id === commentId ? { ...e, content } : e)),
old?.map((e) =>
e.id === commentId
? { ...e, content, attachments: e.attachments?.filter((a) => kept.has(a.id)) }
: e,
),
);
return { prev };
},

View File

@@ -64,7 +64,7 @@ interface CommentCardProps {
*/
canModerate?: boolean;
onReply: (parentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
onEdit: (commentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
onEdit: (commentId: string, content: string, attachmentIds: string[]) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;
/** Toggle the resolved state on the thread root. Only invoked for root entries. */
@@ -198,6 +198,118 @@ function initialStandaloneAttachmentIds(entry: TimelineEntry): Set<string> {
);
}
// ---------------------------------------------------------------------------
// Shared edit-attachment state hook
// ---------------------------------------------------------------------------
function useEditAttachmentState(
issueId: string,
entry: TimelineEntry,
onEdit: (commentId: string, content: string, attachmentIds: string[]) => Promise<void>,
) {
const { t } = useT("issues");
const { uploadWithToast } = useFileUpload(api);
const [editing, setEditing] = useState(false);
const editorRef = useRef<ContentEditorRef>(null);
const cancelledRef = useRef(false);
const [pendingAttachments, setPendingAttachments] = useState<Attachment[]>([]);
const [retainedStandaloneIds, setRetainedStandaloneIds] = useState<Set<string> | null>(null);
const editorAttachments = pendingAttachments.length > 0
? [...(entry.attachments ?? []), ...pendingAttachments]
: entry.attachments;
const handleUpload = useCallback(async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) setPendingAttachments((prev) => [...prev, result]);
return result;
}, [uploadWithToast, issueId]);
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
enabled: editing,
});
const draftKey = `edit:${issueId}:${entry.id}` as const;
const getDraft = useCommentDraftStore.getState().getDraft;
const setDraft = useCommentDraftStore((s) => s.setDraft);
const clearDraft = useCommentDraftStore((s) => s.clearDraft);
const initialValue = editing
? (getDraft(draftKey) ?? entry.content ?? "")
: (entry.content ?? "");
const standaloneEditAttachments = (entry.attachments ?? []).filter((a) =>
retainedStandaloneIds?.has(a.id),
);
const resetState = () => {
setEditing(false);
setPendingAttachments([]);
setRetainedStandaloneIds(null);
clearDraft(draftKey);
};
const startEdit = () => {
cancelledRef.current = false;
setRetainedStandaloneIds(initialStandaloneAttachmentIds(entry));
setEditing(true);
};
const cancelEdit = () => {
cancelledRef.current = true;
resetState();
};
const saveEdit = async () => {
if (cancelledRef.current) return;
const trimmed = editorRef.current
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
.trim();
if (!trimmed) return;
const activeIds = collectActiveAttachmentIds(
trimmed,
[...(entry.attachments ?? []), ...pendingAttachments],
retainedStandaloneIds,
);
const attachmentsChanged = !sameIdSet(activeIds, (entry.attachments ?? []).map((a) => a.id));
if (trimmed === (entry.content ?? "").trim() && !attachmentsChanged) {
resetState();
return;
}
try {
await onEdit(entry.id, trimmed, activeIds);
resetState();
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.comment.update_failed),
);
}
};
return {
editing,
editorRef,
editorAttachments,
handleUpload,
isDragOver,
dropZoneProps,
draftKey,
setDraft,
clearDraft,
initialValue,
standaloneEditAttachments,
retainedStandaloneIds,
setRetainedStandaloneIds,
startEdit,
cancelEdit,
saveEdit,
};
}
// ---------------------------------------------------------------------------
// Single comment row (used for both parent and replies within the same Card)
// ---------------------------------------------------------------------------
@@ -215,113 +327,24 @@ function CommentRow({
entry: TimelineEntry;
currentUserId?: string;
canModerate?: boolean;
onEdit: (commentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
onEdit: (commentId: string, content: string, attachmentIds: string[]) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;
}) {
const { t } = useT("issues");
const timeAgo = useTimeAgo();
const { getActorName } = useActorName();
const [editing, setEditing] = useState(false);
const editEditorRef = useRef<ContentEditorRef>(null);
const cancelledRef = useRef(false);
const { uploadWithToast } = useFileUpload(api);
// Pending uploads from this edit pass. Merged with `entry.attachments` so
// newly uploaded text/code files get an Eye button in the edit-mode editor;
// the active subset is sent as `attachmentIds` on save so the server binds
// them to the comment (otherwise they'd remain orphaned at the issue level
// and disappear after refresh).
const [pendingAttachments, setPendingAttachments] = useState<Attachment[]>([]);
const [retainedStandaloneIds, setRetainedStandaloneIds] = useState<Set<string> | null>(null);
const editorAttachments = pendingAttachments.length > 0
? [...(entry.attachments ?? []), ...pendingAttachments]
: entry.attachments;
const handleEditUpload = useCallback(async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) setPendingAttachments((prev) => [...prev, result]);
return result;
}, [uploadWithToast, issueId]);
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editEditorRef.current?.uploadFile(f)),
enabled: editing,
});
// Edit-mode draft: virtualization unmounts the card when it scrolls out
// of viewport, taking the in-progress edit with it. Persist via store
// so a scroll-away + scroll-back round-trip restores the user's edits.
// Key includes issueId so two issues with the same comment id (impossible
// but defensive) don't collide; cleared on cancel and on save.
const editDraftKey = `edit:${issueId}:${entry.id}` as const;
const getEditDraft = useCommentDraftStore.getState().getDraft;
const setEditDraft = useCommentDraftStore((s) => s.setDraft);
const clearEditDraft = useCommentDraftStore((s) => s.clearDraft);
// Read the snapshot once when the edit pass mounts; ContentEditor only
// honors `defaultValue` on mount, so a live store subscription here would
// cause an extra unmount/remount on every keystroke.
const editInitialValue = editing
? (getEditDraft(editDraftKey) ?? entry.content ?? "")
: (entry.content ?? "");
const edit = useEditAttachmentState(issueId, entry, onEdit);
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
const canDeleteEntry = isOwn || canModerate;
const [confirmDelete, setConfirmDelete] = useState(false);
const startEdit = () => {
cancelledRef.current = false;
setRetainedStandaloneIds(initialStandaloneAttachmentIds(entry));
setEditing(true);
};
const cancelEdit = () => {
cancelledRef.current = true;
setEditing(false);
setPendingAttachments([]);
setRetainedStandaloneIds(null);
clearEditDraft(editDraftKey);
};
const saveEdit = async () => {
if (cancelledRef.current) return;
const trimmed = editEditorRef.current
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
.trim();
if (!trimmed) return;
const activeIds = collectActiveAttachmentIds(
trimmed,
[...(entry.attachments ?? []), ...pendingAttachments],
retainedStandaloneIds,
);
const attachmentsChanged = !sameIdSet(activeIds, (entry.attachments ?? []).map((a) => a.id));
if (trimmed === (entry.content ?? "").trim() && !attachmentsChanged) {
setEditing(false);
setPendingAttachments([]);
setRetainedStandaloneIds(null);
clearEditDraft(editDraftKey);
return;
}
try {
await onEdit(entry.id, trimmed, activeIds);
setEditing(false);
setPendingAttachments([]);
setRetainedStandaloneIds(null);
clearEditDraft(editDraftKey);
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.comment.update_failed),
);
}
};
const reactions = entry.reactions ?? [];
const contentText = entry.content ?? "";
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
const standaloneEditAttachments = (entry.attachments ?? []).filter((attachment) =>
retainedStandaloneIds?.has(attachment.id),
);
return (
<div className="py-3">
@@ -368,7 +391,7 @@ function CommentRow({
<>
<DropdownMenuSeparator />
{canEditEntry && (
<DropdownMenuItem onClick={startEdit}>
<DropdownMenuItem onClick={edit.startEdit}>
<Pencil className="h-3.5 w-3.5" />
{t(($) => $.comment.edit_action)}
</DropdownMenuItem>
@@ -392,36 +415,36 @@ function CommentRow({
</div>
</div>
{editing ? (
{edit.editing ? (
<div
{...dropZoneProps}
{...edit.dropZoneProps}
className="relative mt-1.5 pl-8"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
onKeyDown={(e) => { if (e.key === "Escape") edit.cancelEdit(); }}
>
<div className="text-sm leading-relaxed">
<ContentEditor
ref={editEditorRef}
defaultValue={editInitialValue}
ref={edit.editorRef}
defaultValue={edit.initialValue}
placeholder={t(($) => $.comment.edit_placeholder)}
onUpdate={(md) => {
if (md.trim().length > 0) setEditDraft(editDraftKey, md);
else clearEditDraft(editDraftKey);
if (md.trim().length > 0) edit.setDraft(edit.draftKey, md);
else edit.clearDraft(edit.draftKey);
}}
onSubmit={saveEdit}
onUploadFile={handleEditUpload}
onSubmit={edit.saveEdit}
onUploadFile={edit.handleUpload}
debounceMs={100}
currentIssueId={issueId}
attachments={editorAttachments}
attachments={edit.editorAttachments}
/>
</div>
<div className="flex items-center justify-between mt-2">
<div className="flex min-w-0 flex-1 flex-col gap-1">
{standaloneEditAttachments.length > 0 && (
{edit.standaloneEditAttachments.length > 0 && (
<AttachmentList
attachments={standaloneEditAttachments}
attachments={edit.standaloneEditAttachments}
className="max-w-full"
onRemove={(attachmentId) =>
setRetainedStandaloneIds((ids) => {
edit.setRetainedStandaloneIds((ids) => {
const next = new Set(ids ?? []);
next.delete(attachmentId);
return next;
@@ -432,15 +455,15 @@ function CommentRow({
<FileUploadButton
size="sm"
multiple
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
onSelect={(file) => edit.editorRef.current?.uploadFile(file)}
/>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={cancelEdit}>{t(($) => $.comment.cancel_edit)}</Button>
<Button size="sm" variant="outline" onClick={saveEdit}>{t(($) => $.comment.save_action)}</Button>
<Button size="sm" variant="ghost" onClick={edit.cancelEdit}>{t(($) => $.comment.cancel_edit)}</Button>
<Button size="sm" variant="outline" onClick={edit.saveEdit}>{t(($) => $.comment.save_action)}</Button>
</div>
</div>
{isDragOver && <FileDropOverlay />}
{edit.isDragOver && <FileDropOverlay />}
</div>
) : (
<>
@@ -483,100 +506,18 @@ function CommentCardImpl({
const { t } = useT("issues");
const timeAgo = useTimeAgo();
const { getActorName } = useActorName();
const { uploadWithToast } = useFileUpload(api);
const isCollapsed = useCommentCollapseStore((s) => s.isCollapsed(issueId, entry.id));
const toggleCollapse = useCommentCollapseStore((s) => s.toggle);
const open = !isCollapsed;
const handleOpenChange = useCallback((_open: boolean) => toggleCollapse(issueId, entry.id), [toggleCollapse, issueId, entry.id]);
const [editing, setEditing] = useState(false);
const editEditorRef = useRef<ContentEditorRef>(null);
const cancelledRef = useRef(false);
// Pending uploads from the root-comment edit pass — same rationale as CommentRow.
const [parentPendingAttachments, setParentPendingAttachments] = useState<Attachment[]>([]);
const [parentRetainedStandaloneIds, setParentRetainedStandaloneIds] = useState<Set<string> | null>(null);
const parentEditorAttachments = parentPendingAttachments.length > 0
? [...(entry.attachments ?? []), ...parentPendingAttachments]
: entry.attachments;
const handleParentEditUpload = useCallback(async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) setParentPendingAttachments((prev) => [...prev, result]);
return result;
}, [uploadWithToast, issueId]);
const { isDragOver: parentDragOver, dropZoneProps: parentDropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editEditorRef.current?.uploadFile(f)),
enabled: editing,
});
// Edit-mode draft (root comment). Same rationale as CommentRow's draft.
const parentEditDraftKey = `edit:${issueId}:${entry.id}` as const;
const getParentEditDraft = useCommentDraftStore.getState().getDraft;
const setParentEditDraft = useCommentDraftStore((s) => s.setDraft);
const clearParentEditDraft = useCommentDraftStore((s) => s.clearDraft);
const parentEditInitialValue = editing
? (getParentEditDraft(parentEditDraftKey) ?? entry.content ?? "")
: (entry.content ?? "");
const edit = useEditAttachmentState(issueId, entry, onEdit);
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
// Author-only edit is the same as before; admins additionally get edit
// *and* delete on member-authored comments, plus delete on agent-authored
// ones. Edit on agent comments is intentionally never offered — agents
// own their own outputs.
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
const canDeleteEntry = isOwn || canModerate;
const [confirmDelete, setConfirmDelete] = useState(false);
const startEdit = () => {
cancelledRef.current = false;
setParentRetainedStandaloneIds(initialStandaloneAttachmentIds(entry));
setEditing(true);
};
const cancelEdit = () => {
cancelledRef.current = true;
setEditing(false);
setParentPendingAttachments([]);
setParentRetainedStandaloneIds(null);
clearParentEditDraft(parentEditDraftKey);
};
const saveEdit = async () => {
if (cancelledRef.current) return;
const trimmed = editEditorRef.current
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
.trim();
if (!trimmed) return;
const activeIds = collectActiveAttachmentIds(
trimmed,
[...(entry.attachments ?? []), ...parentPendingAttachments],
parentRetainedStandaloneIds,
);
const attachmentsChanged = !sameIdSet(activeIds, (entry.attachments ?? []).map((a) => a.id));
if (trimmed === (entry.content ?? "").trim() && !attachmentsChanged) {
setEditing(false);
setParentPendingAttachments([]);
setParentRetainedStandaloneIds(null);
clearParentEditDraft(parentEditDraftKey);
return;
}
try {
await onEdit(entry.id, trimmed, activeIds);
setEditing(false);
setParentPendingAttachments([]);
setParentRetainedStandaloneIds(null);
clearParentEditDraft(parentEditDraftKey);
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.comment.update_failed),
);
}
};
// The parent precomputes the flat thread (using collectThreadReplies),
// memoizes by thread, and stabilizes the array reference, so we render
// straight from `replies` instead of re-walking the graph on every render.
const allNestedReplies = replies;
const replyCount = allNestedReplies.length;
@@ -584,9 +525,6 @@ function CommentCardImpl({
const reactions = entry.reactions ?? [];
const contentText = entry.content ?? "";
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
const parentStandaloneEditAttachments = (entry.attachments ?? []).filter((attachment) =>
parentRetainedStandaloneIds?.has(attachment.id),
);
const isHighlighted = highlightedCommentId === entry.id;
@@ -685,7 +623,7 @@ function CommentCardImpl({
<>
<DropdownMenuSeparator />
{canEditEntry && (
<DropdownMenuItem onClick={startEdit}>
<DropdownMenuItem onClick={edit.startEdit}>
<Pencil className="h-3.5 w-3.5" />
{t(($) => $.comment.edit_action)}
</DropdownMenuItem>
@@ -716,36 +654,36 @@ function CommentCardImpl({
<CollapsibleContent>
{/* Parent comment body */}
<div className="px-4 pb-3">
{editing ? (
{edit.editing ? (
<div
{...parentDropZoneProps}
{...edit.dropZoneProps}
className="relative pl-10"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
onKeyDown={(e) => { if (e.key === "Escape") edit.cancelEdit(); }}
>
<div className="text-sm leading-relaxed">
<ContentEditor
ref={editEditorRef}
defaultValue={parentEditInitialValue}
ref={edit.editorRef}
defaultValue={edit.initialValue}
placeholder={t(($) => $.comment.edit_placeholder)}
onUpdate={(md) => {
if (md.trim().length > 0) setParentEditDraft(parentEditDraftKey, md);
else clearParentEditDraft(parentEditDraftKey);
if (md.trim().length > 0) edit.setDraft(edit.draftKey, md);
else edit.clearDraft(edit.draftKey);
}}
onSubmit={saveEdit}
onUploadFile={handleParentEditUpload}
onSubmit={edit.saveEdit}
onUploadFile={edit.handleUpload}
debounceMs={100}
currentIssueId={issueId}
attachments={parentEditorAttachments}
attachments={edit.editorAttachments}
/>
</div>
<div className="flex items-center justify-between mt-2">
<div className="flex min-w-0 flex-1 flex-col gap-1">
{parentStandaloneEditAttachments.length > 0 && (
{edit.standaloneEditAttachments.length > 0 && (
<AttachmentList
attachments={parentStandaloneEditAttachments}
attachments={edit.standaloneEditAttachments}
className="max-w-full"
onRemove={(attachmentId) =>
setParentRetainedStandaloneIds((ids) => {
edit.setRetainedStandaloneIds((ids) => {
const next = new Set(ids ?? []);
next.delete(attachmentId);
return next;
@@ -756,15 +694,15 @@ function CommentCardImpl({
<FileUploadButton
size="sm"
multiple
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
onSelect={(file) => edit.editorRef.current?.uploadFile(file)}
/>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={cancelEdit}>{t(($) => $.comment.cancel_edit)}</Button>
<Button size="sm" variant="outline" onClick={saveEdit}>{t(($) => $.comment.save_action)}</Button>
<Button size="sm" variant="ghost" onClick={edit.cancelEdit}>{t(($) => $.comment.cancel_edit)}</Button>
<Button size="sm" variant="outline" onClick={edit.saveEdit}>{t(($) => $.comment.save_action)}</Button>
</div>
</div>
{parentDragOver && <FileDropOverlay />}
{edit.isDragOver && <FileDropOverlay />}
</div>
) : (
<>

View File

@@ -296,7 +296,7 @@ export function useIssueTimeline(issueId: string, userId?: string) {
);
const editComment = useCallback(
async (commentId: string, content: string, attachmentIds?: string[]) => {
async (commentId: string, content: string, attachmentIds: string[]) => {
try {
await updateComment({ commentId, content, attachmentIds });
} catch (err) {

View File

@@ -0,0 +1,178 @@
package main
import (
"context"
"io"
"testing"
)
// createTestAttachment inserts a test attachment row directly into the DB,
// linked to the given issue with no comment_id. Returns the attachment UUID.
func createTestAttachment(t *testing.T, issueID string) string {
t.Helper()
var id string
err := testPool.QueryRow(context.Background(), `
INSERT INTO attachment (workspace_id, issue_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
VALUES ($1::uuid, $2::uuid, 'member', $3::uuid, 'test.txt', 'https://example.com/test.txt', 'text/plain', 42)
RETURNING id::text
`, testWorkspaceID, issueID, testUserID).Scan(&id)
if err != nil {
t.Fatalf("createTestAttachment: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1::uuid`, id)
})
return id
}
// listCommentAttachmentIDs returns the attachment IDs linked to a comment.
func listCommentAttachmentIDs(t *testing.T, commentID string) []string {
t.Helper()
rows, err := testPool.Query(context.Background(),
`SELECT id::text FROM attachment WHERE comment_id = $1::uuid ORDER BY created_at`, commentID)
if err != nil {
t.Fatalf("listCommentAttachmentIDs: %v", err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
t.Fatalf("listCommentAttachmentIDs scan: %v", err)
}
ids = append(ids, id)
}
return ids
}
func TestUpdateCommentAttachments(t *testing.T) {
issueID := createIssue(t, "Attachment edit integration test")
t.Cleanup(func() {
resp := authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
resp.Body.Close()
})
t.Run("edit to remove some attachments keeps the rest", func(t *testing.T) {
att1 := createTestAttachment(t, issueID)
att2 := createTestAttachment(t, issueID)
att3 := createTestAttachment(t, issueID)
// Create comment with all three attachments.
resp := authRequest(t, "POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "comment with three attachments",
"type": "comment",
"attachment_ids": []string{att1, att2, att3},
})
if resp.StatusCode != 201 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("CreateComment: expected 201, got %d: %s", resp.StatusCode, body)
}
var comment map[string]any
readJSON(t, resp, &comment)
commentID := comment["id"].(string)
// Verify all three are linked.
ids := listCommentAttachmentIDs(t, commentID)
if len(ids) != 3 {
t.Fatalf("expected 3 attachments, got %d", len(ids))
}
// Edit: keep only att1 and att3, remove att2.
resp = authRequest(t, "PUT", "/api/comments/"+commentID, map[string]any{
"content": "updated — removed att2",
"attachment_ids": []string{att1, att3},
})
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("UpdateComment: expected 200, got %d: %s", resp.StatusCode, body)
}
ids = listCommentAttachmentIDs(t, commentID)
if len(ids) != 2 {
t.Fatalf("expected 2 attachments after edit, got %d", len(ids))
}
idSet := map[string]bool{ids[0]: true, ids[1]: true}
if !idSet[att1] || !idSet[att3] {
t.Errorf("expected att1 and att3 to remain, got %v", ids)
}
})
t.Run("edit to remove all attachments", func(t *testing.T) {
att1 := createTestAttachment(t, issueID)
resp := authRequest(t, "POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "comment with one attachment",
"type": "comment",
"attachment_ids": []string{att1},
})
if resp.StatusCode != 201 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("CreateComment: expected 201, got %d: %s", resp.StatusCode, body)
}
var comment map[string]any
readJSON(t, resp, &comment)
commentID := comment["id"].(string)
if ids := listCommentAttachmentIDs(t, commentID); len(ids) != 1 {
t.Fatalf("expected 1 attachment, got %d", len(ids))
}
// Edit with empty attachment_ids to remove all.
resp = authRequest(t, "PUT", "/api/comments/"+commentID, map[string]any{
"content": "no more attachments",
"attachment_ids": []string{},
})
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("UpdateComment: expected 200, got %d: %s", resp.StatusCode, body)
}
if ids := listCommentAttachmentIDs(t, commentID); len(ids) != 0 {
t.Fatalf("expected 0 attachments after removing all, got %d", len(ids))
}
})
t.Run("old client omitting attachment_ids preserves existing attachments", func(t *testing.T) {
att1 := createTestAttachment(t, issueID)
resp := authRequest(t, "POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "comment with attachment",
"type": "comment",
"attachment_ids": []string{att1},
})
if resp.StatusCode != 201 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("CreateComment: expected 201, got %d: %s", resp.StatusCode, body)
}
var comment map[string]any
readJSON(t, resp, &comment)
commentID := comment["id"].(string)
if ids := listCommentAttachmentIDs(t, commentID); len(ids) != 1 {
t.Fatalf("expected 1 attachment, got %d", len(ids))
}
// Old client: only sends content, no attachment_ids field at all.
resp = authRequest(t, "PUT", "/api/comments/"+commentID, map[string]any{
"content": "edited content without attachment_ids",
})
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("UpdateComment: expected 200, got %d: %s", resp.StatusCode, body)
}
ids := listCommentAttachmentIDs(t, commentID)
if len(ids) != 1 {
t.Fatalf("expected 1 attachment preserved (old client), got %d", len(ids))
}
if ids[0] != att1 {
t.Errorf("expected att1 %q preserved, got %q", att1, ids[0])
}
})
}

View File

@@ -0,0 +1,120 @@
package main
import (
"fmt"
"io"
"testing"
)
func TestEditCommentTriggers(t *testing.T) {
agentID := getAgentID(t)
issueID := createIssue(t, "Edit comment triggers integration test")
t.Cleanup(func() {
clearTasks(t, issueID)
resp := authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
resp.Body.Close()
})
t.Run("edit adds agent mention enqueues task", func(t *testing.T) {
clearTasks(t, issueID)
commentID := postComment(t, issueID, "plain comment no mentions", nil)
clearTasks(t, issueID)
newContent := fmt.Sprintf("[@Agent](mention://agent/%s) please review", agentID)
resp := authRequest(t, "PUT", "/api/comments/"+commentID, map[string]any{
"content": newContent,
})
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("UpdateComment: expected 200, got %d: %s", resp.StatusCode, body)
}
resp.Body.Close()
if n := countPendingTasks(t, issueID); n != 1 {
t.Errorf("expected 1 pending task after adding agent mention via edit, got %d", n)
}
})
t.Run("edit removes agent mention cancels task", func(t *testing.T) {
clearTasks(t, issueID)
content := fmt.Sprintf("[@Agent](mention://agent/%s) fix this", agentID)
commentID := postComment(t, issueID, content, nil)
if n := countPendingTasks(t, issueID); n != 1 {
t.Fatalf("expected 1 pending task from initial mention, got %d", n)
}
resp := authRequest(t, "PUT", "/api/comments/"+commentID, map[string]any{
"content": "removed the mention, nevermind",
})
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("UpdateComment: expected 200, got %d: %s", resp.StatusCode, body)
}
resp.Body.Close()
if n := countPendingTasks(t, issueID); n != 0 {
t.Errorf("expected 0 pending tasks after removing mention via edit, got %d", n)
}
})
t.Run("edit changes content but keeps same mention re-triggers", func(t *testing.T) {
clearTasks(t, issueID)
content := fmt.Sprintf("[@Agent](mention://agent/%s) fix bug A", agentID)
commentID := postComment(t, issueID, content, nil)
if n := countPendingTasks(t, issueID); n != 1 {
t.Fatalf("expected 1 pending task from initial mention, got %d", n)
}
clearTasks(t, issueID)
newContent := fmt.Sprintf("[@Agent](mention://agent/%s) actually fix bug B instead", agentID)
resp := authRequest(t, "PUT", "/api/comments/"+commentID, map[string]any{
"content": newContent,
})
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("UpdateComment: expected 200, got %d: %s", resp.StatusCode, body)
}
resp.Body.Close()
if n := countPendingTasks(t, issueID); n != 1 {
t.Errorf("expected 1 pending task after content change re-trigger, got %d", n)
}
})
t.Run("edit on agent-assigned issue cancels and re-triggers assignee task", func(t *testing.T) {
assignedIssue := createIssueAssignedToAgent(t, "Edit assignee trigger test", agentID)
t.Cleanup(func() {
clearTasks(t, assignedIssue)
resp := authRequest(t, "DELETE", "/api/issues/"+assignedIssue, nil)
resp.Body.Close()
})
clearTasks(t, assignedIssue)
commentID := postComment(t, assignedIssue, "fix the login page", nil)
if n := countPendingTasks(t, assignedIssue); n != 1 {
t.Fatalf("expected 1 pending task from on_comment trigger, got %d", n)
}
clearTasks(t, assignedIssue)
resp := authRequest(t, "PUT", "/api/comments/"+commentID, map[string]any{
"content": "actually fix the signup page instead",
})
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("UpdateComment: expected 200, got %d: %s", resp.StatusCode, body)
}
resp.Body.Close()
if n := countPendingTasks(t, assignedIssue); n != 1 {
t.Errorf("expected 1 pending task after edit re-triggered assignee, got %d", n)
}
})
}

View File

@@ -725,38 +725,25 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
// must keep the resolved root in sync.
h.TaskService.AutoUnresolveThreadOnReply(r.Context(), parentComment, uuidToString(issue.WorkspaceID), authorType, authorID)
// If the issue is assigned to an agent with on_comment trigger, enqueue a new task.
// Skip when the comment comes from the assigned agent itself to avoid loops.
// Also skip when the comment @mentions others but not the assignee agent —
// the user is talking to someone else, not requesting work from the assignee.
// Also skip when replying in a member-started thread without mentioning the
// assignee — the user is continuing a member-to-member conversation.
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue, authorType, authorID) &&
h.triggerTasksForComment(r.Context(), issue, comment, parentComment, authorType, authorID)
writeJSON(w, http.StatusCreated, resp)
}
func (h *Handler) triggerTasksForComment(ctx context.Context, issue db.Issue, comment db.Comment, parentComment *db.Comment, actorType, actorID string) {
if actorType == "member" && h.shouldEnqueueOnComment(ctx, issue, actorType, actorID) &&
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) &&
!h.isReplyToMemberThread(r.Context(), parentComment, comment.Content, issue) {
// Always use the current comment as the trigger so the agent reads
// the actual new reply, not the thread root. Reply placement (flat
// thread grouping) is handled downstream by createAgentComment,
// which resolves parent_id to the thread root before posting. This
// mirrors the mention path's behavior (see enqueueMentionedAgentTasks).
if _, err := h.TaskService.EnqueueTaskForIssue(r.Context(), issue, comment.ID); err != nil {
slog.Warn("enqueue agent task on comment failed", "issue_id", issueID, "error", err)
!h.isReplyToMemberThread(ctx, parentComment, comment.Content, issue) {
if _, err := h.TaskService.EnqueueTaskForIssue(ctx, issue, comment.ID); err != nil {
slog.Warn("enqueue agent task on comment failed", "issue_id", uuidToString(issue.ID), "error", err)
}
}
// Squad trigger: if the issue is assigned to a squad, trigger the squad leader.
// Skip when the comment author is the leader (prevent internal loops), or
// when a member explicitly @mentions anyone (agent/member/squad/all) — that
// counts as deliberate routing and the leader stays out.
if h.shouldEnqueueSquadLeaderOnComment(r.Context(), issue, comment.Content, authorType, authorID) {
h.enqueueSquadLeaderTask(r.Context(), issue, comment.ID, authorType, authorID)
if h.shouldEnqueueSquadLeaderOnComment(ctx, issue, comment.Content, actorType, actorID) {
h.enqueueSquadLeaderTask(ctx, issue, comment.ID, actorType, actorID)
}
// Trigger @mentioned agents: parse agent mentions and enqueue tasks for each.
// Pass parentComment so that replies inherit mentions from the thread root.
h.enqueueMentionedAgentTasks(r.Context(), issue, comment, parentComment, authorType, authorID)
writeJSON(w, http.StatusCreated, resp)
h.enqueueMentionedAgentTasks(ctx, issue, comment, parentComment, actorType, actorID)
}
// commentMentionsOthersButNotAssignee returns true if the comment @mentions
@@ -1048,6 +1035,11 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
// NOTE: See CreateComment — Markdown is sanitized at render/edit time, not here.
oldContent := existing.Content
// Expand bare issue identifiers (same pipeline as CreateComment).
req.Content = mention.ExpandIssueIdentifiers(r.Context(), h.Queries, wsUUID, req.Content)
comment, err := h.Queries.UpdateComment(r.Context(), db.UpdateCommentParams{
ID: commentUUID,
Content: req.Content,
@@ -1063,11 +1055,13 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
// existing attachment links rather than unlinking everything.
if replaceAttachments {
if err := h.Queries.ReplaceCommentAttachments(r.Context(), db.ReplaceCommentAttachmentsParams{
CommentID: comment.ID,
IssueID: existing.IssueID,
Column3: attachmentIDs,
CommentID: comment.ID,
IssueID: existing.IssueID,
AttachmentIds: attachmentIDs,
}); err != nil {
slog.Error("failed to replace comment attachments", "error", err)
writeError(w, http.StatusInternalServerError, "failed to update attachments")
return
}
}
@@ -1078,6 +1072,28 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
resp := commentToResponse(comment, grouped[cid], groupedAtt[cid])
slog.Info("comment updated", append(logger.RequestAttrs(r), "comment_id", commentId)...)
h.publish(protocol.EventCommentUpdated, workspaceID, actorType, actorID, map[string]any{"comment": resp})
if oldContent != comment.Content {
if err := h.TaskService.CancelTasksByTriggerComment(r.Context(), existing.ID); err != nil {
slog.Warn("cancel tasks for edited comment failed", "comment_id", uuidToString(existing.ID), "error", err)
}
issue, err := h.Queries.GetIssue(r.Context(), existing.IssueID)
if err != nil {
slog.Warn("load issue for edit post-processing failed", "issue_id", uuidToString(existing.IssueID), "error", err)
} else {
var parentComment *db.Comment
if existing.ParentID.Valid {
parent, err := h.Queries.GetComment(r.Context(), existing.ParentID)
if err == nil {
parentComment = &parent
}
}
h.triggerTasksForComment(r.Context(), issue, comment, parentComment, actorType, actorID)
}
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -462,12 +462,12 @@ WHERE issue_id = $2
`
type ReplaceCommentAttachmentsParams struct {
CommentID pgtype.UUID `json:"comment_id"`
IssueID pgtype.UUID `json:"issue_id"`
Column3 []pgtype.UUID `json:"column_3"`
CommentID pgtype.UUID `json:"comment_id"`
IssueID pgtype.UUID `json:"issue_id"`
AttachmentIds []pgtype.UUID `json:"attachment_ids"`
}
func (q *Queries) ReplaceCommentAttachments(ctx context.Context, arg ReplaceCommentAttachmentsParams) error {
_, err := q.db.Exec(ctx, replaceCommentAttachments, arg.CommentID, arg.IssueID, arg.Column3)
_, err := q.db.Exec(ctx, replaceCommentAttachments, arg.CommentID, arg.IssueID, arg.AttachmentIds)
return err
}

View File

@@ -47,13 +47,13 @@ WHERE issue_id = $2
-- name: ReplaceCommentAttachments :exec
UPDATE attachment
SET comment_id = CASE
WHEN id = ANY($3::uuid[]) THEN $1
WHEN id = ANY(sqlc.arg(attachment_ids)::uuid[]) THEN $1
ELSE NULL
END
WHERE issue_id = $2
AND (
comment_id = $1
OR (comment_id IS NULL AND id = ANY($3::uuid[]))
OR (comment_id IS NULL AND id = ANY(sqlc.arg(attachment_ids)::uuid[]))
);
-- name: LinkAttachmentsToChatMessage :exec