mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 16:39:33 +02:00
Compare commits
14 Commits
agent/lamb
...
v0.1.17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09565bc40f | ||
|
|
4036d64996 | ||
|
|
5b0a537302 | ||
|
|
0d9d4e6b69 | ||
|
|
4c0dbbf1c8 | ||
|
|
52a9a6ae5f | ||
|
|
d6a5ba4d5e | ||
|
|
4afef09a03 | ||
|
|
0771c15a59 | ||
|
|
3a96567fc1 | ||
|
|
9aee403ff9 | ||
|
|
7883fe7bd7 | ||
|
|
cbfb7d58b6 | ||
|
|
2832a06fe3 |
8
Makefile
8
Makefile
@@ -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:
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface CreateIssueRequest {
|
||||
assignee_id?: string;
|
||||
parent_issue_id?: string;
|
||||
due_date?: string;
|
||||
attachment_ids?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateIssueRequest {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
|
||||
var authCmd = &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Manage authentication",
|
||||
Short: "Authenticate multica with Multica",
|
||||
}
|
||||
|
||||
var authLoginCmd = &cobra.Command{
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
173
server/cmd/multica/help.go
Normal 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.
|
||||
`
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user