Compare commits

..

14 Commits

Author SHA1 Message Date
LinYushen
09565bc40f Merge pull request #426 from multica-ai/fix/attachment-upload-linking
fix(attachment): use UUIDv7 as S3 key and link attachments on issue/comment creation
2026-04-05 08:04:11 +08:00
yushen
4036d64996 fix(attachment): use UUIDv7 as S3 key and link attachments on issue/comment creation
- Use google/uuid NewV7() for attachment ID and S3 file key instead of
  random hex, so the S3 object name matches the attachment record ID
- Add LinkAttachmentsToIssue query to associate orphaned attachments
  with a newly created issue
- Pass attachment_ids in CreateIssue request so uploads during issue
  creation (before the issue exists) get linked after commit
- Collect and pass attachment IDs in comment-input and reply-input
  so comment creation properly links uploaded files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 07:55:17 +08:00
LinYushen
5b0a537302 Merge pull request #425 from multica-ai/agent/eve/90e2273d
refactor(cli): improve help UX with examples and arg validation
2026-04-05 07:05:23 +08:00
yushen
0d9d4e6b69 merge: resolve conflicts with origin/main in help.go
Keep branch additions (errSilent, exactArgs, examples template blocks)
that were added in the CLI help UX improvement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 07:03:03 +08:00
yushen
4c0dbbf1c8 refactor(cli): improve help UX — add examples support, show help on arg errors
- Add EXAMPLES section to leaf and sub help templates (gh CLI style)
- Add example to attachment download command
- Simplify attachment download description
- Show help output when required args are missing (error first, then help)
- Replace cobra.ExactArgs with custom exactArgs that prints help on failure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 07:00:19 +08:00
devv-eve
52a9a6ae5f refactor(cli): overhaul help output to match gh CLI style (#423)
* refactor(cli): overhaul help output to match gh CLI style

- Add gh-style grouped help with CORE/RUNTIME/ADDITIONAL COMMANDS sections
- Use UPPERCASE section headers (USAGE, FLAGS, EXAMPLES, LEARN MORE)
- Format commands as "name:  description" with automatic alignment
- Add ENVIRONMENT VARIABLES and EXAMPLES sections to root help
- Apply consistent templates to root, subcommand, and leaf commands
- Update descriptions from "Manage X" to "Work with X" for gh parity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(execenv): add explicit instruction for agents to always use multica CLI

Agents were using curl/wget to access Multica attachment URLs directly,
which fails due to authentication. Add a prominent "Important" section
to the generated CLAUDE.md template that explicitly prohibits direct
HTTP access and instructs agents to escalate missing CLI functionality
to their workspace owner.

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:30:40 -07:00
Devv
d6a5ba4d5e fix(execenv): add explicit instruction for agents to always use multica CLI
Agents were using curl/wget to access Multica attachment URLs directly,
which fails due to authentication. Add a prominent "Important" section
to the generated CLAUDE.md template that explicitly prohibits direct
HTTP access and instructs agents to escalate missing CLI functionality
to their workspace owner.
2026-04-04 15:27:50 -07:00
Devv
4afef09a03 refactor(cli): overhaul help output to match gh CLI style
- Add gh-style grouped help with CORE/RUNTIME/ADDITIONAL COMMANDS sections
- Use UPPERCASE section headers (USAGE, FLAGS, EXAMPLES, LEARN MORE)
- Format commands as "name:  description" with automatic alignment
- Add ENVIRONMENT VARIABLES and EXAMPLES sections to root help
- Apply consistent templates to root, subcommand, and leaf commands
- Update descriptions from "Manage X" to "Work with X" for gh parity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:10:59 -07:00
Jiayuan Zhang
0771c15a59 fix(trigger): skip parent mention inheritance when reply @mentions only members (#421)
When a reply in a thread explicitly mentions only non-agent entities
(members or issues), do not inherit agent mentions from the parent
comment. This prevents false agent triggers when a user is directing
their reply at other people (e.g. "cc @Someone") rather than requesting
work from agents mentioned in the thread root.

Fixes MUL-324
2026-04-05 04:44:24 +08:00
Jiayuan Zhang
3a96567fc1 fix(web): remove duplicate emoji button on comment card (#419)
* fix(web): remove duplicate emoji button on parent comment card

The parent CommentCard rendered two emoji pickers: one in the header
toolbar (QuickEmojiPicker) and another inside ReactionBar (which has
its own QuickEmojiPicker when hideAddButton is not set). Added
hideAddButton to the parent's ReactionBar, matching the pattern
already used in CommentRow for replies.

* fix(web): show emoji button at bottom for long comments

For short comments, the emoji picker only appears in the top-right
toolbar. For long comments (>500 chars or >8 newlines), the ReactionBar
also shows an add button at the bottom so users don't have to scroll
back up to add reactions.
2026-04-05 04:17:36 +08:00
Jiayuan Zhang
9aee403ff9 Merge pull request #405 from multica-ai/agent/lambda/8bf9407f
fix(comment): allow @mention of assigned agent on done/cancelled issues
2026-04-04 13:31:07 +08:00
Jiayuan Zhang
7883fe7bd7 Merge pull request #406 from multica-ai/fix/setup-worktree-env-overwrite
fix: make setup-worktree preserve existing .env.worktree
2026-04-04 13:26:17 +08:00
Jiayuan
cbfb7d58b6 fix: make setup-worktree preserve existing .env.worktree
setup-worktree was using FORCE=1 to unconditionally regenerate
.env.worktree, which overwrites any manual edits (e.g. switching
to the main DB). Now it only generates the file if it doesn't exist.
2026-04-04 13:23:14 +08:00
Jiayuan
2832a06fe3 fix(comment): allow @mention of assigned agent on done/cancelled issues
The assignee was unconditionally skipped in the mention path, assuming
on_comment would handle it. But on_comment is suppressed for terminal
statuses (done/cancelled), so an explicit @mention of the assignee had
no effect. Now only skip the assignee dedup when on_comment will
actually fire (non-terminal status).
2026-04-04 13:14:09 +08:00
30 changed files with 479 additions and 96 deletions

View File

@@ -98,8 +98,12 @@ check-main:
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
setup-worktree:
@echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."
@FORCE=1 bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE)
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
else \
echo "==> Using existing $(WORKTREE_ENV_FILE)"; \
fi
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
start-worktree:

View File

@@ -167,7 +167,7 @@ vi.mock("@/features/issues/config", () => ({
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10" },
in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10" },
done: { label: "Done", iconColor: "text-done", hoverBg: "hover:bg-done/10" },
done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10" },
blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10" },
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
},

View File

@@ -29,7 +29,6 @@
--color-success: var(--success);
--color-warning: var(--warning);
--color-info: var(--info);
--color-done: var(--done);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-priority: var(--priority);
@@ -96,7 +95,6 @@
--success: oklch(0.55 0.16 145);
--warning: oklch(0.75 0.16 85);
--info: oklch(0.55 0.18 250);
--done: oklch(0.55 0.18 300);
--priority: oklch(0.65 0.18 50);
--scrollbar-thumb: oklch(0 0 0 / 10%);
--scrollbar-thumb-hover: oklch(0 0 0 / 18%);
@@ -141,7 +139,6 @@
--success: oklch(0.65 0.15 145);
--warning: oklch(0.70 0.16 85);
--info: oklch(0.65 0.18 250);
--done: oklch(0.65 0.18 300);
--priority: oklch(0.70 0.18 50);
--scrollbar-thumb: oklch(1 0 0 / 8%);
--scrollbar-thumb-hover: oklch(1 0 0 / 18%);

View File

@@ -148,6 +148,8 @@ function CommentRow({
};
const reactions = entry.reactions ?? [];
const contentText = entry.content ?? "";
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
return (
<div className={`py-3${isTemp ? " opacity-60" : ""}`}>
@@ -252,7 +254,7 @@ function CommentRow({
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
hideAddButton
hideAddButton={!isLongContent}
className="mt-1.5 pl-8"
/>
)}
@@ -330,6 +332,8 @@ function CommentCard({
const replyCount = allNestedReplies.length;
const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80);
const reactions = entry.reactions ?? [];
const contentText = entry.content ?? "";
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
const isHighlighted = highlightedCommentId === entry.id;
@@ -458,6 +462,7 @@ function CommentCard({
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
hideAddButton={!isLongContent}
className="mt-1.5 pl-10"
/>
)}

View File

@@ -16,10 +16,15 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const editorRef = useRef<ContentEditorRef>(null);
const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
const { uploadWithToast } = useFileUpload();
const handleUpload = async (file: File) => {
return await uploadWithToast(file, { issueId });
const result = await uploadWithToast(file, { issueId });
if (result) {
setAttachmentIds((prev) => [...prev, result.id]);
}
return result;
};
const handleSubmit = async () => {
@@ -27,9 +32,10 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
if (!content || submitting) return;
setSubmitting(true);
try {
await onSubmit(content);
await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined);
editorRef.current?.clearContent();
setIsEmpty(true);
setAttachmentIds([]);
} finally {
setSubmitting(false);
}

View File

@@ -38,6 +38,7 @@ function ReplyInput({
const [isEmpty, setIsEmpty] = useState(true);
const [isExpanded, setIsExpanded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
const { uploadWithToast } = useFileUpload();
useEffect(() => {
@@ -52,7 +53,11 @@ function ReplyInput({
}, []);
const handleUpload = async (file: File) => {
return await uploadWithToast(file, { issueId });
const result = await uploadWithToast(file, { issueId });
if (result) {
setAttachmentIds((prev) => [...prev, result.id]);
}
return result;
};
const handleSubmit = async () => {
@@ -60,9 +65,10 @@ function ReplyInput({
if (!content || submitting) return;
setSubmitting(true);
try {
await onSubmit(content);
await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined);
editorRef.current?.clearContent();
setIsEmpty(true);
setAttachmentIds([]);
} finally {
setSubmitting(false);
}

View File

@@ -46,7 +46,7 @@ export const STATUS_CONFIG: Record<
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", badgeBg: "bg-muted", badgeText: "text-muted-foreground", columnBg: "bg-muted/40" },
in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10", dividerColor: "bg-warning", badgeBg: "bg-warning", badgeText: "text-white", columnBg: "bg-warning/5" },
in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10", dividerColor: "bg-success", badgeBg: "bg-success", badgeText: "text-white", columnBg: "bg-success/5" },
done: { label: "Done", iconColor: "text-done", hoverBg: "hover:bg-done/10", dividerColor: "bg-done", badgeBg: "bg-done", badgeText: "text-white", columnBg: "bg-done/5" },
done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10", dividerColor: "bg-info", badgeBg: "bg-info", badgeText: "text-white", columnBg: "bg-info/5" },
blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10", dividerColor: "bg-destructive", badgeBg: "bg-destructive", badgeText: "text-white", columnBg: "bg-destructive/5" },
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", badgeBg: "bg-muted", badgeText: "text-muted-foreground", columnBg: "bg-muted/40" },
};

View File

@@ -93,9 +93,16 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
// Due date popover
const [dueDateOpen, setDueDateOpen] = useState(false);
// File upload
// File upload — collect attachment IDs so we can link them after issue creation.
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
const { uploadWithToast } = useFileUpload();
const handleUpload = (file: File) => uploadWithToast(file);
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file);
if (result) {
setAttachmentIds((prev) => [...prev, result.id]);
}
return result;
};
const assigneeQuery = assigneeFilter.toLowerCase();
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(assigneeQuery));
@@ -130,6 +137,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
assignee_type: assigneeType,
assignee_id: assigneeId,
due_date: dueDate || undefined,
attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined,
});
useIssueStore.getState().addIssue(issue);
clearDraft();

View File

@@ -11,6 +11,7 @@ export interface CreateIssueRequest {
assignee_id?: string;
parent_issue_id?: string;
due_date?: string;
attachment_ids?: string[];
}
export interface UpdateIssueRequest {

View File

@@ -17,7 +17,7 @@ import (
var agentCmd = &cobra.Command{
Use: "agent",
Short: "Manage agents",
Short: "Work with agents",
}
var agentListCmd = &cobra.Command{
@@ -29,7 +29,7 @@ var agentListCmd = &cobra.Command{
var agentGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get agent details",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runAgentGet,
}
@@ -42,28 +42,28 @@ var agentCreateCmd = &cobra.Command{
var agentUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update an agent",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runAgentUpdate,
}
var agentArchiveCmd = &cobra.Command{
Use: "archive <id>",
Short: "Archive an agent",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runAgentArchive,
}
var agentRestoreCmd = &cobra.Command{
Use: "restore <id>",
Short: "Restore an archived agent",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runAgentRestore,
}
var agentTasksCmd = &cobra.Command{
Use: "tasks <id>",
Short: "List tasks for an agent",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runAgentTasks,
}
@@ -77,14 +77,14 @@ var agentSkillsCmd = &cobra.Command{
var agentSkillsListCmd = &cobra.Command{
Use: "list <agent-id>",
Short: "List skills assigned to an agent",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runAgentSkillsList,
}
var agentSkillsSetCmd = &cobra.Command{
Use: "set <agent-id>",
Short: "Set skills for an agent (replaces all current assignments)",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runAgentSkillsSet,
}

View File

@@ -14,14 +14,19 @@ import (
var attachmentCmd = &cobra.Command{
Use: "attachment",
Short: "Manage attachments",
Short: "Work with attachments",
}
var attachmentDownloadCmd = &cobra.Command{
Use: "download <attachment-id>",
Short: "Download an attachment to a local file",
Long: "Fetches the attachment metadata from the API, then downloads the file using its signed URL. Prints the local file path on success.",
Args: cobra.ExactArgs(1),
Long: "Download an attachment by its ID to a local file.",
Example: ` # Download an image attachment to the current directory
$ multica attachment download abc123
# Download to a specific directory
$ multica attachment download abc123 -o /tmp/images`,
Args: exactArgs(1),
RunE: runAttachmentDownload,
}

View File

@@ -22,7 +22,7 @@ import (
var authCmd = &cobra.Command{
Use: "auth",
Short: "Manage authentication",
Short: "Authenticate multica with Multica",
}
var authLoginCmd = &cobra.Command{

View File

@@ -11,7 +11,7 @@ import (
var configCmd = &cobra.Command{
Use: "config",
Short: "Show CLI configuration",
Short: "Manage configuration for multica",
RunE: runConfigShow,
}
@@ -25,7 +25,7 @@ var configSetCmd = &cobra.Command{
Use: "set <key> <value>",
Short: "Set a CLI configuration value",
Long: "Supported keys: server_url, app_url, workspace_id",
Args: cobra.ExactArgs(2),
Args: exactArgs(2),
RunE: runConfigSet,
}

View File

@@ -23,7 +23,7 @@ import (
var daemonCmd = &cobra.Command{
Use: "daemon",
Short: "Manage the local agent runtime daemon",
Short: "Control the local agent runtime daemon",
}
var daemonStartCmd = &cobra.Command{

View File

@@ -16,7 +16,7 @@ import (
var issueCmd = &cobra.Command{
Use: "issue",
Short: "Manage issues",
Short: "Work with issues",
}
var issueListCmd = &cobra.Command{
@@ -28,7 +28,7 @@ var issueListCmd = &cobra.Command{
var issueGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get issue details",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runIssueGet,
}
@@ -41,21 +41,21 @@ var issueCreateCmd = &cobra.Command{
var issueUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update an issue",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runIssueUpdate,
}
var issueAssignCmd = &cobra.Command{
Use: "assign <id>",
Short: "Assign an issue to a member or agent",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runIssueAssign,
}
var issueStatusCmd = &cobra.Command{
Use: "status <id> <status>",
Short: "Change issue status",
Args: cobra.ExactArgs(2),
Args: exactArgs(2),
RunE: runIssueStatus,
}
@@ -63,27 +63,27 @@ var issueStatusCmd = &cobra.Command{
var issueCommentCmd = &cobra.Command{
Use: "comment",
Short: "Manage issue comments",
Short: "Work with issue comments",
}
var issueCommentListCmd = &cobra.Command{
Use: "list <issue-id>",
Short: "List comments on an issue",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runIssueCommentList,
}
var issueCommentAddCmd = &cobra.Command{
Use: "add <issue-id>",
Short: "Add a comment to an issue",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runIssueCommentAdd,
}
var issueCommentDeleteCmd = &cobra.Command{
Use: "delete <comment-id>",
Short: "Delete a comment",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runIssueCommentDelete,
}
@@ -92,14 +92,14 @@ var issueCommentDeleteCmd = &cobra.Command{
var issueRunsCmd = &cobra.Command{
Use: "runs <issue-id>",
Short: "List execution history for an issue",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runIssueRuns,
}
var issueRunMessagesCmd = &cobra.Command{
Use: "run-messages <task-id>",
Short: "List messages for an execution",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runIssueRunMessages,
}

View File

@@ -14,14 +14,14 @@ import (
var repoCmd = &cobra.Command{
Use: "repo",
Short: "Manage repositories",
Short: "Work with repositories",
}
var repoCheckoutCmd = &cobra.Command{
Use: "checkout <url>",
Short: "Check out a repository into the working directory",
Long: "Creates a git worktree from the daemon's bare clone cache. Used by agents to check out repos on demand.",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runRepoCheckout,
}

View File

@@ -13,7 +13,7 @@ import (
var runtimeCmd = &cobra.Command{
Use: "runtime",
Short: "Manage agent runtimes",
Short: "Work with agent runtimes",
}
var runtimeListCmd = &cobra.Command{
@@ -25,28 +25,28 @@ var runtimeListCmd = &cobra.Command{
var runtimeUsageCmd = &cobra.Command{
Use: "usage <runtime-id>",
Short: "Get token usage for a runtime",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runRuntimeUsage,
}
var runtimeActivityCmd = &cobra.Command{
Use: "activity <runtime-id>",
Short: "Get hourly task activity for a runtime",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runRuntimeActivity,
}
var runtimePingCmd = &cobra.Command{
Use: "ping <runtime-id>",
Short: "Ping a runtime to check connectivity",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runRuntimePing,
}
var runtimeUpdateCmd = &cobra.Command{
Use: "update <runtime-id>",
Short: "Initiate a CLI update on a runtime",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runRuntimeUpdate,
}

View File

@@ -16,7 +16,7 @@ import (
var skillCmd = &cobra.Command{
Use: "skill",
Short: "Manage skills",
Short: "Work with skills",
}
var skillListCmd = &cobra.Command{
@@ -28,7 +28,7 @@ var skillListCmd = &cobra.Command{
var skillGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get skill details (includes files)",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runSkillGet,
}
@@ -41,14 +41,14 @@ var skillCreateCmd = &cobra.Command{
var skillUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a skill",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runSkillUpdate,
}
var skillDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a skill",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runSkillDelete,
}
@@ -62,27 +62,27 @@ var skillImportCmd = &cobra.Command{
var skillFilesCmd = &cobra.Command{
Use: "files",
Short: "Manage skill files",
Short: "Work with skill files",
}
var skillFilesListCmd = &cobra.Command{
Use: "list <skill-id>",
Short: "List files for a skill",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runSkillFilesList,
}
var skillFilesUpsertCmd = &cobra.Command{
Use: "upsert <skill-id>",
Short: "Create or update a skill file",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runSkillFilesUpsert,
}
var skillFilesDeleteCmd = &cobra.Command{
Use: "delete <skill-id> <file-id>",
Short: "Delete a skill file",
Args: cobra.ExactArgs(2),
Args: exactArgs(2),
RunE: runSkillFilesDelete,
}

View File

@@ -15,7 +15,7 @@ import (
var workspaceCmd = &cobra.Command{
Use: "workspace",
Short: "Manage workspaces",
Short: "Work with workspaces",
}
var workspaceListCmd = &cobra.Command{
@@ -41,14 +41,14 @@ var workspaceMembersCmd = &cobra.Command{
var workspaceWatchCmd = &cobra.Command{
Use: "watch <workspace-id>",
Short: "Add a workspace to the daemon watch list",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runWatch,
}
var workspaceUnwatchCmd = &cobra.Command{
Use: "unwatch <workspace-id>",
Short: "Remove a workspace from the daemon watch list",
Args: cobra.ExactArgs(1),
Args: exactArgs(1),
RunE: runUnwatch,
}

173
server/cmd/multica/help.go Normal file
View File

@@ -0,0 +1,173 @@
package main
import (
"fmt"
"strings"
"text/template"
"github.com/spf13/cobra"
)
// Command group IDs used across the CLI.
const (
groupCore = "core"
groupRuntime = "runtime"
groupAdditional = "additional"
)
// errSilent is returned when the error message has already been printed.
var errSilent = fmt.Errorf("")
// exactArgs returns a cobra.PositionalArgs that validates the arg count
// and prints help on failure, so users see usage context with the error.
func exactArgs(n int) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) != n {
if n == 1 {
fmt.Fprintf(cmd.ErrOrStderr(), "Error: accepts 1 arg, received %d\n\n", len(args))
} else {
fmt.Fprintf(cmd.ErrOrStderr(), "Error: accepts %d args, received %d\n\n", n, len(args))
}
cmd.Help()
return errSilent
}
return nil
}
}
// initHelp configures the root command to use gh-style help output.
func initHelp(root *cobra.Command) {
root.SetHelpTemplate(rootHelpTemplate)
root.SetUsageTemplate(rootHelpTemplate)
root.CompletionOptions.HiddenDefaultCmd = true
root.AddGroup(
&cobra.Group{ID: groupCore, Title: "CORE COMMANDS"},
&cobra.Group{ID: groupRuntime, Title: "RUNTIME COMMANDS"},
&cobra.Group{ID: groupAdditional, Title: "ADDITIONAL COMMANDS"},
)
// Apply gh-style templates to all commands recursively.
applyTemplates(root)
}
func applyTemplates(cmd *cobra.Command) {
for _, c := range cmd.Commands() {
if c.HasSubCommands() {
c.SetHelpTemplate(subHelpTemplate)
c.SetUsageTemplate(subHelpTemplate)
} else {
c.SetHelpTemplate(leafHelpTemplate)
c.SetUsageTemplate(leafHelpTemplate)
}
applyTemplates(c)
}
}
// formatCommandList formats a list of commands in "name: description" style
// with automatic alignment, matching gh's output.
func formatCommandList(cmds []*cobra.Command) string {
if len(cmds) == 0 {
return ""
}
maxLen := 0
for _, c := range cmds {
if c.IsAvailableCommand() && len(c.Name()) > maxLen {
maxLen = len(c.Name())
}
}
var b strings.Builder
for _, c := range cmds {
if !c.IsAvailableCommand() {
continue
}
padding := strings.Repeat(" ", maxLen-len(c.Name()))
fmt.Fprintf(&b, " %s:%s %s\n", c.Name(), padding, c.Short)
}
return b.String()
}
// commandsInGroup returns commands that belong to a specific group.
func commandsInGroup(cmds []*cobra.Command, groupID string) []*cobra.Command {
var result []*cobra.Command
for _, c := range cmds {
if c.GroupID == groupID && c.IsAvailableCommand() {
result = append(result, c)
}
}
return result
}
func init() {
cobra.AddTemplateFuncs(template.FuncMap{
"formatCommandList": formatCommandList,
"commandsInGroup": commandsInGroup,
})
}
var rootHelpTemplate = `Work seamlessly with Multica from the command line.
USAGE
multica <command> <subcommand> [flags]
{{range .Groups}}
{{.Title}}
{{formatCommandList (commandsInGroup $.Commands .ID)}}
{{- end}}
FLAGS
{{.LocalFlags.FlagUsages}}
EXAMPLES
$ multica login
$ multica issue list --output json
$ multica daemon start
$ multica agent list --output json
ENVIRONMENT VARIABLES
MULTICA_SERVER_URL Override the default server URL
MULTICA_WORKSPACE_ID Set the active workspace
LEARN MORE
Use ` + "`multica <command> <subcommand> --help`" + ` for more information about a command.
`
var subHelpTemplate = `{{.Short}}
USAGE
{{.CommandPath}} <command> [flags]
COMMANDS
{{formatCommandList .Commands}}
INHERITED FLAGS
--help Show help for command
{{- if .Example}}
EXAMPLES
{{.Example}}
{{- end}}
LEARN MORE
Use ` + "`{{.CommandPath}} <command> --help`" + ` for more information about a command.
`
var leafHelpTemplate = `{{if .Long}}{{.Long}}{{else}}{{.Short}}{{end}}
USAGE
{{.UseLine}}
{{- if .HasLocalFlags}}
FLAGS
{{.LocalFlags.FlagUsages}}
{{- end}}
INHERITED FLAGS
--help Show help for command
{{- if .Example}}
EXAMPLES
{{.Example}}
{{- end}}
LEARN MORE
Use ` + "`multica <command> <subcommand> --help`" + ` for more information about a command.
`

View File

@@ -15,7 +15,7 @@ var (
var rootCmd = &cobra.Command{
Use: "multica",
Short: "Multica CLI — local agent runtime and management tool",
Long: "multica manages local agent runtimes and provides control commands for the Multica platform.",
Long: "Work seamlessly with Multica from the command line.",
SilenceUsage: true,
SilenceErrors: true,
}
@@ -25,24 +25,47 @@ func init() {
rootCmd.PersistentFlags().String("workspace-id", "", "Workspace ID (env: MULTICA_WORKSPACE_ID)")
rootCmd.PersistentFlags().String("profile", "", "Configuration profile name (e.g. dev) — isolates config, daemon state, and workspaces")
rootCmd.AddCommand(loginCmd)
rootCmd.AddCommand(authCmd)
rootCmd.AddCommand(daemonCmd)
// Core commands
issueCmd.GroupID = groupCore
agentCmd.GroupID = groupCore
workspaceCmd.GroupID = groupCore
repoCmd.GroupID = groupCore
skillCmd.GroupID = groupCore
// Runtime commands
daemonCmd.GroupID = groupRuntime
runtimeCmd.GroupID = groupRuntime
// Additional commands
authCmd.GroupID = groupAdditional
loginCmd.GroupID = groupAdditional
attachmentCmd.GroupID = groupAdditional
configCmd.GroupID = groupAdditional
updateCmd.GroupID = groupAdditional
versionCmd.GroupID = groupAdditional
rootCmd.AddCommand(issueCmd)
rootCmd.AddCommand(agentCmd)
rootCmd.AddCommand(workspaceCmd)
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(issueCmd)
rootCmd.AddCommand(attachmentCmd)
rootCmd.AddCommand(repoCmd)
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(updateCmd)
rootCmd.AddCommand(skillCmd)
rootCmd.AddCommand(daemonCmd)
rootCmd.AddCommand(runtimeCmd)
rootCmd.AddCommand(authCmd)
rootCmd.AddCommand(loginCmd)
rootCmd.AddCommand(attachmentCmd)
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(updateCmd)
rootCmd.AddCommand(versionCmd)
initHelp(rootCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
if err != errSilent {
fmt.Fprintln(os.Stderr, "Error:", err)
}
os.Exit(1)
}
}

View File

@@ -381,6 +381,34 @@ func TestCommentTriggerThreadInheritedMention(t *testing.T) {
t.Errorf("expected 1 pending task (no duplicate), got %d", n)
}
})
t.Run("reply mentioning only a member does not inherit agent mention", func(t *testing.T) {
clearTasks(t, issueID)
// Top-level comment @mentions the agent.
content := fmt.Sprintf("[@Agent](mention://agent/%s) can you help?", agentID)
threadID := postComment(t, issueID, content, nil)
clearTasks(t, issueID)
// Reply mentions only a member — should NOT inherit parent's agent mention.
reply := fmt.Sprintf("cc [@Someone](mention://member/%s)", testUserID)
postComment(t, issueID, reply, strPtr(threadID))
if n := countPendingTasks(t, issueID); n != 0 {
t.Errorf("expected 0 pending tasks (member-only reply should not inherit agent mention), got %d", n)
}
})
t.Run("reply mentioning agent and member still inherits", func(t *testing.T) {
clearTasks(t, issueID)
// Top-level comment @mentions the agent.
content := fmt.Sprintf("[@Agent](mention://agent/%s) review this", agentID)
threadID := postComment(t, issueID, content, nil)
clearTasks(t, issueID)
// Reply mentions both agent and member — should still trigger.
reply := fmt.Sprintf("[@Agent](mention://agent/%s) and cc [@Someone](mention://member/%s)", agentID, testUserID)
postComment(t, issueID, reply, strPtr(threadID))
if n := countPendingTasks(t, issueID); n != 1 {
t.Errorf("expected 1 pending task (reply mentions agent explicitly), got %d", n)
}
})
}
// TestCommentTriggerCoalescing verifies that rapid-fire comments don't create
@@ -402,3 +430,33 @@ func TestCommentTriggerCoalescing(t *testing.T) {
t.Errorf("expected 1 pending task (coalescing), got %d", n)
}
}
// TestCommentTriggerMentionAssigneeDoneIssue verifies that @mentioning the
// assigned agent on a done issue still triggers execution. Previously the
// assignee was unconditionally skipped in the mention path (assuming
// on_comment handled it), but on_comment is suppressed for terminal statuses.
func TestCommentTriggerMentionAssigneeDoneIssue(t *testing.T) {
agentID := getAgentID(t)
// Create an issue assigned to the agent, then mark it done.
issueID := createIssueAssignedToAgent(t, "Mention-assignee-done test", agentID)
clearTasks(t, issueID) // clear any tasks from assignment
resp := authRequest(t, "PUT", "/api/issues/"+issueID, map[string]any{
"status": "done",
})
resp.Body.Close()
t.Cleanup(func() {
clearTasks(t, issueID)
resp := authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
resp.Body.Close()
})
// @mention the assigned agent on the done issue — should trigger.
content := fmt.Sprintf("[@Agent](mention://agent/%s) reopen this please", agentID)
postComment(t, issueID, content, nil)
if n := countPendingTasks(t, issueID); n != 1 {
t.Errorf("expected 1 pending task after @mention of assignee on done issue, got %d", n)
}
}

View File

@@ -34,6 +34,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect

View File

@@ -48,6 +48,8 @@ github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=

View File

@@ -143,6 +143,13 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("This downloads the file to the current directory and prints the local path. Use `-o <dir>` to save elsewhere.\n")
b.WriteString("After downloading, you can read the file directly (e.g. view an image, read a document).\n\n")
b.WriteString("## Important: Always Use the `multica` CLI\n\n")
b.WriteString("All interactions with Multica platform resources — including issues, comments, attachments, images, files, and any other platform data — **must** go through the `multica` CLI. ")
b.WriteString("Do NOT use `curl`, `wget`, or any other HTTP client to access Multica URLs or APIs directly. ")
b.WriteString("Multica resource URLs require authenticated access that only the `multica` CLI can provide.\n\n")
b.WriteString("If you need to perform an operation that is not covered by any existing `multica` command, ")
b.WriteString("do NOT attempt to work around it. Instead, post a comment mentioning the workspace owner to request the missing functionality.\n\n")
b.WriteString("## Output\n\n")
b.WriteString("Keep comments concise and natural — state the outcome, not the process.\n")
b.WriteString("Good: \"Fixed the login redirect. PR: https://...\"\n")

View File

@@ -354,7 +354,9 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu
// enqueues a task for each mentioned agent. When parentComment is non-nil
// (i.e. the comment is a reply), mentions from the parent (thread root) are
// also included so that agents mentioned in the top-level comment are
// re-triggered by subsequent replies in the same thread.
// re-triggered by subsequent replies in the same thread — unless the reply
// explicitly @mentions only non-agent entities (members, issues), which
// signals the user is talking to other people and not the agent.
// Skips self-mentions, agents that are already the issue's assignee (handled
// by on_comment), agents with on_mention trigger disabled, and private agents
// mentioned by non-owner members (only the agent owner or workspace
@@ -366,17 +368,31 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue
mentions := util.ParseMentions(comment.Content)
// When replying in a thread, also include mentions from the parent comment
// so that agents mentioned in the thread root are triggered by replies.
// However, skip inheritance when the reply explicitly @mentions only
// non-agent entities (members, issues) — the user is directing the reply
// at other people, not requesting work from agents in the parent thread.
if parentComment != nil {
parentMentions := util.ParseMentions(parentComment.Content)
seen := make(map[string]bool, len(mentions))
hasAgentMention := false
hasNonAgentMention := false
for _, m := range mentions {
seen[m.Type+":"+m.ID] = true
if m.Type == "agent" {
hasAgentMention = true
} else {
hasNonAgentMention = true
}
}
for _, m := range parentMentions {
if !seen[m.Type+":"+m.ID] {
mentions = append(mentions, m)
if hasAgentMention || !hasNonAgentMention {
parentMentions := util.ParseMentions(parentComment.Content)
seen := make(map[string]bool, len(mentions))
for _, m := range mentions {
seen[m.Type+":"+m.ID] = true
}
for _, m := range parentMentions {
if !seen[m.Type+":"+m.ID] {
mentions = append(mentions, m)
seen[m.Type+":"+m.ID] = true
}
}
}
}
for _, m := range mentions {
@@ -389,9 +405,14 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue
}
agentUUID := parseUUID(m.ID)
// Prevent duplicate: skip if this agent is the issue's assignee
// (already handled by the on_comment trigger above).
if issue.AssigneeType.Valid && issue.AssigneeType.String == "agent" &&
issue.AssigneeID.Valid && uuidToString(issue.AssigneeID) == m.ID {
// (already handled by the on_comment trigger above) — but only
// when the issue is in a non-terminal status where on_comment
// will actually fire. For done/cancelled issues on_comment is
// suppressed, so an explicit @mention must still go through.
isAssignee := issue.AssigneeType.Valid && issue.AssigneeType.String == "agent" &&
issue.AssigneeID.Valid && uuidToString(issue.AssigneeID) == m.ID
isTerminal := issue.Status == "done" || issue.Status == "cancelled"
if isAssignee && !isTerminal {
continue
}
// Load the agent to check visibility, archive status, and trigger config.

View File

@@ -2,8 +2,6 @@ package handler
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"log/slog"
@@ -12,6 +10,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
@@ -134,13 +133,14 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
return
}
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
slog.Error("failed to generate file key", "error", err)
// Generate a UUIDv7 to use as both the attachment ID and S3 key.
id, err := uuid.NewV7()
if err != nil {
slog.Error("failed to generate uuid", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
key := hex.EncodeToString(b) + path.Ext(header.Filename)
key := id.String() + path.Ext(header.Filename)
link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename)
if err != nil {
@@ -154,6 +154,7 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
uploaderType, uploaderID := h.resolveActor(r, userID, workspaceID)
params := db.CreateAttachmentParams{
ID: pgtype.UUID{Bytes: id, Valid: true},
WorkspaceID: parseUUID(workspaceID),
UploaderType: uploaderType,
UploaderID: parseUUID(uploaderID),
@@ -295,6 +296,22 @@ func (h *Handler) DeleteAttachment(w http.ResponseWriter, r *http.Request) {
// Attachment linking
// ---------------------------------------------------------------------------
// linkAttachmentsByIssueIDs links the given attachment IDs to an issue.
// Only updates attachments that have no issue_id yet.
func (h *Handler) linkAttachmentsByIssueIDs(ctx context.Context, issueID, workspaceID pgtype.UUID, ids []string) {
uuids := make([]pgtype.UUID, len(ids))
for i, id := range ids {
uuids[i] = parseUUID(id)
}
if err := h.Queries.LinkAttachmentsToIssue(ctx, db.LinkAttachmentsToIssueParams{
IssueID: issueID,
WorkspaceID: workspaceID,
Column3: uuids,
}); err != nil {
slog.Error("failed to link attachments to issue", "error", err)
}
}
// linkAttachmentsByIDs links the given attachment IDs to a comment.
// Only updates attachments that belong to the same issue and have no comment_id yet.
func (h *Handler) linkAttachmentsByIDs(ctx context.Context, commentID, issueID pgtype.UUID, ids []string) {

View File

@@ -170,14 +170,15 @@ func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
}
type CreateIssueRequest struct {
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
ParentIssueID *string `json:"parent_issue_id"`
DueDate *string `json:"due_date"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
ParentIssueID *string `json:"parent_issue_id"`
DueDate *string `json:"due_date"`
AttachmentIDs []string `json:"attachment_ids,omitempty"`
}
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
@@ -287,8 +288,28 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
return
}
// Link any pre-uploaded attachments to this issue.
if len(req.AttachmentIDs) > 0 {
h.linkAttachmentsByIssueIDs(r.Context(), issue.ID, issue.WorkspaceID, req.AttachmentIDs)
}
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
resp := issueToResponse(issue, prefix)
// Fetch linked attachments so they appear in the response.
if len(req.AttachmentIDs) > 0 {
attachments, err := h.Queries.ListAttachmentsByIssue(r.Context(), db.ListAttachmentsByIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
})
if err == nil && len(attachments) > 0 {
resp.Attachments = make([]AttachmentResponse, len(attachments))
for i, a := range attachments {
resp.Attachments[i] = h.attachmentToResponse(a)
}
}
}
slog.Info("issue created", append(logger.RequestAttrs(r), "issue_id", uuidToString(issue.ID), "title", issue.Title, "status", issue.Status, "workspace_id", workspaceID)...)
h.publish(protocol.EventIssueCreated, workspaceID, creatorType, actualCreatorID, map[string]any{"issue": resp})

View File

@@ -12,12 +12,13 @@ import (
)
const createAttachment = `-- name: CreateAttachment :one
INSERT INTO attachment (workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
VALUES ($1, $8, $9, $2, $3, $4, $5, $6, $7)
INSERT INTO attachment (id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
VALUES ($1, $2, $9, $10, $3, $4, $5, $6, $7, $8)
RETURNING id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at
`
type CreateAttachmentParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
UploaderType string `json:"uploader_type"`
UploaderID pgtype.UUID `json:"uploader_id"`
@@ -31,6 +32,7 @@ type CreateAttachmentParams struct {
func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (Attachment, error) {
row := q.db.QueryRow(ctx, createAttachment,
arg.ID,
arg.WorkspaceID,
arg.UploaderType,
arg.UploaderID,
@@ -120,6 +122,25 @@ func (q *Queries) LinkAttachmentsToComment(ctx context.Context, arg LinkAttachme
return err
}
const linkAttachmentsToIssue = `-- name: LinkAttachmentsToIssue :exec
UPDATE attachment
SET issue_id = $1
WHERE workspace_id = $2
AND issue_id IS NULL
AND id = ANY($3::uuid[])
`
type LinkAttachmentsToIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Column3 []pgtype.UUID `json:"column_3"`
}
func (q *Queries) LinkAttachmentsToIssue(ctx context.Context, arg LinkAttachmentsToIssueParams) error {
_, err := q.db.Exec(ctx, linkAttachmentsToIssue, arg.IssueID, arg.WorkspaceID, arg.Column3)
return err
}
const listAttachmentURLsByCommentID = `-- name: ListAttachmentURLsByCommentID :many
SELECT url FROM attachment
WHERE comment_id = $1

View File

@@ -1,6 +1,6 @@
-- name: CreateAttachment :one
INSERT INTO attachment (workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
VALUES ($1, sqlc.narg(issue_id), sqlc.narg(comment_id), $2, $3, $4, $5, $6, $7)
INSERT INTO attachment (id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
VALUES ($1, $2, sqlc.narg(issue_id), sqlc.narg(comment_id), $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: ListAttachmentsByIssue :many
@@ -38,5 +38,12 @@ WHERE issue_id = $2
AND comment_id IS NULL
AND id = ANY($3::uuid[]);
-- name: LinkAttachmentsToIssue :exec
UPDATE attachment
SET issue_id = $1
WHERE workspace_id = $2
AND issue_id IS NULL
AND id = ANY($3::uuid[]);
-- name: DeleteAttachment :exec
DELETE FROM attachment WHERE id = $1 AND workspace_id = $2;