Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan
2b2e9828ea feat(project): add Project feature with full CRUD and issue association
- Add `project` table with status, color, lead, dates, and sort_order
- Add `project_id` column to `issue` table (nullable FK, ON DELETE SET NULL)
- Implement project CRUD API (POST/GET/PUT/DELETE /api/projects)
- Auto-calculate project progress from issue completion ratio
- Add project_id support to issue create, update, list, and batch operations
- Add WebSocket events (project:created/updated/deleted) with realtime sync
- Add frontend: project types, API client, Zustand store, realtime integration
- Add projects list page (/projects) with create dialog
- Add project detail page (/projects/[id]) with issue list
- Add project picker to issue detail properties sidebar
- Add "Projects" nav item to sidebar
2026-04-05 04:13:07 +08:00
59 changed files with 1840 additions and 1845 deletions

View File

@@ -15,6 +15,7 @@ import {
BookOpenText,
SquarePen,
CircleUser,
FolderKanban,
} from "lucide-react";
import { WorkspaceAvatar } from "@/features/workspace";
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
@@ -49,6 +50,7 @@ const primaryNav = [
{ href: "/inbox", label: "Inbox", icon: Inbox },
{ href: "/my-issues", label: "My Issues", icon: CircleUser },
{ href: "/issues", label: "Issues", icon: ListTodo },
{ href: "/projects", label: "Projects", icon: FolderKanban },
];
const workspaceNav = [

View File

@@ -1437,7 +1437,7 @@ function AgentDetail({
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive"
onClick={() => setConfirmArchive(true)}

View File

@@ -80,6 +80,7 @@ const stableStoreIssues = vi.hoisted(() => [
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 0,
due_date: "2026-06-01T00:00:00Z",
created_at: "2026-01-15T00:00:00Z",
@@ -202,6 +203,7 @@ const mockIssue: Issue = {
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 0,
due_date: "2026-06-01T00:00:00Z",
created_at: "2026-01-15T00:00:00Z",

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" },
},
@@ -220,6 +220,7 @@ vi.mock("@dnd-kit/utilities", () => ({
const issueDefaults = {
parent_issue_id: null,
project_id: null,
position: 0,
};

View File

@@ -0,0 +1,9 @@
"use client";
import { use } from "react";
import { ProjectDetailPage } from "@/features/projects/components/project-detail-page";
export default function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
return <ProjectDetailPage projectId={id} />;
}

View File

@@ -0,0 +1,7 @@
"use client";
import { ProjectsPage } from "@/features/projects/components/projects-page";
export default function Page() {
return <ProjectsPage />;
}

View File

@@ -30,12 +30,3 @@
background-color: var(--sidebar-accent);
color: var(--sidebar-accent-foreground);
}
/* Sonner toast: align icon to first line of text, not vertically centered */
[data-sonner-toast] {
align-items: flex-start !important;
}
[data-sonner-toast] [data-icon] {
margin-top: 2.5px;
}

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

@@ -13,19 +13,19 @@ const Toaster = ({ ...props }: ToasterProps) => {
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4 text-success" />
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4 text-info" />
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4 text-warning" />
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4 text-destructive" />
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin text-brand" />
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={

View File

@@ -90,17 +90,9 @@ export const useInboxStore = create<InboxState>((set, get) => ({
items: s.items.map((i) => (i.id === id ? { ...i, read: true } : i)),
})),
archive: (id) =>
set((s) => {
const target = s.items.find((i) => i.id === id);
const issueId = target?.issue_id;
return {
items: s.items.map((i) =>
i.id === id || (issueId && i.issue_id === issueId)
? { ...i, archived: true }
: i,
),
};
}),
set((s) => ({
items: s.items.map((i) => (i.id === id ? { ...i, archived: true } : i)),
})),
markAllRead: () =>
set((s) => ({
items: s.items.map((i) => (!i.archived ? { ...i, read: true } : i)),

View File

@@ -1,14 +1,13 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Bot, ChevronRight, ChevronUp, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react";
import { Bot, ChevronRight, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react";
import { api } from "@/shared/api";
import { useWSEvent } from "@/features/realtime";
import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskCancelledPayload } from "@/shared/types/events";
import type { AgentTask } from "@/shared/types/agent";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { useActorName } from "@/features/workspace";
import { redactSecrets } from "../utils/redact";
@@ -100,20 +99,16 @@ function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] {
interface AgentLiveCardProps {
issueId: string;
agentName?: string;
/** Scroll container ref — needed for sticky sentinel detection. */
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
}
export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentLiveCardProps) {
export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
const { getActorName } = useActorName();
const [activeTask, setActiveTask] = useState<AgentTask | null>(null);
const [items, setItems] = useState<TimelineItem[]>([]);
const [elapsed, setElapsed] = useState("");
const [autoScroll, setAutoScroll] = useState(true);
const [cancelling, setCancelling] = useState(false);
const [isStuck, setIsStuck] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const seenSeqs = useRef(new Set<string>());
// Check for active task on mount
@@ -220,36 +215,12 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL
// Elapsed time
useEffect(() => {
if (!activeTask?.started_at && !activeTask?.dispatched_at) return;
const startRef = activeTask.started_at ?? activeTask.dispatched_at!;
setElapsed(formatElapsed(startRef));
const interval = setInterval(() => setElapsed(formatElapsed(startRef)), 1000);
const ref = activeTask.started_at ?? activeTask.dispatched_at!;
setElapsed(formatElapsed(ref));
const interval = setInterval(() => setElapsed(formatElapsed(ref)), 1000);
return () => clearInterval(interval);
}, [activeTask?.started_at, activeTask?.dispatched_at]);
// Sentinel pattern: detect when the card is scrolled past and becomes "stuck"
useEffect(() => {
const sentinel = sentinelRef.current;
const root = scrollContainerRef?.current;
if (!sentinel || !root || !activeTask) {
setIsStuck(false);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]) setIsStuck(!entries[0].isIntersecting);
},
{ root, threshold: 0, rootMargin: "-40px 0px 0px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [scrollContainerRef, activeTask]);
const scrollToCard = useCallback(() => {
sentinelRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, []);
// Auto-scroll
useEffect(() => {
if (autoScroll && scrollRef.current) {
@@ -277,104 +248,67 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL
if (!activeTask) return null;
const toolCount = items.filter((i) => i.type === "tool_use").length;
const name = (activeTask.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent";
return (
<>
{/* Sentinel — zero-height element that IntersectionObserver watches */}
<div ref={sentinelRef} className="mt-4 h-0 pointer-events-none" aria-hidden />
<div
className={cn(
"rounded-lg border transition-all duration-200",
isStuck
? "sticky top-4 z-10 shadow-md border-brand/30 bg-brand/10 backdrop-blur-md"
: "border-info/20 bg-info/5",
<div className="rounded-lg border border-info/20 bg-info/5">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2">
<div className="flex items-center justify-center h-5 w-5 rounded-full bg-info/10 text-info shrink-0">
<Bot className="h-3 w-3" />
</div>
<div className="flex items-center gap-1.5 text-xs font-medium min-w-0">
<Loader2 className="h-3 w-3 animate-spin text-info shrink-0" />
<span className="truncate">{(activeTask?.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent"} is working</span>
</div>
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
{toolCount > 0 && (
<span className="text-xs text-muted-foreground shrink-0">
{toolCount} tool {toolCount === 1 ? "call" : "calls"}
</span>
)}
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2">
{activeTask.agent_id ? (
<ActorAvatar actorType="agent" actorId={activeTask.agent_id} size={20} />
) : (
<div className={cn(
"flex items-center justify-center h-5 w-5 rounded-full shrink-0",
isStuck ? "bg-brand/15 text-brand" : "bg-info/10 text-info",
)}>
<Bot className="h-3 w-3" />
</div>
)}
<div className="flex items-center gap-1.5 text-xs font-medium min-w-0">
<Loader2 className={cn("h-3 w-3 animate-spin shrink-0", isStuck ? "text-brand" : "text-info")} />
<span className="truncate">{name} is working</span>
</div>
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
{!isStuck && toolCount > 0 && (
<span className="text-xs text-muted-foreground shrink-0">
{toolCount} tool {toolCount === 1 ? "call" : "calls"}
</span>
)}
{isStuck ? (
<button
onClick={scrollToCard}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0"
title="Scroll to live card"
>
<ChevronUp className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={handleCancel}
disabled={cancelling}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50 shrink-0"
title="Stop agent"
>
{cancelling ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Square className="h-3 w-3" />
)}
<span>Stop</span>
</button>
)}
</div>
{/* Timeline content — collapses when stuck */}
<div
className={cn(
"overflow-hidden transition-all duration-200",
isStuck ? "max-h-0 opacity-0" : "max-h-[20rem] opacity-100",
)}
<button
onClick={handleCancel}
disabled={cancelling}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50 shrink-0"
title="Stop agent"
>
{items.length > 0 && (
<div
ref={scrollRef}
onScroll={handleScroll}
className="relative max-h-80 overflow-y-auto border-t border-info/10 px-3 py-2 space-y-0.5"
>
{items.map((item, idx) => (
<TimelineRow key={`${item.seq}-${idx}`} item={item} />
))}
{cancelling ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Square className="h-3 w-3" />
)}
<span>Stop</span>
</button>
</div>
{!autoScroll && (
<button
onClick={() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
setAutoScroll(true);
}
}}
className="sticky bottom-0 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-background border px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground shadow-sm"
>
<ArrowDown className="h-3 w-3" />
Latest
</button>
)}
</div>
{/* Timeline content */}
{items.length > 0 && (
<div
ref={scrollRef}
onScroll={handleScroll}
className="relative max-h-80 overflow-y-auto border-t border-info/10 px-3 py-2 space-y-0.5"
>
{items.map((item, idx) => (
<TimelineRow key={`${item.seq}-${idx}`} item={item} />
))}
{!autoScroll && (
<button
onClick={() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
setAutoScroll(true);
}
}}
className="sticky bottom-0 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-background border px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground shadow-sm"
>
<ArrowDown className="h-3 w-3" />
Latest
</button>
)}
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -64,6 +64,7 @@ import { CommentCard } from "./comment-card";
import { CommentInput } from "./comment-input";
import { AgentLiveCard, TaskRunHistory } from "./agent-live-card";
import { api } from "@/shared/api";
import { ProjectPicker } from "@/features/projects/components/project-picker";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useIssueStore } from "@/features/issues";
@@ -513,7 +514,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{issue.assignee_type === "member" && issue.assignee_id === m.user_id && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
{agents.filter((a) => !a.archived_at && canAssignAgent(a, user?.id, currentMemberRole)).map((a) => (
{agents.filter((a) => canAssignAgent(a, user?.id, currentMemberRole)).map((a) => (
<DropdownMenuItem
key={a.id}
onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}
@@ -742,9 +743,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
})}
</CommandGroup>
)}
{agents.filter((a) => !a.archived_at).length > 0 && (
{agents.length > 0 && (
<CommandGroup heading="Agents">
{agents.filter((a) => !a.archived_at).map((a) => {
{agents.map((a) => {
const sub = subscribers.find((s) => s.user_type === "agent" && s.user_id === a.id);
const isSubbed = !!sub;
return (
@@ -771,11 +772,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</div>
{/* Agent live output */}
<AgentLiveCard
issueId={id}
agentName={issue.assignee_type === "agent" && issue.assignee_id ? getActorName("agent", issue.assignee_id) : undefined}
scrollContainerRef={scrollContainerRef}
/>
<div className="mt-4">
<AgentLiveCard
issueId={id}
agentName={issue.assignee_type === "agent" && issue.assignee_id ? getActorName("agent", issue.assignee_id) : undefined}
/>
</div>
{/* Agent execution history */}
<div className="mt-3">
@@ -1017,6 +1019,15 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
onUpdate={handleUpdateField}
/>
</PropRow>
{/* Project */}
<PropRow label="Project">
<ProjectPicker
projectId={issue.project_id}
onUpdate={handleUpdateField}
align="start"
/>
</PropRow>
</div>}
</div>

View File

@@ -162,7 +162,7 @@ function ActorSubContent({
m.name.toLowerCase().includes(query),
);
const filteredAgents = agents.filter((a) =>
!a.archived_at && a.name.toLowerCase().includes(query),
a.name.toLowerCase().includes(query),
);
const isSelected = (type: "member" | "agent", id: string) =>

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

@@ -25,6 +25,7 @@ function makeIssue(overrides: Partial<Issue> = {}): Issue {
creator_type: "member",
creator_id: "u-1",
parent_issue_id: null,
project_id: null,
position: 0,
due_date: null,
created_at: "2025-01-01T00:00:00Z",

View File

@@ -272,39 +272,6 @@ export const en: LandingDict = {
title: "Changelog",
subtitle: "New updates and improvements to Multica.",
entries: [
{
version: "0.1.6",
date: "2026-04-03",
title: "Editor Overhaul & Agent Lifecycle",
changes: [
"Unified Tiptap editor with a single Markdown pipeline for editing and display",
"Reliable Markdown paste, inline code spacing, and link styling",
"Agent archive and restore — soft delete replaces hard delete",
"Archived agents hidden from default agent list",
"Skeleton loading states, error toasts, and confirmation dialogs across the app",
"OpenCode added as a supported agent provider",
"Reply-triggered agent tasks now inherit thread-root @mentions",
"Granular real-time event handling for issues and inbox — no more full refetches",
"Unified image upload flow for paste and button in the editor",
],
},
{
version: "0.1.5",
date: "2026-04-02",
title: "Mentions & Permissions",
changes: [
"@mention issues in comments with server-side auto-expansion",
"@all mention to notify every workspace member",
"Inbox auto-scrolls to the referenced comment from a notification",
"Repositories extracted into a standalone settings tab",
"CLI update support from the web runtime page and direct download for non-Homebrew installs",
"CLI commands for viewing issue execution runs and run messages",
"Agent permission model — owners and admins manage agents, members manage skills on their own agents",
"Per-issue serial execution to prevent concurrent task collisions",
"File upload now supports all file types",
"README redesign with quickstart guide",
],
},
{
version: "0.1.4",
date: "2026-04-01",

View File

@@ -272,39 +272,6 @@ export const zh: LandingDict = {
title: "\u66f4\u65b0\u65e5\u5fd7",
subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002",
entries: [
{
version: "0.1.6",
date: "2026-04-03",
title: "编辑器重构与 Agent 生命周期",
changes: [
"统一 Tiptap 编辑器,编辑和展示共用单一 Markdown 渲染管线",
"Markdown 粘贴、行内代码间距和链接样式修复",
"Agent 支持归档和恢复——软删除替代硬删除",
"默认列表隐藏已归档的 Agent",
"全应用新增骨架屏加载态、错误提示和确认对话框",
"新增 OpenCode 作为支持的 Agent 提供商",
"回复触发的 Agent 任务自动继承主线程 @提及",
"Issue 和收件箱实时事件细粒度处理,不再全量刷新",
"编辑器中统一图片上传流程,支持粘贴和按钮上传",
],
},
{
version: "0.1.5",
date: "2026-04-02",
title: "提及与权限",
changes: [
"评论中支持 @提及 Issue服务端自动展开",
"支持 @all 提及工作区所有成员",
"收件箱通知点击后自动滚动到对应评论",
"仓库管理独立为设置页单独标签页",
"支持从网页端运行时页面更新 CLI非 Homebrew 安装支持直接下载更新",
"新增 CLI 命令查看 Issue 执行记录和运行消息",
"Agent 权限模型优化——所有者和管理员管理 Agent成员可管理自己 Agent 的技能",
"每个 Issue 串行执行,防止并发任务冲突",
"文件上传支持所有文件类型",
"README 重新设计,新增快速入门指南",
],
},
{
version: "0.1.4",
date: "2026-04-01",

View File

@@ -99,7 +99,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
const assigneeQuery = assigneeFilter.toLowerCase();
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(assigneeQuery));
const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(assigneeQuery));
const filteredAgents = agents.filter((a) => a.name.toLowerCase().includes(assigneeQuery));
const assigneeLabel =
assigneeType && assigneeId

View File

@@ -0,0 +1,139 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ProjectStatus } from "@/shared/types";
import { useProjectStore } from "@/features/projects";
import { PROJECT_STATUSES, PROJECT_STATUS_CONFIG, PROJECT_COLORS } from "@/features/projects/config/status";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
interface CreateProjectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState<ProjectStatus>("backlog");
const [color, setColor] = useState(PROJECT_COLORS[0]);
const [loading, setLoading] = useState(false);
const createProject = useProjectStore((s) => s.createProject);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setLoading(true);
try {
await createProject({
name: name.trim(),
description: description.trim() || undefined,
status,
color,
});
toast.success("Project created");
onOpenChange(false);
setName("");
setDescription("");
setStatus("backlog");
setColor(PROJECT_COLORS[0]);
} catch {
toast.error("Failed to create project");
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>New Project</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="project-name">Name</Label>
<Input
id="project-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Project name"
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="project-desc">Description</Label>
<Textarea
id="project-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as ProjectStatus)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROJECT_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{PROJECT_STATUS_CONFIG[s].label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex gap-1.5 flex-wrap">
{PROJECT_COLORS.map((c) => (
<button
key={c}
type="button"
className={cn(
"h-6 w-6 rounded-full transition-all",
color === c ? "ring-2 ring-offset-2 ring-primary" : "hover:scale-110",
)}
style={{ backgroundColor: c }}
onClick={() => setColor(c)}
/>
))}
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!name.trim() || loading}>
{loading ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,233 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ChevronLeft,
Check,
MoreHorizontal,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Skeleton } from "@/components/ui/skeleton";
import type { UpdateProjectRequest } from "@/shared/types";
import { useProjectStore } from "@/features/projects";
import { useIssueStore } from "@/features/issues";
import { useActorName } from "@/features/workspace";
import { ProjectStatusBadge } from "./project-status-badge";
import { ProjectProgressBar } from "./project-progress-bar";
import { PROJECT_STATUSES, PROJECT_STATUS_CONFIG } from "@/features/projects/config/status";
import { StatusIcon } from "@/features/issues/components";
import { STATUS_CONFIG } from "@/features/issues/config";
import { api } from "@/shared/api";
export function ProjectDetailPage({ projectId }: { projectId: string }) {
const router = useRouter();
const project = useProjectStore((s) => s.projects.find((p) => p.id === projectId)) ?? null;
const updateProjectApi = useProjectStore((s) => s.updateProjectApi);
const deleteProject = useProjectStore((s) => s.deleteProject);
const allIssues = useIssueStore((s) => s.issues);
const { getActorName } = useActorName();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(!project);
// If project isn't in the store yet, fetch it
useEffect(() => {
if (project) {
setLoading(false);
return;
}
api.getProject(projectId).then((p) => {
useProjectStore.getState().addProject(p);
setLoading(false);
}).catch(() => {
setLoading(false);
});
}, [project, projectId]);
const projectIssues = allIssues.filter((i) => i.project_id === projectId);
const handleUpdateField = useCallback(
(updates: UpdateProjectRequest) => {
if (!project) return;
updateProjectApi(project.id, updates);
},
[project, updateProjectApi],
);
const handleDelete = async () => {
await deleteProject(projectId);
router.push("/projects");
};
if (loading) {
return (
<div className="p-6 space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96" />
<Skeleton className="h-64 w-full" />
</div>
);
}
if (!project) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
Project not found
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center gap-3 border-b px-6 py-3">
<Link
href="/projects"
className="inline-flex items-center justify-center h-7 w-7 rounded-md hover:bg-accent transition-colors"
>
<ChevronLeft className="h-4 w-4" />
</Link>
<div
className="h-3 w-3 rounded-full shrink-0"
style={{ backgroundColor: project.color ?? "#6366f1" }}
/>
<h1 className="text-lg font-semibold truncate flex-1">{project.name}</h1>
{/* Status dropdown */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="sm" className="gap-1.5">
<ProjectStatusBadge status={project.status} />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-44">
{PROJECT_STATUSES.map((s) => (
<DropdownMenuItem key={s} onClick={() => handleUpdateField({ status: s })}>
<span className={PROJECT_STATUS_CONFIG[s].color}>
{PROJECT_STATUS_CONFIG[s].label}
</span>
{s === project.status && <Check className="ml-auto h-3.5 w-3.5" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* More menu */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-4 w-4" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Progress */}
<div className="max-w-sm">
<ProjectProgressBar progress={project.progress} />
</div>
{/* Description */}
{project.description && (
<p className="text-sm text-muted-foreground">{project.description}</p>
)}
{/* Meta */}
<div className="flex gap-6 text-xs text-muted-foreground">
{project.lead_type && project.lead_id && (
<span>Lead: {getActorName(project.lead_type, project.lead_id)}</span>
)}
{project.start_date && (
<span>Start: {new Date(project.start_date).toLocaleDateString()}</span>
)}
{project.target_date && (
<span>Target: {new Date(project.target_date).toLocaleDateString()}</span>
)}
</div>
{/* Issues list */}
<div>
<h2 className="text-sm font-medium mb-3">
Issues ({projectIssues.length})
</h2>
{projectIssues.length === 0 ? (
<p className="text-xs text-muted-foreground">
No issues in this project yet. Assign issues from the issue detail page.
</p>
) : (
<div className="space-y-1">
{projectIssues.map((issue) => (
<Link
key={issue.id}
href={`/issues/${issue.id}`}
className="flex items-center gap-3 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors text-sm"
>
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs text-muted-foreground shrink-0">
{issue.identifier}
</span>
<span className="truncate flex-1">{issue.title}</span>
<span className="text-xs text-muted-foreground shrink-0">
{STATUS_CONFIG[issue.status].label}
</span>
</Link>
))}
</div>
)}
</div>
</div>
{/* Delete dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete project?</AlertDialogTitle>
<AlertDialogDescription>
This will delete the project. Issues in this project will not be deleted,
but they will no longer be associated with any project.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { useState } from "react";
import { Check, FolderKanban, X } from "lucide-react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from "@/components/ui/command";
import { useProjectStore } from "@/features/projects";
interface ProjectPickerProps {
projectId: string | null;
onUpdate: (updates: { project_id?: string | null }) => void;
align?: "start" | "end";
}
export function ProjectPicker({ projectId, onUpdate, align = "start" }: ProjectPickerProps) {
const projects = useProjectStore((s) => s.projects);
const [open, setOpen] = useState(false);
const current = projects.find((p) => p.id === projectId);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden text-xs">
{current ? (
<>
<div
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: current.color ?? "#6366f1" }}
/>
<span className="truncate">{current.name}</span>
</>
) : (
<span className="text-muted-foreground">None</span>
)}
</PopoverTrigger>
<PopoverContent className="w-52 p-0" align={align}>
<Command>
<CommandInput placeholder="Search projects..." />
<CommandList>
<CommandEmpty>No projects found.</CommandEmpty>
<CommandGroup>
{projectId && (
<CommandItem
onSelect={() => {
onUpdate({ project_id: null });
setOpen(false);
}}
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Remove from project</span>
</CommandItem>
)}
{projects.map((p) => (
<CommandItem
key={p.id}
onSelect={() => {
onUpdate({ project_id: p.id });
setOpen(false);
}}
>
<div
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: p.color ?? "#6366f1" }}
/>
<span className="truncate">{p.name}</span>
{p.id === projectId && (
<Check className="ml-auto h-3.5 w-3.5 shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
import type { ProjectProgress } from "@/shared/types";
import { cn } from "@/lib/utils";
export function ProjectProgressBar({
progress,
className,
}: {
progress?: ProjectProgress;
className?: string;
}) {
const pct = progress?.percent ?? 0;
const total = progress?.total ?? 0;
const completed = progress?.completed ?? 0;
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="h-1.5 flex-1 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{completed}/{total}
</span>
</div>
);
}

View File

@@ -0,0 +1,27 @@
"use client";
import type { ProjectStatus } from "@/shared/types";
import { PROJECT_STATUS_CONFIG } from "@/features/projects/config/status";
import { cn } from "@/lib/utils";
export function ProjectStatusBadge({
status,
className,
}: {
status: ProjectStatus;
className?: string;
}) {
const config = PROJECT_STATUS_CONFIG[status];
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
config.bg,
config.color,
className,
)}
>
{config.label}
</span>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useProjectStore } from "@/features/projects";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { ProjectStatusBadge } from "./project-status-badge";
import { ProjectProgressBar } from "./project-progress-bar";
import { CreateProjectDialog } from "./create-project-dialog";
import { Skeleton } from "@/components/ui/skeleton";
export function ProjectsPage() {
const projects = useProjectStore((s) => s.projects);
const loading = useProjectStore((s) => s.loading);
const [createOpen, setCreateOpen] = useState(false);
const { getActorName } = useActorName();
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between border-b px-6 py-3">
<h1 className="text-lg font-semibold">Projects</h1>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
New Project
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-lg" />
))}
</div>
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<p className="text-sm">No projects yet</p>
<Button variant="outline" size="sm" className="mt-3" onClick={() => setCreateOpen(true)}>
Create your first project
</Button>
</div>
) : (
<div className="space-y-2">
{projects.map((project) => (
<Link
key={project.id}
href={`/projects/${project.id}`}
className="flex items-center gap-4 rounded-lg border p-4 hover:bg-accent/50 transition-colors"
>
{/* Color dot */}
<div
className="h-3 w-3 rounded-full shrink-0"
style={{ backgroundColor: project.color ?? "#6366f1" }}
/>
{/* Name + description */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{project.name}</span>
<ProjectStatusBadge status={project.status} />
</div>
{project.description && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{project.description}
</p>
)}
</div>
{/* Lead */}
{project.lead_type && project.lead_id && (
<span className="text-xs text-muted-foreground shrink-0">
{getActorName(project.lead_type, project.lead_id)}
</span>
)}
{/* Progress */}
<div className="w-32 shrink-0">
<ProjectProgressBar progress={project.progress} />
</div>
{/* Target date */}
{project.target_date && (
<span className="text-xs text-muted-foreground shrink-0">
{new Date(project.target_date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</span>
)}
</Link>
))}
</div>
)}
</div>
<CreateProjectDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

View File

@@ -0,0 +1,33 @@
import type { ProjectStatus } from "@/shared/types";
export const PROJECT_STATUSES: ProjectStatus[] = [
"backlog",
"planned",
"in_progress",
"completed",
"cancelled",
];
export const PROJECT_STATUS_CONFIG: Record<
ProjectStatus,
{ label: string; color: string; bg: string }
> = {
backlog: { label: "Backlog", color: "text-muted-foreground", bg: "bg-muted" },
planned: { label: "Planned", color: "text-blue-600", bg: "bg-blue-50 dark:bg-blue-950" },
in_progress: { label: "In Progress", color: "text-yellow-600", bg: "bg-yellow-50 dark:bg-yellow-950" },
completed: { label: "Completed", color: "text-green-600", bg: "bg-green-50 dark:bg-green-950" },
cancelled: { label: "Cancelled", color: "text-muted-foreground", bg: "bg-muted" },
};
export const PROJECT_COLORS = [
"#6366f1",
"#8b5cf6",
"#ec4899",
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#14b8a6",
"#06b6d4",
"#3b82f6",
];

View File

@@ -0,0 +1 @@
export { useProjectStore } from "./store";

View File

@@ -0,0 +1,80 @@
"use client";
import { create } from "zustand";
import type { Project, CreateProjectRequest, UpdateProjectRequest } from "@/shared/types";
import { toast } from "sonner";
import { api } from "@/shared/api";
interface ProjectState {
projects: Project[];
loading: boolean;
fetch: () => Promise<void>;
addProject: (project: Project) => void;
updateProject: (id: string, updates: Partial<Project>) => void;
removeProject: (id: string) => void;
createProject: (data: CreateProjectRequest) => Promise<Project>;
updateProjectApi: (id: string, data: UpdateProjectRequest) => Promise<void>;
deleteProject: (id: string) => Promise<void>;
}
export const useProjectStore = create<ProjectState>((set, get) => ({
projects: [],
loading: true,
fetch: async () => {
const isInitialLoad = get().projects.length === 0;
if (isInitialLoad) set({ loading: true });
try {
const res = await api.listProjects();
set({ projects: res.projects, loading: false });
} catch {
toast.error("Failed to load projects");
if (isInitialLoad) set({ loading: false });
}
},
addProject: (project) =>
set((s) => ({
projects: s.projects.some((p) => p.id === project.id)
? s.projects
: [...s.projects, project],
})),
updateProject: (id, updates) =>
set((s) => ({
projects: s.projects.map((p) => (p.id === id ? { ...p, ...updates } : p)),
})),
removeProject: (id) =>
set((s) => ({ projects: s.projects.filter((p) => p.id !== id) })),
createProject: async (data) => {
const project = await api.createProject(data);
get().addProject(project);
return project;
},
updateProjectApi: async (id, data) => {
const prev = get().projects.find((p) => p.id === id);
get().updateProject(id, data);
try {
const updated = await api.updateProject(id, data);
get().updateProject(id, updated);
} catch {
if (prev) get().updateProject(id, prev);
toast.error("Failed to update project");
}
},
deleteProject: async (id) => {
const prev = get().projects;
get().removeProject(id);
try {
await api.deleteProject(id);
toast.success("Project deleted");
} catch {
set({ projects: prev });
toast.error("Failed to delete project");
}
},
}));

View File

@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { useIssueStore } from "@/features/issues";
import { useInboxStore } from "@/features/inbox";
import { useWorkspaceStore } from "@/features/workspace";
import { useProjectStore } from "@/features/projects";
import { useAuthStore } from "@/features/auth";
import { createLogger } from "@/shared/logger";
import { api } from "@/shared/api";
@@ -60,6 +61,7 @@ export function useRealtimeSync(ws: WSClient | null) {
});
},
skill: () => void useWorkspaceStore.getState().refreshSkills(),
project: () => void useProjectStore.getState().fetch(),
};
const timers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -173,6 +175,7 @@ export function useRealtimeSync(ws: WSClient | null) {
useWorkspaceStore.getState().refreshAgents(),
useWorkspaceStore.getState().refreshMembers(),
useWorkspaceStore.getState().refreshSkills(),
useProjectStore.getState().fetch(),
]);
} catch (e) {
logger.error("reconnect refetch failed", e);

View File

@@ -5,6 +5,7 @@ import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types";
import { useIssueStore } from "@/features/issues";
import { useInboxStore } from "@/features/inbox";
import { useRuntimeStore } from "@/features/runtimes";
import { useProjectStore } from "@/features/projects";
import { toast } from "sonner";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";
@@ -90,6 +91,7 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
api.listSkills().catch(() => [] as Skill[]),
useIssueStore.getState().fetch().catch(() => {}),
useInboxStore.getState().fetch().catch(() => {}),
useProjectStore.getState().fetch().catch(() => {}),
]);
logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length);
set({ members: nextMembers, agents: nextAgents, skills: nextSkills });
@@ -114,6 +116,7 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
useIssueStore.getState().setIssues([]);
useInboxStore.getState().setItems([]);
useRuntimeStore.getState().setRuntimes([]);
useProjectStore.setState({ projects: [], loading: true });
set({ workspace: ws, members: [], agents: [], skills: [] });
await hydrateWorkspace(workspaces, ws.id);

View File

@@ -35,6 +35,10 @@ import type {
TimelineEntry,
TaskMessagePayload,
Attachment,
Project,
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
} from "@/shared/types";
import { type Logger, noopLogger } from "@/shared/logger";
@@ -579,4 +583,33 @@ export class ApiClient {
async deleteAttachment(id: string): Promise<void> {
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
}
// Projects
async listProjects(params?: { status?: string }): Promise<ListProjectsResponse> {
const search = new URLSearchParams();
if (params?.status) search.set("status", params.status);
return this.fetch(`/api/projects?${search}`);
}
async getProject(id: string): Promise<Project> {
return this.fetch(`/api/projects/${id}`);
}
async createProject(data: CreateProjectRequest): Promise<Project> {
return this.fetch("/api/projects", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
return this.fetch(`/api/projects/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteProject(id: string): Promise<void> {
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
}
}

View File

@@ -10,6 +10,7 @@ export interface CreateIssueRequest {
assignee_type?: IssueAssigneeType;
assignee_id?: string;
parent_issue_id?: string;
project_id?: string;
due_date?: string;
}
@@ -20,6 +21,7 @@ export interface UpdateIssueRequest {
priority?: IssuePriority;
assignee_type?: IssueAssigneeType | null;
assignee_id?: string | null;
project_id?: string | null;
position?: number;
due_date?: string | null;
}

View File

@@ -32,3 +32,12 @@ export type { IssueSubscriber } from "./subscriber";
export type * from "./events";
export type * from "./api";
export type { Attachment } from "./attachment";
export type {
Project,
ProjectStatus,
ProjectLeadType,
ProjectProgress,
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
} from "./project";

View File

@@ -34,6 +34,7 @@ export interface Issue {
creator_type: IssueAssigneeType;
creator_id: string;
parent_issue_id: string | null;
project_id: string | null;
position: number;
due_date: string | null;
reactions?: IssueReaction[];

View File

@@ -0,0 +1,62 @@
export type ProjectStatus =
| "backlog"
| "planned"
| "in_progress"
| "completed"
| "cancelled";
export type ProjectLeadType = "member" | "agent";
export interface ProjectProgress {
total: number;
completed: number;
percent: number;
}
export interface Project {
id: string;
workspace_id: string;
name: string;
description: string | null;
status: ProjectStatus;
icon: string | null;
color: string | null;
lead_type: ProjectLeadType | null;
lead_id: string | null;
start_date: string | null;
target_date: string | null;
sort_order: number;
progress?: ProjectProgress;
created_at: string;
updated_at: string;
}
export interface CreateProjectRequest {
name: string;
description?: string;
status?: ProjectStatus;
icon?: string;
color?: string;
lead_type?: ProjectLeadType;
lead_id?: string;
start_date?: string;
target_date?: string;
}
export interface UpdateProjectRequest {
name?: string;
description?: string;
status?: ProjectStatus;
icon?: string;
color?: string;
lead_type?: ProjectLeadType | null;
lead_id?: string | null;
start_date?: string | null;
target_date?: string | null;
sort_order?: number;
}
export interface ListProjectsResponse {
projects: Project[];
total: number;
}

View File

@@ -2,11 +2,9 @@ package main
import (
"context"
"encoding/json"
"fmt"
"net/url"
"os"
"strings"
"time"
"github.com/spf13/cobra"
@@ -26,124 +24,10 @@ var agentListCmd = &cobra.Command{
RunE: runAgentList,
}
var agentGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get agent details",
Args: cobra.ExactArgs(1),
RunE: runAgentGet,
}
var agentCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new agent",
RunE: runAgentCreate,
}
var agentUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update an agent",
Args: cobra.ExactArgs(1),
RunE: runAgentUpdate,
}
var agentArchiveCmd = &cobra.Command{
Use: "archive <id>",
Short: "Archive an agent",
Args: cobra.ExactArgs(1),
RunE: runAgentArchive,
}
var agentRestoreCmd = &cobra.Command{
Use: "restore <id>",
Short: "Restore an archived agent",
Args: cobra.ExactArgs(1),
RunE: runAgentRestore,
}
var agentTasksCmd = &cobra.Command{
Use: "tasks <id>",
Short: "List tasks for an agent",
Args: cobra.ExactArgs(1),
RunE: runAgentTasks,
}
// Agent skills subcommands.
var agentSkillsCmd = &cobra.Command{
Use: "skills",
Short: "Manage agent skill assignments",
}
var agentSkillsListCmd = &cobra.Command{
Use: "list <agent-id>",
Short: "List skills assigned to an agent",
Args: cobra.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),
RunE: runAgentSkillsSet,
}
func init() {
agentCmd.AddCommand(agentListCmd)
agentCmd.AddCommand(agentGetCmd)
agentCmd.AddCommand(agentCreateCmd)
agentCmd.AddCommand(agentUpdateCmd)
agentCmd.AddCommand(agentArchiveCmd)
agentCmd.AddCommand(agentRestoreCmd)
agentCmd.AddCommand(agentTasksCmd)
agentCmd.AddCommand(agentSkillsCmd)
agentSkillsCmd.AddCommand(agentSkillsListCmd)
agentSkillsCmd.AddCommand(agentSkillsSetCmd)
// agent list
agentListCmd.Flags().String("output", "table", "Output format: table or json")
agentListCmd.Flags().Bool("include-archived", false, "Include archived agents")
// agent get
agentGetCmd.Flags().String("output", "json", "Output format: table or json")
// agent create
agentCreateCmd.Flags().String("name", "", "Agent name (required)")
agentCreateCmd.Flags().String("description", "", "Agent description")
agentCreateCmd.Flags().String("instructions", "", "Agent instructions")
agentCreateCmd.Flags().String("runtime-id", "", "Runtime ID (required)")
agentCreateCmd.Flags().String("runtime-config", "", "Runtime config as JSON string")
agentCreateCmd.Flags().String("visibility", "private", "Visibility: private or workspace")
agentCreateCmd.Flags().Int32("max-concurrent-tasks", 6, "Maximum concurrent tasks")
agentCreateCmd.Flags().String("output", "json", "Output format: table or json")
// agent update
agentUpdateCmd.Flags().String("name", "", "New name")
agentUpdateCmd.Flags().String("description", "", "New description")
agentUpdateCmd.Flags().String("instructions", "", "New instructions")
agentUpdateCmd.Flags().String("runtime-id", "", "New runtime ID")
agentUpdateCmd.Flags().String("runtime-config", "", "New runtime config as JSON string")
agentUpdateCmd.Flags().String("visibility", "", "New visibility: private or workspace")
agentUpdateCmd.Flags().String("status", "", "New status")
agentUpdateCmd.Flags().Int32("max-concurrent-tasks", 0, "New max concurrent tasks")
agentUpdateCmd.Flags().String("output", "json", "Output format: table or json")
// agent archive
agentArchiveCmd.Flags().String("output", "json", "Output format: table or json")
// agent restore
agentRestoreCmd.Flags().String("output", "json", "Output format: table or json")
// agent tasks
agentTasksCmd.Flags().String("output", "table", "Output format: table or json")
// agent skills list
agentSkillsListCmd.Flags().String("output", "table", "Output format: table or json")
// agent skills set
agentSkillsSetCmd.Flags().StringSlice("skill-ids", nil, "Skill IDs to assign (comma-separated)")
agentSkillsSetCmd.Flags().String("output", "json", "Output format: table or json")
}
// resolveProfile returns the --profile flag value (empty string means default profile).
@@ -206,10 +90,6 @@ func resolveWorkspaceID(cmd *cobra.Command) string {
return cfg.WorkspaceID
}
// ---------------------------------------------------------------------------
// Agent commands
// ---------------------------------------------------------------------------
func runAgentList(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
@@ -220,16 +100,9 @@ func runAgentList(cmd *cobra.Command, _ []string) error {
defer cancel()
var agents []map[string]any
params := url.Values{}
if client.WorkspaceID != "" {
params.Set("workspace_id", client.WorkspaceID)
}
if v, _ := cmd.Flags().GetBool("include-archived"); v {
params.Set("include_archived", "true")
}
path := "/api/agents"
if len(params) > 0 {
path += "?" + params.Encode()
if client.WorkspaceID != "" {
path += "?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
}
if err := client.GetJSON(ctx, path, &agents); err != nil {
return fmt.Errorf("list agents: %w", err)
@@ -240,342 +113,20 @@ func runAgentList(cmd *cobra.Command, _ []string) error {
return cli.PrintJSON(os.Stdout, agents)
}
headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "ARCHIVED"}
headers := []string{"ID", "NAME", "STATUS", "RUNTIME"}
rows := make([][]string, 0, len(agents))
for _, a := range agents {
archived := ""
if v := strVal(a, "archived_at"); v != "" {
archived = "yes"
}
rows = append(rows, []string{
strVal(a, "id"),
strVal(a, "name"),
strVal(a, "status"),
strVal(a, "runtime_mode"),
archived,
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runAgentGet(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var agent map[string]any
if err := client.GetJSON(ctx, "/api/agents/"+args[0], &agent); err != nil {
return fmt.Errorf("get agent: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, agent)
}
headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "VISIBILITY", "DESCRIPTION"}
rows := [][]string{{
strVal(agent, "id"),
strVal(agent, "name"),
strVal(agent, "status"),
strVal(agent, "runtime_mode"),
strVal(agent, "visibility"),
strVal(agent, "description"),
}}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runAgentCreate(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
name, _ := cmd.Flags().GetString("name")
if name == "" {
return fmt.Errorf("--name is required")
}
runtimeID, _ := cmd.Flags().GetString("runtime-id")
if runtimeID == "" {
return fmt.Errorf("--runtime-id is required")
}
body := map[string]any{
"name": name,
"runtime_id": runtimeID,
}
if v, _ := cmd.Flags().GetString("description"); v != "" {
body["description"] = v
}
if v, _ := cmd.Flags().GetString("instructions"); v != "" {
body["instructions"] = v
}
if cmd.Flags().Changed("runtime-config") {
v, _ := cmd.Flags().GetString("runtime-config")
var rc any
if err := json.Unmarshal([]byte(v), &rc); err != nil {
return fmt.Errorf("--runtime-config must be valid JSON: %w", err)
}
body["runtime_config"] = rc
}
if cmd.Flags().Changed("visibility") {
v, _ := cmd.Flags().GetString("visibility")
body["visibility"] = v
}
if cmd.Flags().Changed("max-concurrent-tasks") {
v, _ := cmd.Flags().GetInt32("max-concurrent-tasks")
body["max_concurrent_tasks"] = v
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/agents", body, &result); err != nil {
return fmt.Errorf("create agent: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Agent created: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runAgentUpdate(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
body := map[string]any{}
if cmd.Flags().Changed("name") {
v, _ := cmd.Flags().GetString("name")
body["name"] = v
}
if cmd.Flags().Changed("description") {
v, _ := cmd.Flags().GetString("description")
body["description"] = v
}
if cmd.Flags().Changed("instructions") {
v, _ := cmd.Flags().GetString("instructions")
body["instructions"] = v
}
if cmd.Flags().Changed("runtime-id") {
v, _ := cmd.Flags().GetString("runtime-id")
body["runtime_id"] = v
}
if cmd.Flags().Changed("runtime-config") {
v, _ := cmd.Flags().GetString("runtime-config")
var rc any
if err := json.Unmarshal([]byte(v), &rc); err != nil {
return fmt.Errorf("--runtime-config must be valid JSON: %w", err)
}
body["runtime_config"] = rc
}
if cmd.Flags().Changed("visibility") {
v, _ := cmd.Flags().GetString("visibility")
body["visibility"] = v
}
if cmd.Flags().Changed("status") {
v, _ := cmd.Flags().GetString("status")
body["status"] = v
}
if cmd.Flags().Changed("max-concurrent-tasks") {
v, _ := cmd.Flags().GetInt32("max-concurrent-tasks")
body["max_concurrent_tasks"] = v
}
if len(body) == 0 {
return fmt.Errorf("no fields to update; use --name, --description, --instructions, --runtime-id, --runtime-config, --visibility, --status, or --max-concurrent-tasks")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PutJSON(ctx, "/api/agents/"+args[0], body, &result); err != nil {
return fmt.Errorf("update agent: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Agent updated: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runAgentArchive(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/agents/"+args[0]+"/archive", nil, &result); err != nil {
return fmt.Errorf("archive agent: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Agent archived: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runAgentRestore(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/agents/"+args[0]+"/restore", nil, &result); err != nil {
return fmt.Errorf("restore agent: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Agent restored: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runAgentTasks(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var tasks []map[string]any
if err := client.GetJSON(ctx, "/api/agents/"+args[0]+"/tasks", &tasks); err != nil {
return fmt.Errorf("list agent tasks: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, tasks)
}
headers := []string{"ID", "ISSUE_ID", "STATUS", "CREATED_AT"}
rows := make([][]string, 0, len(tasks))
for _, t := range tasks {
rows = append(rows, []string{
strVal(t, "id"),
strVal(t, "issue_id"),
strVal(t, "status"),
strVal(t, "created_at"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
// ---------------------------------------------------------------------------
// Agent skills subcommands
// ---------------------------------------------------------------------------
func runAgentSkillsList(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var skills []map[string]any
if err := client.GetJSON(ctx, "/api/agents/"+args[0]+"/skills", &skills); err != nil {
return fmt.Errorf("list agent skills: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, skills)
}
headers := []string{"ID", "NAME", "DESCRIPTION"}
rows := make([][]string, 0, len(skills))
for _, s := range skills {
rows = append(rows, []string{
strVal(s, "id"),
strVal(s, "name"),
strVal(s, "description"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runAgentSkillsSet(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
if !cmd.Flags().Changed("skill-ids") {
return fmt.Errorf("--skill-ids is required (comma-separated skill IDs; use --skill-ids '' to clear all)")
}
skillIDs, _ := cmd.Flags().GetStringSlice("skill-ids")
// Allow passing empty string to clear all skills.
cleanIDs := make([]string, 0, len(skillIDs))
for _, id := range skillIDs {
id = strings.TrimSpace(id)
if id != "" {
cleanIDs = append(cleanIDs, id)
}
}
body := map[string]any{
"skill_ids": cleanIDs,
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result json.RawMessage
if err := client.PutJSON(ctx, "/api/agents/"+args[0]+"/skills", body, &result); err != nil {
return fmt.Errorf("set agent skills: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
var pretty any
json.Unmarshal(result, &pretty)
return cli.PrintJSON(os.Stdout, pretty)
}
fmt.Printf("Skills updated for agent %s\n", args[0])
return nil
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func strVal(m map[string]any, key string) string {
v, ok := m[key]
if !ok || v == nil {

View File

@@ -162,9 +162,6 @@ func init() {
// issue comment list
issueCommentListCmd.Flags().String("output", "table", "Output format: table or json")
issueCommentListCmd.Flags().Int("limit", 0, "Maximum number of comments to return (0 = all)")
issueCommentListCmd.Flags().Int("offset", 0, "Number of comments to skip")
issueCommentListCmd.Flags().String("since", "", "Only return comments created after this timestamp (RFC3339)")
// issue runs
issueRunsCmd.Flags().String("output", "table", "Output format: table or json")
@@ -539,36 +536,9 @@ func runIssueCommentList(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
params := url.Values{}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
params.Set("limit", fmt.Sprintf("%d", v))
}
if v, _ := cmd.Flags().GetInt("offset"); v > 0 {
params.Set("offset", fmt.Sprintf("%d", v))
}
if v, _ := cmd.Flags().GetString("since"); v != "" {
params.Set("since", v)
}
path := "/api/issues/" + args[0] + "/comments"
if len(params) > 0 {
path += "?" + params.Encode()
}
var comments []map[string]any
isPaginated := len(params) > 0
if isPaginated {
headers, getErr := client.GetJSONWithHeaders(ctx, path, &comments)
if getErr != nil {
return fmt.Errorf("list comments: %w", getErr)
}
if total := headers.Get("X-Total-Count"); total != "" {
fmt.Fprintf(os.Stderr, "Showing %d of %s comments.\n", len(comments), total)
}
} else {
if err := client.GetJSON(ctx, path, &comments); err != nil {
return fmt.Errorf("list comments: %w", err)
}
if err := client.GetJSON(ctx, "/api/issues/"+args[0]+"/comments", &comments); err != nil {
return fmt.Errorf("list comments: %w", err)
}
output, _ := cmd.Flags().GetString("output")

View File

@@ -1,306 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
var runtimeCmd = &cobra.Command{
Use: "runtime",
Short: "Manage agent runtimes",
}
var runtimeListCmd = &cobra.Command{
Use: "list",
Short: "List runtimes in the workspace",
RunE: runRuntimeList,
}
var runtimeUsageCmd = &cobra.Command{
Use: "usage <runtime-id>",
Short: "Get token usage for a runtime",
Args: cobra.ExactArgs(1),
RunE: runRuntimeUsage,
}
var runtimeActivityCmd = &cobra.Command{
Use: "activity <runtime-id>",
Short: "Get hourly task activity for a runtime",
Args: cobra.ExactArgs(1),
RunE: runRuntimeActivity,
}
var runtimePingCmd = &cobra.Command{
Use: "ping <runtime-id>",
Short: "Ping a runtime to check connectivity",
Args: cobra.ExactArgs(1),
RunE: runRuntimePing,
}
var runtimeUpdateCmd = &cobra.Command{
Use: "update <runtime-id>",
Short: "Initiate a CLI update on a runtime",
Args: cobra.ExactArgs(1),
RunE: runRuntimeUpdate,
}
func init() {
runtimeCmd.AddCommand(runtimeListCmd)
runtimeCmd.AddCommand(runtimeUsageCmd)
runtimeCmd.AddCommand(runtimeActivityCmd)
runtimeCmd.AddCommand(runtimePingCmd)
runtimeCmd.AddCommand(runtimeUpdateCmd)
// runtime list
runtimeListCmd.Flags().String("output", "table", "Output format: table or json")
// runtime usage
runtimeUsageCmd.Flags().String("output", "table", "Output format: table or json")
runtimeUsageCmd.Flags().Int("days", 90, "Number of days of usage data to retrieve (max 365)")
// runtime activity
runtimeActivityCmd.Flags().String("output", "table", "Output format: table or json")
// runtime ping
runtimePingCmd.Flags().String("output", "json", "Output format: table or json")
runtimePingCmd.Flags().Bool("wait", false, "Wait for ping to complete (poll until done)")
// runtime update
runtimeUpdateCmd.Flags().String("target-version", "", "Target version to update to (required)")
runtimeUpdateCmd.Flags().String("output", "json", "Output format: table or json")
runtimeUpdateCmd.Flags().Bool("wait", false, "Wait for update to complete (poll until done)")
}
// ---------------------------------------------------------------------------
// Runtime commands
// ---------------------------------------------------------------------------
func runRuntimeList(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var runtimes []map[string]any
if err := client.GetJSON(ctx, "/api/runtimes", &runtimes); err != nil {
return fmt.Errorf("list runtimes: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, runtimes)
}
headers := []string{"ID", "NAME", "MODE", "PROVIDER", "STATUS", "LAST_SEEN"}
rows := make([][]string, 0, len(runtimes))
for _, rt := range runtimes {
rows = append(rows, []string{
strVal(rt, "id"),
strVal(rt, "name"),
strVal(rt, "runtime_mode"),
strVal(rt, "provider"),
strVal(rt, "status"),
strVal(rt, "last_seen_at"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runRuntimeUsage(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
days, _ := cmd.Flags().GetInt("days")
if days < 1 || days > 365 {
return fmt.Errorf("--days must be between 1 and 365")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var usage []map[string]any
path := fmt.Sprintf("/api/runtimes/%s/usage?days=%d", args[0], days)
if err := client.GetJSON(ctx, path, &usage); err != nil {
return fmt.Errorf("get runtime usage: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, usage)
}
headers := []string{"DATE", "PROVIDER", "MODEL", "INPUT_TOKENS", "OUTPUT_TOKENS", "CACHE_READ", "CACHE_WRITE"}
rows := make([][]string, 0, len(usage))
for _, u := range usage {
rows = append(rows, []string{
strVal(u, "date"),
strVal(u, "provider"),
strVal(u, "model"),
strVal(u, "input_tokens"),
strVal(u, "output_tokens"),
strVal(u, "cache_read_tokens"),
strVal(u, "cache_write_tokens"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runRuntimeActivity(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var activity []map[string]any
if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/activity", &activity); err != nil {
return fmt.Errorf("get runtime activity: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, activity)
}
headers := []string{"HOUR", "COUNT"}
rows := make([][]string, 0, len(activity))
for _, a := range activity {
rows = append(rows, []string{
strVal(a, "hour"),
strVal(a, "count"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runRuntimePing(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
// Initiate ping.
var ping map[string]any
if err := client.PostJSON(ctx, "/api/runtimes/"+args[0]+"/ping", nil, &ping); err != nil {
return fmt.Errorf("initiate ping: %w", err)
}
wait, _ := cmd.Flags().GetBool("wait")
if !wait {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, ping)
}
fmt.Printf("Ping initiated: %s (status: %s)\n", strVal(ping, "id"), strVal(ping, "status"))
return nil
}
// Poll until completed/failed/timeout.
pingID := strVal(ping, "id")
for {
select {
case <-ctx.Done():
return fmt.Errorf("timed out waiting for ping (last status: %s)", strVal(ping, "status"))
case <-time.After(1 * time.Second):
}
if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/ping/"+pingID, &ping); err != nil {
return fmt.Errorf("get ping status: %w", err)
}
status := strVal(ping, "status")
if status == "completed" || status == "failed" || status == "timeout" {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, ping)
}
if status == "completed" {
fmt.Printf("Ping completed in %sms\n", strVal(ping, "duration_ms"))
} else {
fmt.Printf("Ping %s: %s\n", status, strVal(ping, "error"))
}
return nil
}
}
}
func runRuntimeUpdate(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
targetVersion, _ := cmd.Flags().GetString("target-version")
if targetVersion == "" {
return fmt.Errorf("--target-version is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Second)
defer cancel()
body := map[string]any{
"target_version": targetVersion,
}
var update map[string]any
if err := client.PostJSON(ctx, "/api/runtimes/"+args[0]+"/update", body, &update); err != nil {
return fmt.Errorf("initiate update: %w", err)
}
wait, _ := cmd.Flags().GetBool("wait")
if !wait {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, update)
}
fmt.Printf("Update initiated: %s (status: %s)\n", strVal(update, "id"), strVal(update, "status"))
return nil
}
// Poll until completed/failed/timeout.
updateID := strVal(update, "id")
for {
select {
case <-ctx.Done():
return fmt.Errorf("timed out waiting for update (last status: %s)", strVal(update, "status"))
case <-time.After(2 * time.Second):
}
if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/update/"+updateID, &update); err != nil {
return fmt.Errorf("get update status: %w", err)
}
status := strVal(update, "status")
if status == "completed" || status == "failed" || status == "timeout" {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, update)
}
if status == "completed" {
fmt.Printf("Update completed: %s\n", strVal(update, "output"))
} else {
fmt.Printf("Update %s: %s\n", status, strVal(update, "error"))
}
return nil
}
}
}

View File

@@ -1,450 +0,0 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
var skillCmd = &cobra.Command{
Use: "skill",
Short: "Manage skills",
}
var skillListCmd = &cobra.Command{
Use: "list",
Short: "List skills in the workspace",
RunE: runSkillList,
}
var skillGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get skill details (includes files)",
Args: cobra.ExactArgs(1),
RunE: runSkillGet,
}
var skillCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new skill",
RunE: runSkillCreate,
}
var skillUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a skill",
Args: cobra.ExactArgs(1),
RunE: runSkillUpdate,
}
var skillDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a skill",
Args: cobra.ExactArgs(1),
RunE: runSkillDelete,
}
var skillImportCmd = &cobra.Command{
Use: "import",
Short: "Import a skill from a URL (clawhub.ai or skills.sh)",
RunE: runSkillImport,
}
// Skill file subcommands.
var skillFilesCmd = &cobra.Command{
Use: "files",
Short: "Manage skill files",
}
var skillFilesListCmd = &cobra.Command{
Use: "list <skill-id>",
Short: "List files for a skill",
Args: cobra.ExactArgs(1),
RunE: runSkillFilesList,
}
var skillFilesUpsertCmd = &cobra.Command{
Use: "upsert <skill-id>",
Short: "Create or update a skill file",
Args: cobra.ExactArgs(1),
RunE: runSkillFilesUpsert,
}
var skillFilesDeleteCmd = &cobra.Command{
Use: "delete <skill-id> <file-id>",
Short: "Delete a skill file",
Args: cobra.ExactArgs(2),
RunE: runSkillFilesDelete,
}
func init() {
skillCmd.AddCommand(skillListCmd)
skillCmd.AddCommand(skillGetCmd)
skillCmd.AddCommand(skillCreateCmd)
skillCmd.AddCommand(skillUpdateCmd)
skillCmd.AddCommand(skillDeleteCmd)
skillCmd.AddCommand(skillImportCmd)
skillCmd.AddCommand(skillFilesCmd)
skillFilesCmd.AddCommand(skillFilesListCmd)
skillFilesCmd.AddCommand(skillFilesUpsertCmd)
skillFilesCmd.AddCommand(skillFilesDeleteCmd)
// skill list
skillListCmd.Flags().String("output", "table", "Output format: table or json")
// skill get
skillGetCmd.Flags().String("output", "json", "Output format: table or json")
// skill create
skillCreateCmd.Flags().String("name", "", "Skill name (required)")
skillCreateCmd.Flags().String("description", "", "Skill description")
skillCreateCmd.Flags().String("content", "", "Skill content (SKILL.md body)")
skillCreateCmd.Flags().String("config", "", "Skill config as JSON string")
skillCreateCmd.Flags().String("output", "json", "Output format: table or json")
// skill update
skillUpdateCmd.Flags().String("name", "", "New name")
skillUpdateCmd.Flags().String("description", "", "New description")
skillUpdateCmd.Flags().String("content", "", "New content")
skillUpdateCmd.Flags().String("config", "", "New config as JSON string")
skillUpdateCmd.Flags().String("output", "json", "Output format: table or json")
// skill delete
skillDeleteCmd.Flags().Bool("yes", false, "Skip confirmation prompt")
// skill import
skillImportCmd.Flags().String("url", "", "URL to import from (required)")
skillImportCmd.Flags().String("output", "json", "Output format: table or json")
// skill files list
skillFilesListCmd.Flags().String("output", "table", "Output format: table or json")
// skill files upsert
skillFilesUpsertCmd.Flags().String("path", "", "File path within the skill (required)")
skillFilesUpsertCmd.Flags().String("content", "", "File content (required)")
skillFilesUpsertCmd.Flags().String("output", "json", "Output format: table or json")
}
// ---------------------------------------------------------------------------
// Skill commands
// ---------------------------------------------------------------------------
func runSkillList(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var skills []map[string]any
if err := client.GetJSON(ctx, "/api/skills", &skills); err != nil {
return fmt.Errorf("list skills: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, skills)
}
headers := []string{"ID", "NAME", "DESCRIPTION", "CREATED_AT"}
rows := make([][]string, 0, len(skills))
for _, s := range skills {
rows = append(rows, []string{
strVal(s, "id"),
strVal(s, "name"),
strVal(s, "description"),
strVal(s, "created_at"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runSkillGet(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var skill map[string]any
if err := client.GetJSON(ctx, "/api/skills/"+args[0], &skill); err != nil {
return fmt.Errorf("get skill: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, skill)
}
headers := []string{"ID", "NAME", "DESCRIPTION", "CREATED_AT"}
rows := [][]string{{
strVal(skill, "id"),
strVal(skill, "name"),
strVal(skill, "description"),
strVal(skill, "created_at"),
}}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runSkillCreate(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
name, _ := cmd.Flags().GetString("name")
if name == "" {
return fmt.Errorf("--name is required")
}
body := map[string]any{
"name": name,
}
if v, _ := cmd.Flags().GetString("description"); v != "" {
body["description"] = v
}
if v, _ := cmd.Flags().GetString("content"); v != "" {
body["content"] = v
}
if cmd.Flags().Changed("config") {
v, _ := cmd.Flags().GetString("config")
var config any
if err := json.Unmarshal([]byte(v), &config); err != nil {
return fmt.Errorf("--config must be valid JSON: %w", err)
}
body["config"] = config
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/skills", body, &result); err != nil {
return fmt.Errorf("create skill: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Skill created: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runSkillUpdate(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
body := map[string]any{}
if cmd.Flags().Changed("name") {
v, _ := cmd.Flags().GetString("name")
body["name"] = v
}
if cmd.Flags().Changed("description") {
v, _ := cmd.Flags().GetString("description")
body["description"] = v
}
if cmd.Flags().Changed("content") {
v, _ := cmd.Flags().GetString("content")
body["content"] = v
}
if cmd.Flags().Changed("config") {
v, _ := cmd.Flags().GetString("config")
var config any
if err := json.Unmarshal([]byte(v), &config); err != nil {
return fmt.Errorf("--config must be valid JSON: %w", err)
}
body["config"] = config
}
if len(body) == 0 {
return fmt.Errorf("no fields to update; use --name, --description, --content, or --config")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PutJSON(ctx, "/api/skills/"+args[0], body, &result); err != nil {
return fmt.Errorf("update skill: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Skill updated: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
func runSkillDelete(cmd *cobra.Command, args []string) error {
yes, _ := cmd.Flags().GetBool("yes")
if !yes {
fmt.Printf("Are you sure you want to delete skill %s? This cannot be undone. [y/N] ", args[0])
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Println("Aborted.")
return nil
}
}
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := client.DeleteJSON(ctx, "/api/skills/"+args[0]); err != nil {
return fmt.Errorf("delete skill: %w", err)
}
fmt.Printf("Skill deleted: %s\n", args[0])
return nil
}
func runSkillImport(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
importURL, _ := cmd.Flags().GetString("url")
if importURL == "" {
return fmt.Errorf("--url is required")
}
body := map[string]any{
"url": importURL,
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/skills/import", body, &result); err != nil {
return fmt.Errorf("import skill: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Skill imported: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
// ---------------------------------------------------------------------------
// Skill file subcommands
// ---------------------------------------------------------------------------
func runSkillFilesList(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var files []map[string]any
if err := client.GetJSON(ctx, "/api/skills/"+args[0]+"/files", &files); err != nil {
return fmt.Errorf("list skill files: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, files)
}
headers := []string{"ID", "PATH", "CREATED_AT", "UPDATED_AT"}
rows := make([][]string, 0, len(files))
for _, f := range files {
rows = append(rows, []string{
strVal(f, "id"),
strVal(f, "path"),
strVal(f, "created_at"),
strVal(f, "updated_at"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runSkillFilesUpsert(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
filePath, _ := cmd.Flags().GetString("path")
if filePath == "" {
return fmt.Errorf("--path is required")
}
content, _ := cmd.Flags().GetString("content")
if content == "" {
return fmt.Errorf("--content is required")
}
body := map[string]any{
"path": filePath,
"content": content,
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PutJSON(ctx, "/api/skills/"+args[0]+"/files", body, &result); err != nil {
return fmt.Errorf("upsert skill file: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Skill file upserted: %s (%s)\n", strVal(result, "path"), strVal(result, "id"))
return nil
}
func runSkillFilesDelete(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := client.DeleteJSON(ctx, "/api/skills/"+args[0]+"/files/"+args[1]); err != nil {
return fmt.Errorf("delete skill file: %w", err)
}
fmt.Printf("Skill file deleted: %s\n", args[1])
return nil
}

View File

@@ -36,8 +36,6 @@ func init() {
rootCmd.AddCommand(repoCmd)
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(updateCmd)
rootCmd.AddCommand(skillCmd)
rootCmd.AddCommand(runtimeCmd)
}
func main() {

View File

@@ -153,6 +153,17 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Group(func(r chi.Router) {
r.Use(middleware.RequireWorkspaceMember(queries))
// Projects
r.Route("/api/projects", func(r chi.Router) {
r.Get("/", h.ListProjects)
r.Post("/", h.CreateProject)
r.Route("/{id}", func(r chi.Router) {
r.Get("/", h.GetProject)
r.Put("/", h.UpdateProject)
r.Delete("/", h.DeleteProject)
})
})
// Issues
r.Route("/api/issues", func(r chi.Router) {
r.Get("/", h.ListIssues)

View File

@@ -77,34 +77,6 @@ func (c *APIClient) GetJSON(ctx context.Context, path string, out any) error {
return json.NewDecoder(resp.Body).Decode(out)
}
// GetJSONWithHeaders performs a GET request, decodes the JSON response, and
// returns the response headers. Useful when callers need header values like
// X-Total-Count for pagination.
func (c *APIClient) GetJSONWithHeaders(ctx context.Context, path string, out any) (http.Header, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
if err != nil {
return nil, err
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("GET %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data)))
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return resp.Header, err
}
}
return resp.Header, nil
}
// DeleteJSON performs a DELETE request.
func (c *APIClient) DeleteJSON(ctx context.Context, path string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+path, nil)

View File

@@ -47,7 +47,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("### Read\n")
b.WriteString("- `multica issue get <id> --output json` — Get full issue details (title, description, status, priority, assignee)\n")
b.WriteString("- `multica issue list [--status X] [--priority X] [--assignee X] --output json` — List issues in workspace\n")
b.WriteString("- `multica issue comment list <issue-id> [--limit N] [--offset N] [--since <RFC3339>] --output json` — List comments on an issue (supports pagination; includes id, parent_id for threading)\n")
b.WriteString("- `multica issue comment list <issue-id> --output json` — List all comments on an issue (includes id, parent_id for threading)\n")
b.WriteString("- `multica workspace get --output json` — Get workspace details and context\n")
b.WriteString("- `multica agent list --output json` — List agents in workspace\n")
b.WriteString("- `multica issue runs <issue-id> --output json` — List all execution runs for an issue (status, timestamps, errors)\n")
@@ -83,7 +83,6 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("**This task was triggered by a comment.** Your primary job is to respond.\n\n")
fmt.Fprintf(&b, "1. Run `multica issue get %s --output json` to understand the issue context\n", ctx.IssueID)
fmt.Fprintf(&b, "2. Run `multica issue comment list %s --output json` to read the conversation\n", ctx.IssueID)
b.WriteString(" - If the output is very large or truncated, use pagination: `--limit 30` to get the latest 30 comments, or `--since <timestamp>` to fetch only recent ones\n")
fmt.Fprintf(&b, "3. Find the triggering comment (ID: `%s`) and understand what is being asked\n", ctx.TriggerCommentID)
fmt.Fprintf(&b, "4. Reply: `multica issue comment add %s --parent %s --content \"...\"`\n", ctx.IssueID, ctx.TriggerCommentID)
b.WriteString("5. If the comment requests code changes or further work, do the work first, then reply with your results\n")

View File

@@ -5,8 +5,6 @@ import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
@@ -60,81 +58,10 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
return
}
// Parse optional pagination query params.
q := r.URL.Query()
var limit, offset int32
var hasPagination bool
if v := q.Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
writeError(w, http.StatusBadRequest, "invalid limit parameter")
return
}
limit = int32(n)
hasPagination = true
}
if v := q.Get("offset"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
writeError(w, http.StatusBadRequest, "invalid offset parameter")
return
}
offset = int32(n)
hasPagination = true
}
var sinceTime pgtype.Timestamptz
if v := q.Get("since"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid since parameter; expected RFC3339 format")
return
}
sinceTime = pgtype.Timestamptz{Time: t, Valid: true}
}
var comments []db.Comment
var err error
switch {
case sinceTime.Valid && hasPagination:
if limit == 0 {
limit = 50
}
comments, err = h.Queries.ListCommentsSincePaginated(r.Context(), db.ListCommentsSincePaginatedParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
CreatedAt: sinceTime,
Limit: limit,
Offset: offset,
})
case sinceTime.Valid:
// Apply a server-side cap to prevent unbounded result sets when
// --since is used without --limit.
comments, err = h.Queries.ListCommentsSincePaginated(r.Context(), db.ListCommentsSincePaginatedParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
CreatedAt: sinceTime,
Limit: 500,
Offset: 0,
})
hasPagination = true
case hasPagination:
if limit == 0 {
limit = 50
}
comments, err = h.Queries.ListCommentsPaginated(r.Context(), db.ListCommentsPaginatedParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
Limit: limit,
Offset: offset,
})
default:
comments, err = h.Queries.ListComments(r.Context(), db.ListCommentsParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
})
}
comments, err := h.Queries.ListComments(r.Context(), db.ListCommentsParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
@@ -153,17 +80,6 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
resp[i] = commentToResponse(c, grouped[cid], groupedAtt[cid])
}
// Include total count in response header when paginating.
if hasPagination {
total, countErr := h.Queries.CountComments(r.Context(), db.CountCommentsParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
})
if countErr == nil {
w.Header().Set("X-Total-Count", strconv.FormatInt(total, 10))
}
}
writeJSON(w, http.StatusOK, resp)
}
@@ -286,16 +202,8 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
// is announcing to everyone, not specifically requesting work from the agent.
func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.Issue) bool {
mentions := util.ParseMentions(content)
// Filter out issue mentions — they are cross-references, not @people.
filtered := mentions[:0]
for _, m := range mentions {
if m.Type != "issue" {
filtered = append(filtered, m)
}
}
mentions = filtered
if len(mentions) == 0 {
return false // No mentions (or only issue refs) — normal on_comment behavior
return false // No mentions — normal on_comment behavior
}
// @all is a broadcast to all members — suppress agent trigger.
if util.HasMentionAll(mentions) {

View File

@@ -143,21 +143,10 @@ func (h *Handler) ArchiveInboxItem(w http.ResponseWriter, r *http.Request) {
return
}
// Archive all sibling inbox items for the same issue (issue-level archive)
if item.IssueID.Valid {
h.Queries.ArchiveInboxByIssue(r.Context(), db.ArchiveInboxByIssueParams{
WorkspaceID: item.WorkspaceID,
RecipientType: item.RecipientType,
RecipientID: item.RecipientID,
IssueID: item.IssueID,
})
}
userID := requestUserID(r)
workspaceID := uuidToString(item.WorkspaceID)
h.publish(protocol.EventInboxArchived, workspaceID, "member", userID, map[string]any{
"item_id": uuidToString(item.ID),
"issue_id": uuidToPtr(item.IssueID),
"recipient_id": uuidToString(item.RecipientID),
})

View File

@@ -31,6 +31,7 @@ type IssueResponse struct {
CreatorType string `json:"creator_type"`
CreatorID string `json:"creator_id"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
Position float64 `json:"position"`
DueDate *string `json:"due_date"`
CreatedAt string `json:"created_at"`
@@ -72,6 +73,7 @@ func issueToResponse(i db.Issue, issuePrefix string) IssueResponse {
CreatorType: i.CreatorType,
CreatorID: uuidToString(i.CreatorID),
ParentIssueID: uuidToPtr(i.ParentIssueID),
ProjectID: uuidToPtr(i.ProjectID),
Position: i.Position,
DueDate: timestampToPtr(i.DueDate),
CreatedAt: timestampToString(i.CreatedAt),
@@ -110,6 +112,10 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
if a := r.URL.Query().Get("assignee_id"); a != "" {
assigneeFilter = parseUUID(a)
}
var projectFilter pgtype.UUID
if p := r.URL.Query().Get("project_id"); p != "" {
projectFilter = parseUUID(p)
}
issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{
WorkspaceID: parseUUID(workspaceID),
@@ -118,6 +124,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Status: statusFilter,
Priority: priorityFilter,
AssigneeID: assigneeFilter,
ProjectID: projectFilter,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list issues")
@@ -177,6 +184,7 @@ type CreateIssueRequest struct {
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
DueDate *string `json:"due_date"`
}
@@ -231,6 +239,11 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
parentIssueID = parseUUID(*req.ParentIssueID)
}
var projectID pgtype.UUID
if req.ProjectID != nil {
projectID = parseUUID(*req.ProjectID)
}
var dueDate pgtype.Timestamptz
if req.DueDate != nil && *req.DueDate != "" {
t, err := time.Parse(time.RFC3339, *req.DueDate)
@@ -275,6 +288,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
Position: 0,
DueDate: dueDate,
Number: issueNumber,
ProjectID: projectID,
})
if err != nil {
slog.Warn("create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
@@ -309,6 +323,7 @@ type UpdateIssueRequest struct {
Priority *string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
ProjectID *string `json:"project_id"`
Position *float64 `json:"position"`
DueDate *string `json:"due_date"`
}
@@ -345,6 +360,7 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
AssigneeType: prevIssue.AssigneeType,
AssigneeID: prevIssue.AssigneeID,
DueDate: prevIssue.DueDate,
ProjectID: prevIssue.ProjectID,
}
// COALESCE fields — only set when explicitly provided
@@ -390,6 +406,13 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
params.DueDate = pgtype.Timestamptz{Valid: false} // explicit null = clear date
}
}
if _, ok := rawFields["project_id"]; ok {
if req.ProjectID != nil {
params.ProjectID = parseUUID(*req.ProjectID)
} else {
params.ProjectID = pgtype.UUID{Valid: false} // explicit null = remove from project
}
}
// Enforce agent visibility: private agents can only be assigned by owner/admin.
if req.AssigneeType != nil && *req.AssigneeType == "agent" && req.AssigneeID != nil {
@@ -649,6 +672,7 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
AssigneeType: prevIssue.AssigneeType,
AssigneeID: prevIssue.AssigneeID,
DueDate: prevIssue.DueDate,
ProjectID: prevIssue.ProjectID,
}
if req.Updates.Title != nil {
@@ -691,6 +715,13 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
params.DueDate = pgtype.Timestamptz{Valid: false}
}
}
if _, ok := rawUpdates["project_id"]; ok {
if req.Updates.ProjectID != nil {
params.ProjectID = parseUUID(*req.Updates.ProjectID)
} else {
params.ProjectID = pgtype.UUID{Valid: false}
}
}
// Enforce agent visibility for batch assignment.
if req.Updates.AssigneeType != nil && *req.Updates.AssigneeType == "agent" && req.Updates.AssigneeID != nil {

View File

@@ -0,0 +1,403 @@
package handler
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/logger"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
type ProjectResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description *string `json:"description"`
Status string `json:"status"`
Icon *string `json:"icon"`
Color *string `json:"color"`
LeadType *string `json:"lead_type"`
LeadID *string `json:"lead_id"`
StartDate *string `json:"start_date"`
TargetDate *string `json:"target_date"`
SortOrder float64 `json:"sort_order"`
Progress *ProjectProgress `json:"progress,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ProjectProgress struct {
Total int32 `json:"total"`
Completed int32 `json:"completed"`
Percent float64 `json:"percent"`
}
func dateToPtr(d pgtype.Date) *string {
if !d.Valid {
return nil
}
s := d.Time.Format("2006-01-02")
return &s
}
func projectToResponse(p db.Project) ProjectResponse {
return ProjectResponse{
ID: uuidToString(p.ID),
WorkspaceID: uuidToString(p.WorkspaceID),
Name: p.Name,
Description: textToPtr(p.Description),
Status: p.Status,
Icon: textToPtr(p.Icon),
Color: textToPtr(p.Color),
LeadType: textToPtr(p.LeadType),
LeadID: uuidToPtr(p.LeadID),
StartDate: dateToPtr(p.StartDate),
TargetDate: dateToPtr(p.TargetDate),
SortOrder: p.SortOrder,
CreatedAt: timestampToString(p.CreatedAt),
UpdatedAt: timestampToString(p.UpdatedAt),
}
}
// ---------------------------------------------------------------------------
// Request types
// ---------------------------------------------------------------------------
type CreateProjectRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
Status string `json:"status"`
Icon *string `json:"icon"`
Color *string `json:"color"`
LeadType *string `json:"lead_type"`
LeadID *string `json:"lead_id"`
StartDate *string `json:"start_date"`
TargetDate *string `json:"target_date"`
}
type UpdateProjectRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Status *string `json:"status"`
Icon *string `json:"icon"`
Color *string `json:"color"`
LeadType *string `json:"lead_type"`
LeadID *string `json:"lead_id"`
StartDate *string `json:"start_date"`
TargetDate *string `json:"target_date"`
SortOrder *float64 `json:"sort_order"`
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
workspaceID := resolveWorkspaceID(r)
var statusFilter pgtype.Text
if s := r.URL.Query().Get("status"); s != "" {
statusFilter = pgtype.Text{String: s, Valid: true}
}
projects, err := h.Queries.ListProjects(r.Context(), db.ListProjectsParams{
WorkspaceID: parseUUID(workspaceID),
Status: statusFilter,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list projects")
return
}
// Batch-load progress for all projects.
progressRows, _ := h.Queries.ListProjectsProgress(r.Context(), parseUUID(workspaceID))
progressMap := make(map[string]ProjectProgress, len(progressRows))
for _, row := range progressRows {
pid := uuidToString(row.ProjectID)
pct := float64(0)
if row.Total > 0 {
pct = float64(row.Completed) / float64(row.Total) * 100
}
progressMap[pid] = ProjectProgress{Total: row.Total, Completed: row.Completed, Percent: pct}
}
resp := make([]ProjectResponse, len(projects))
for i, p := range projects {
resp[i] = projectToResponse(p)
if prog, ok := progressMap[resp[i].ID]; ok {
resp[i].Progress = &prog
} else {
resp[i].Progress = &ProjectProgress{}
}
}
writeJSON(w, http.StatusOK, map[string]any{
"projects": resp,
"total": len(resp),
})
}
func (h *Handler) GetProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workspaceID := resolveWorkspaceID(r)
project, err := h.Queries.GetProject(r.Context(), db.GetProjectParams{
ID: parseUUID(id),
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
resp := projectToResponse(project)
// Attach progress.
progress, err := h.Queries.GetProjectProgress(r.Context(), db.GetProjectProgressParams{
ProjectID: project.ID,
WorkspaceID: project.WorkspaceID,
})
if err == nil {
pct := float64(0)
if progress.Total > 0 {
pct = float64(progress.Completed) / float64(progress.Total) * 100
}
resp.Progress = &ProjectProgress{Total: progress.Total, Completed: progress.Completed, Percent: pct}
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) {
var req CreateProjectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
workspaceID := resolveWorkspaceID(r)
userID, ok := requireUserID(w, r)
if !ok {
return
}
status := req.Status
if status == "" {
status = "backlog"
}
var leadType pgtype.Text
var leadID pgtype.UUID
if req.LeadType != nil {
leadType = pgtype.Text{String: *req.LeadType, Valid: true}
}
if req.LeadID != nil {
leadID = parseUUID(*req.LeadID)
}
var startDate pgtype.Date
if req.StartDate != nil && *req.StartDate != "" {
t, err := time.Parse("2006-01-02", *req.StartDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid start_date format, expected YYYY-MM-DD")
return
}
startDate = pgtype.Date{Time: t, Valid: true}
}
var targetDate pgtype.Date
if req.TargetDate != nil && *req.TargetDate != "" {
t, err := time.Parse("2006-01-02", *req.TargetDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid target_date format, expected YYYY-MM-DD")
return
}
targetDate = pgtype.Date{Time: t, Valid: true}
}
project, err := h.Queries.CreateProject(r.Context(), db.CreateProjectParams{
WorkspaceID: parseUUID(workspaceID),
Name: req.Name,
Description: ptrToText(req.Description),
Status: status,
Icon: ptrToText(req.Icon),
Color: ptrToText(req.Color),
LeadType: leadType,
LeadID: leadID,
StartDate: startDate,
TargetDate: targetDate,
SortOrder: 0,
})
if err != nil {
slog.Warn("create project failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to create project")
return
}
resp := projectToResponse(project)
resp.Progress = &ProjectProgress{}
slog.Info("project created", append(logger.RequestAttrs(r), "project_id", resp.ID, "name", project.Name, "workspace_id", workspaceID)...)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
h.publish(protocol.EventProjectCreated, workspaceID, actorType, actorID, map[string]any{"project": resp})
writeJSON(w, http.StatusCreated, resp)
}
func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workspaceID := resolveWorkspaceID(r)
prevProject, err := h.Queries.GetProject(r.Context(), db.GetProjectParams{
ID: parseUUID(id),
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "failed to read request body")
return
}
var req UpdateProjectRequest
if err := json.Unmarshal(bodyBytes, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// Track which fields were explicitly present in JSON.
var rawFields map[string]json.RawMessage
json.Unmarshal(bodyBytes, &rawFields)
params := db.UpdateProjectParams{
ID: prevProject.ID,
WorkspaceID: prevProject.WorkspaceID,
LeadType: prevProject.LeadType,
LeadID: prevProject.LeadID,
StartDate: prevProject.StartDate,
TargetDate: prevProject.TargetDate,
}
if req.Name != nil {
params.Name = pgtype.Text{String: *req.Name, Valid: true}
}
if req.Description != nil {
params.Description = pgtype.Text{String: *req.Description, Valid: true}
}
if req.Status != nil {
params.Status = pgtype.Text{String: *req.Status, Valid: true}
}
if req.Icon != nil {
params.Icon = pgtype.Text{String: *req.Icon, Valid: true}
}
if req.Color != nil {
params.Color = pgtype.Text{String: *req.Color, Valid: true}
}
if req.SortOrder != nil {
params.SortOrder = pgtype.Float8{Float64: *req.SortOrder, Valid: true}
}
// Nullable fields — only override when explicitly present.
if _, ok := rawFields["lead_type"]; ok {
if req.LeadType != nil {
params.LeadType = pgtype.Text{String: *req.LeadType, Valid: true}
} else {
params.LeadType = pgtype.Text{Valid: false}
}
}
if _, ok := rawFields["lead_id"]; ok {
if req.LeadID != nil {
params.LeadID = parseUUID(*req.LeadID)
} else {
params.LeadID = pgtype.UUID{Valid: false}
}
}
if _, ok := rawFields["start_date"]; ok {
if req.StartDate != nil && *req.StartDate != "" {
t, err := time.Parse("2006-01-02", *req.StartDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid start_date format, expected YYYY-MM-DD")
return
}
params.StartDate = pgtype.Date{Time: t, Valid: true}
} else {
params.StartDate = pgtype.Date{Valid: false}
}
}
if _, ok := rawFields["target_date"]; ok {
if req.TargetDate != nil && *req.TargetDate != "" {
t, err := time.Parse("2006-01-02", *req.TargetDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid target_date format, expected YYYY-MM-DD")
return
}
params.TargetDate = pgtype.Date{Time: t, Valid: true}
} else {
params.TargetDate = pgtype.Date{Valid: false}
}
}
project, err := h.Queries.UpdateProject(r.Context(), params)
if err != nil {
slog.Warn("update project failed", append(logger.RequestAttrs(r), "error", err, "project_id", id, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to update project")
return
}
resp := projectToResponse(project)
slog.Info("project updated", append(logger.RequestAttrs(r), "project_id", id, "workspace_id", workspaceID)...)
userID := requestUserID(r)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
h.publish(protocol.EventProjectUpdated, workspaceID, actorType, actorID, map[string]any{"project": resp})
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) DeleteProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workspaceID := resolveWorkspaceID(r)
_, err := h.Queries.GetProject(r.Context(), db.GetProjectParams{
ID: parseUUID(id),
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
err = h.Queries.DeleteProject(r.Context(), db.DeleteProjectParams{
ID: parseUUID(id),
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete project")
return
}
userID := requestUserID(r)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
h.publish(protocol.EventProjectDeleted, workspaceID, actorType, actorID, map[string]any{"project_id": id})
slog.Info("project deleted", append(logger.RequestAttrs(r), "project_id", id, "workspace_id", workspaceID)...)
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -86,16 +86,6 @@ func TestCommentMentionsOthersButNotAssignee(t *testing.T) {
content: fmt.Sprintf("[@All](mention://all/all) [@Agent](mention://agent/%s) fyi", agentAssigneeID),
want: true,
},
{
name: "issue mention only → allow trigger (cross-reference, not @person)",
content: "[PAN-1](mention://issue/44c266e7-f6dd-4be3-9140-5ac40233f79c) is related",
want: false,
},
{
name: "issue mention + other agent → suppress (agent mention matters)",
content: fmt.Sprintf("[PAN-1](mention://issue/44c266e7-f6dd-4be3-9140-5ac40233f79c) cc [@Other](mention://agent/%s)", otherAgentID),
want: true,
},
}
for _, tt := range tests {

View File

@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_issue_project;
ALTER TABLE issue DROP COLUMN IF EXISTS project_id;
DROP TABLE IF EXISTS project;

View File

@@ -0,0 +1,25 @@
-- Create project table
CREATE TABLE project (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'backlog'
CHECK (status IN ('backlog', 'planned', 'in_progress', 'completed', 'cancelled')),
icon TEXT,
color TEXT,
lead_type TEXT CHECK (lead_type IN ('member', 'agent')),
lead_id UUID,
start_date DATE,
target_date DATE,
sort_order FLOAT8 NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_project_workspace ON project(workspace_id);
CREATE INDEX idx_project_workspace_status ON project(workspace_id, status);
-- Add project_id to issue
ALTER TABLE issue ADD COLUMN project_id UUID REFERENCES project(id) ON DELETE SET NULL;
CREATE INDEX idx_issue_project ON issue(workspace_id, project_id);

View File

@@ -11,23 +11,6 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const countComments = `-- name: CountComments :one
SELECT count(*) FROM comment
WHERE issue_id = $1 AND workspace_id = $2
`
type CountCommentsParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) CountComments(ctx context.Context, arg CountCommentsParams) (int64, error) {
row := q.db.QueryRow(ctx, countComments, arg.IssueID, arg.WorkspaceID)
var count int64
err := row.Scan(&count)
return count, err
}
const createComment = `-- name: CreateComment :one
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, parent_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
@@ -172,151 +155,6 @@ func (q *Queries) ListComments(ctx context.Context, arg ListCommentsParams) ([]C
return items, nil
}
const listCommentsPaginated = `-- name: ListCommentsPaginated :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment
WHERE issue_id = $1 AND workspace_id = $2
ORDER BY created_at ASC
LIMIT $3 OFFSET $4
`
type ListCommentsPaginatedParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListCommentsPaginated(ctx context.Context, arg ListCommentsPaginatedParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsPaginated,
arg.IssueID,
arg.WorkspaceID,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCommentsSince = `-- name: ListCommentsSince :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
ORDER BY created_at ASC
`
type ListCommentsSinceParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func (q *Queries) ListCommentsSince(ctx context.Context, arg ListCommentsSinceParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsSince, arg.IssueID, arg.WorkspaceID, arg.CreatedAt)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCommentsSincePaginated = `-- name: ListCommentsSincePaginated :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
ORDER BY created_at ASC
LIMIT $4 OFFSET $5
`
type ListCommentsSincePaginatedParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListCommentsSincePaginated(ctx context.Context, arg ListCommentsSincePaginatedParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsSincePaginated,
arg.IssueID,
arg.WorkspaceID,
arg.CreatedAt,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateComment = `-- name: UpdateComment :one
UPDATE comment SET
content = $2,

View File

@@ -66,31 +66,6 @@ func (q *Queries) ArchiveCompletedInbox(ctx context.Context, arg ArchiveComplete
return result.RowsAffected(), nil
}
const archiveInboxByIssue = `-- name: ArchiveInboxByIssue :execrows
UPDATE inbox_item SET archived = true
WHERE workspace_id = $1 AND recipient_type = $2 AND recipient_id = $3 AND issue_id = $4 AND archived = false
`
type ArchiveInboxByIssueParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
RecipientType string `json:"recipient_type"`
RecipientID pgtype.UUID `json:"recipient_id"`
IssueID pgtype.UUID `json:"issue_id"`
}
func (q *Queries) ArchiveInboxByIssue(ctx context.Context, arg ArchiveInboxByIssueParams) (int64, error) {
result, err := q.db.Exec(ctx, archiveInboxByIssue,
arg.WorkspaceID,
arg.RecipientType,
arg.RecipientID,
arg.IssueID,
)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const archiveInboxItem = `-- name: ArchiveInboxItem :one
UPDATE inbox_item SET archived = true
WHERE id = $1

View File

@@ -15,10 +15,10 @@ const createIssue = `-- name: CreateIssue :one
INSERT INTO issue (
workspace_id, title, description, status, priority,
assignee_type, assignee_id, creator_type, creator_id,
parent_issue_id, position, due_date, number
parent_issue_id, position, due_date, number, project_id
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id
`
type CreateIssueParams struct {
@@ -35,6 +35,7 @@ type CreateIssueParams struct {
Position float64 `json:"position"`
DueDate pgtype.Timestamptz `json:"due_date"`
Number int32 `json:"number"`
ProjectID pgtype.UUID `json:"project_id"`
}
func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) {
@@ -52,6 +53,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue
arg.Position,
arg.DueDate,
arg.Number,
arg.ProjectID,
)
var i Issue
err := row.Scan(
@@ -73,6 +75,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
)
return i, err
}
@@ -87,7 +90,7 @@ func (q *Queries) DeleteIssue(ctx context.Context, id pgtype.UUID) error {
}
const getIssue = `-- name: GetIssue :one
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id FROM issue
WHERE id = $1
`
@@ -113,12 +116,13 @@ func (q *Queries) GetIssue(ctx context.Context, id pgtype.UUID) (Issue, error) {
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
)
return i, err
}
const getIssueByNumber = `-- name: GetIssueByNumber :one
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id FROM issue
WHERE workspace_id = $1 AND number = $2
`
@@ -149,12 +153,13 @@ func (q *Queries) GetIssueByNumber(ctx context.Context, arg GetIssueByNumberPara
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
)
return i, err
}
const getIssueInWorkspace = `-- name: GetIssueInWorkspace :one
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id FROM issue
WHERE id = $1 AND workspace_id = $2
`
@@ -185,16 +190,18 @@ func (q *Queries) GetIssueInWorkspace(ctx context.Context, arg GetIssueInWorkspa
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
)
return i, err
}
const listIssues = `-- name: ListIssues :many
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id FROM issue
WHERE workspace_id = $1
AND ($4::text IS NULL OR status = $4)
AND ($5::text IS NULL OR priority = $5)
AND ($6::uuid IS NULL OR assignee_id = $6)
AND ($7::uuid IS NULL OR project_id = $7)
ORDER BY position ASC, created_at DESC
LIMIT $2 OFFSET $3
`
@@ -206,6 +213,7 @@ type ListIssuesParams struct {
Status pgtype.Text `json:"status"`
Priority pgtype.Text `json:"priority"`
AssigneeID pgtype.UUID `json:"assignee_id"`
ProjectID pgtype.UUID `json:"project_id"`
}
func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue, error) {
@@ -216,6 +224,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue
arg.Status,
arg.Priority,
arg.AssigneeID,
arg.ProjectID,
)
if err != nil {
return nil, err
@@ -243,6 +252,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
); err != nil {
return nil, err
}
@@ -264,9 +274,10 @@ UPDATE issue SET
assignee_id = $7,
position = COALESCE($8, position),
due_date = $9,
project_id = $10,
updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id
`
type UpdateIssueParams struct {
@@ -279,6 +290,7 @@ type UpdateIssueParams struct {
AssigneeID pgtype.UUID `json:"assignee_id"`
Position pgtype.Float8 `json:"position"`
DueDate pgtype.Timestamptz `json:"due_date"`
ProjectID pgtype.UUID `json:"project_id"`
}
func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue, error) {
@@ -292,6 +304,7 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue
arg.AssigneeID,
arg.Position,
arg.DueDate,
arg.ProjectID,
)
var i Issue
err := row.Scan(
@@ -313,6 +326,7 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
)
return i, err
}
@@ -322,7 +336,7 @@ UPDATE issue SET
status = $2,
updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id
`
type UpdateIssueStatusParams struct {
@@ -352,6 +366,7 @@ func (q *Queries) UpdateIssueStatus(ctx context.Context, arg UpdateIssueStatusPa
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
)
return i, err
}

View File

@@ -175,6 +175,7 @@ type Issue struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Number int32 `json:"number"`
ProjectID pgtype.UUID `json:"project_id"`
}
type IssueDependency struct {
@@ -234,6 +235,23 @@ type PersonalAccessToken struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Project struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
Icon pgtype.Text `json:"icon"`
Color pgtype.Text `json:"color"`
LeadType pgtype.Text `json:"lead_type"`
LeadID pgtype.UUID `json:"lead_id"`
StartDate pgtype.Date `json:"start_date"`
TargetDate pgtype.Date `json:"target_date"`
SortOrder float64 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type RuntimeUsage struct {
ID pgtype.UUID `json:"id"`
RuntimeID pgtype.UUID `json:"runtime_id"`

View File

@@ -0,0 +1,292 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: project.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createProject = `-- name: CreateProject :one
INSERT INTO project (
workspace_id, name, description, status, icon, color,
lead_type, lead_id, start_date, target_date, sort_order
) VALUES (
$1, $2, $3, $4, $5, $6,
$8, $9,
$10, $11, $7
) RETURNING id, workspace_id, name, description, status, icon, color, lead_type, lead_id, start_date, target_date, sort_order, created_at, updated_at
`
type CreateProjectParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
Icon pgtype.Text `json:"icon"`
Color pgtype.Text `json:"color"`
SortOrder float64 `json:"sort_order"`
LeadType pgtype.Text `json:"lead_type"`
LeadID pgtype.UUID `json:"lead_id"`
StartDate pgtype.Date `json:"start_date"`
TargetDate pgtype.Date `json:"target_date"`
}
func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) {
row := q.db.QueryRow(ctx, createProject,
arg.WorkspaceID,
arg.Name,
arg.Description,
arg.Status,
arg.Icon,
arg.Color,
arg.SortOrder,
arg.LeadType,
arg.LeadID,
arg.StartDate,
arg.TargetDate,
)
var i Project
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.Status,
&i.Icon,
&i.Color,
&i.LeadType,
&i.LeadID,
&i.StartDate,
&i.TargetDate,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteProject = `-- name: DeleteProject :exec
DELETE FROM project WHERE id = $1 AND workspace_id = $2
`
type DeleteProjectParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) DeleteProject(ctx context.Context, arg DeleteProjectParams) error {
_, err := q.db.Exec(ctx, deleteProject, arg.ID, arg.WorkspaceID)
return err
}
const getProject = `-- name: GetProject :one
SELECT id, workspace_id, name, description, status, icon, color, lead_type, lead_id, start_date, target_date, sort_order, created_at, updated_at FROM project
WHERE id = $1 AND workspace_id = $2
`
type GetProjectParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) GetProject(ctx context.Context, arg GetProjectParams) (Project, error) {
row := q.db.QueryRow(ctx, getProject, arg.ID, arg.WorkspaceID)
var i Project
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.Status,
&i.Icon,
&i.Color,
&i.LeadType,
&i.LeadID,
&i.StartDate,
&i.TargetDate,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getProjectProgress = `-- name: GetProjectProgress :one
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE status IN ('done', 'cancelled'))::int AS completed
FROM issue
WHERE project_id = $1 AND workspace_id = $2
`
type GetProjectProgressParams struct {
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
type GetProjectProgressRow struct {
Total int32 `json:"total"`
Completed int32 `json:"completed"`
}
func (q *Queries) GetProjectProgress(ctx context.Context, arg GetProjectProgressParams) (GetProjectProgressRow, error) {
row := q.db.QueryRow(ctx, getProjectProgress, arg.ProjectID, arg.WorkspaceID)
var i GetProjectProgressRow
err := row.Scan(&i.Total, &i.Completed)
return i, err
}
const listProjects = `-- name: ListProjects :many
SELECT id, workspace_id, name, description, status, icon, color, lead_type, lead_id, start_date, target_date, sort_order, created_at, updated_at FROM project
WHERE workspace_id = $1
AND ($2::text IS NULL OR status = $2)
ORDER BY sort_order ASC, created_at DESC
`
type ListProjectsParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Status pgtype.Text `json:"status"`
}
func (q *Queries) ListProjects(ctx context.Context, arg ListProjectsParams) ([]Project, error) {
rows, err := q.db.Query(ctx, listProjects, arg.WorkspaceID, arg.Status)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Project{}
for rows.Next() {
var i Project
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.Status,
&i.Icon,
&i.Color,
&i.LeadType,
&i.LeadID,
&i.StartDate,
&i.TargetDate,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjectsProgress = `-- name: ListProjectsProgress :many
SELECT
project_id,
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE status IN ('done', 'cancelled'))::int AS completed
FROM issue
WHERE workspace_id = $1 AND project_id IS NOT NULL
GROUP BY project_id
`
type ListProjectsProgressRow struct {
ProjectID pgtype.UUID `json:"project_id"`
Total int32 `json:"total"`
Completed int32 `json:"completed"`
}
func (q *Queries) ListProjectsProgress(ctx context.Context, workspaceID pgtype.UUID) ([]ListProjectsProgressRow, error) {
rows, err := q.db.Query(ctx, listProjectsProgress, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListProjectsProgressRow{}
for rows.Next() {
var i ListProjectsProgressRow
if err := rows.Scan(&i.ProjectID, &i.Total, &i.Completed); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateProject = `-- name: UpdateProject :one
UPDATE project SET
name = COALESCE($3, name),
description = COALESCE($4, description),
status = COALESCE($5, status),
icon = COALESCE($6, icon),
color = COALESCE($7, color),
lead_type = $8,
lead_id = $9,
start_date = $10,
target_date = $11,
sort_order = COALESCE($12, sort_order),
updated_at = now()
WHERE id = $1 AND workspace_id = $2
RETURNING id, workspace_id, name, description, status, icon, color, lead_type, lead_id, start_date, target_date, sort_order, created_at, updated_at
`
type UpdateProjectParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
Status pgtype.Text `json:"status"`
Icon pgtype.Text `json:"icon"`
Color pgtype.Text `json:"color"`
LeadType pgtype.Text `json:"lead_type"`
LeadID pgtype.UUID `json:"lead_id"`
StartDate pgtype.Date `json:"start_date"`
TargetDate pgtype.Date `json:"target_date"`
SortOrder pgtype.Float8 `json:"sort_order"`
}
func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (Project, error) {
row := q.db.QueryRow(ctx, updateProject,
arg.ID,
arg.WorkspaceID,
arg.Name,
arg.Description,
arg.Status,
arg.Icon,
arg.Color,
arg.LeadType,
arg.LeadID,
arg.StartDate,
arg.TargetDate,
arg.SortOrder,
)
var i Project
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.Status,
&i.Icon,
&i.Color,
&i.LeadType,
&i.LeadID,
&i.StartDate,
&i.TargetDate,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@@ -3,27 +3,6 @@ SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2
ORDER BY created_at ASC;
-- name: ListCommentsPaginated :many
SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2
ORDER BY created_at ASC
LIMIT $3 OFFSET $4;
-- name: ListCommentsSince :many
SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
ORDER BY created_at ASC;
-- name: ListCommentsSincePaginated :many
SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
ORDER BY created_at ASC
LIMIT $4 OFFSET $5;
-- name: CountComments :one
SELECT count(*) FROM comment
WHERE issue_id = $1 AND workspace_id = $2;
-- name: GetComment :one
SELECT * FROM comment
WHERE id = $1;

View File

@@ -32,10 +32,6 @@ UPDATE inbox_item SET archived = true
WHERE id = $1
RETURNING *;
-- name: ArchiveInboxByIssue :execrows
UPDATE inbox_item SET archived = true
WHERE workspace_id = $1 AND recipient_type = $2 AND recipient_id = $3 AND issue_id = $4 AND archived = false;
-- name: CountUnreadInbox :one
SELECT count(*) FROM inbox_item
WHERE workspace_id = $1 AND recipient_type = $2 AND recipient_id = $3 AND read = false AND archived = false;

View File

@@ -4,6 +4,7 @@ WHERE workspace_id = $1
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'))
AND (sqlc.narg('project_id')::uuid IS NULL OR project_id = sqlc.narg('project_id'))
ORDER BY position ASC, created_at DESC
LIMIT $2 OFFSET $3;
@@ -19,9 +20,9 @@ WHERE id = $1 AND workspace_id = $2;
INSERT INTO issue (
workspace_id, title, description, status, priority,
assignee_type, assignee_id, creator_type, creator_id,
parent_issue_id, position, due_date, number
parent_issue_id, position, due_date, number, project_id
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, sqlc.narg('project_id')
) RETURNING *;
-- name: GetIssueByNumber :one
@@ -38,6 +39,7 @@ UPDATE issue SET
assignee_id = sqlc.narg('assignee_id'),
position = COALESCE(sqlc.narg('position'), position),
due_date = sqlc.narg('due_date'),
project_id = sqlc.narg('project_id'),
updated_at = now()
WHERE id = $1
RETURNING *;

View File

@@ -0,0 +1,54 @@
-- name: CreateProject :one
INSERT INTO project (
workspace_id, name, description, status, icon, color,
lead_type, lead_id, start_date, target_date, sort_order
) VALUES (
$1, $2, $3, $4, $5, $6,
sqlc.narg('lead_type'), sqlc.narg('lead_id'),
sqlc.narg('start_date'), sqlc.narg('target_date'), $7
) RETURNING *;
-- name: GetProject :one
SELECT * FROM project
WHERE id = $1 AND workspace_id = $2;
-- name: ListProjects :many
SELECT * FROM project
WHERE workspace_id = $1
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
ORDER BY sort_order ASC, created_at DESC;
-- name: UpdateProject :one
UPDATE project SET
name = COALESCE(sqlc.narg('name'), name),
description = COALESCE(sqlc.narg('description'), description),
status = COALESCE(sqlc.narg('status'), status),
icon = COALESCE(sqlc.narg('icon'), icon),
color = COALESCE(sqlc.narg('color'), color),
lead_type = sqlc.narg('lead_type'),
lead_id = sqlc.narg('lead_id'),
start_date = sqlc.narg('start_date'),
target_date = sqlc.narg('target_date'),
sort_order = COALESCE(sqlc.narg('sort_order'), sort_order),
updated_at = now()
WHERE id = $1 AND workspace_id = $2
RETURNING *;
-- name: DeleteProject :exec
DELETE FROM project WHERE id = $1 AND workspace_id = $2;
-- name: GetProjectProgress :one
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE status IN ('done', 'cancelled'))::int AS completed
FROM issue
WHERE project_id = $1 AND workspace_id = $2;
-- name: ListProjectsProgress :many
SELECT
project_id,
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE status IN ('done', 'cancelled'))::int AS completed
FROM issue
WHERE workspace_id = $1 AND project_id IS NOT NULL
GROUP BY project_id;

View File

@@ -58,6 +58,11 @@ const (
EventSkillUpdated = "skill:updated"
EventSkillDeleted = "skill:deleted"
// Project events
EventProjectCreated = "project:created"
EventProjectUpdated = "project:updated"
EventProjectDeleted = "project:deleted"
// Daemon events
EventDaemonHeartbeat = "daemon:heartbeat"
EventDaemonRegister = "daemon:register"