mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-04 04:55:41 +02:00
Compare commits
3 Commits
agent/lamb
...
agent/j/50
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4eb30dd783 | ||
|
|
f40f37ced1 | ||
|
|
bdf79e868b |
@@ -10,6 +10,7 @@
|
||||
"members-roles",
|
||||
"issues",
|
||||
"comments",
|
||||
"project-resources",
|
||||
"---Agents---",
|
||||
"agents",
|
||||
"agents-create",
|
||||
|
||||
142
apps/docs/content/docs/project-resources.mdx
Normal file
142
apps/docs/content/docs/project-resources.mdx
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: Project Resources
|
||||
description: Attach typed pointers (Git repos today, more later) to a project so agents can pick them up as scoped context.
|
||||
---
|
||||
|
||||
A **Project Resource** is a typed pointer — a Git repo URL today, a Notion page or document link tomorrow — attached to a [project](/workspaces). When an [agent](/agents) runs against an issue inside that project, the daemon automatically writes the project's resource list into the agent's working directory and into its [meta-skill](/skills) prompt.
|
||||
|
||||
The result: the agent knows which repo to check out, which docs are the "primary references" for this project, without anyone copy-pasting context into the issue body.
|
||||
|
||||
## Mental model
|
||||
|
||||
A project is no longer just a label. It is a small **resource container**:
|
||||
|
||||
- A project has 0..N **resources**.
|
||||
- A resource has a `resource_type` (e.g. `github_repo`) and a `resource_ref` (a JSON payload typed by `resource_type`).
|
||||
- New resource types add a string + a handler. **No schema migration. No frontend rewrite.**
|
||||
|
||||
This shape is intentional — it's the same pattern Multica already uses for agent providers: a `type` discriminator and a typed payload. It keeps the schema stable so adding "Notion page", "Google Doc", "uploaded file", or "external URL" later is a small, additive change.
|
||||
|
||||
## Today: `github_repo`
|
||||
|
||||
The first resource type ships ready to use:
|
||||
|
||||
```json
|
||||
{
|
||||
"resource_type": "github_repo",
|
||||
"resource_ref": {
|
||||
"url": "https://github.com/owner/repo",
|
||||
"default_branch_hint": "main"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`default_branch_hint` is optional — if present, the daemon surfaces it in the meta-skill so the agent knows which branch to base its work on.
|
||||
|
||||
## Attaching repos at project creation
|
||||
|
||||
In the **Web** or **Desktop** app, opening *New project* now shows a **Repos** pill alongside Status / Priority / Lead. Selecting workspace-bound repos (or pasting an ad-hoc URL) attaches them as `github_repo` resources the moment the project is created.
|
||||
|
||||
From the **CLI**:
|
||||
|
||||
```bash
|
||||
# Create + attach in one shot. The server attaches resources in the same
|
||||
# transaction as the project create — invalid resources roll back the whole
|
||||
# operation, so you never end up with a project that has half its resources.
|
||||
multica project create \
|
||||
--title "Agent UX 2026" \
|
||||
--repo https://github.com/multica-ai/multica
|
||||
|
||||
# Manage resources later
|
||||
multica project resource list <project-id>
|
||||
multica project resource add <project-id> --type github_repo --url <url>
|
||||
multica project resource remove <project-id> <resource-id>
|
||||
|
||||
# Generic escape hatch for any resource_type the server understands —
|
||||
# no CLI change needed when a new type ships:
|
||||
multica project resource add <project-id> \
|
||||
--type notion_page \
|
||||
--ref '{"page_id":"…","title":"…"}'
|
||||
```
|
||||
|
||||
`--repo` may be repeated; each value is attached as a separate `github_repo` resource.
|
||||
|
||||
## What the agent sees at runtime
|
||||
|
||||
When the daemon spawns an agent for an issue inside a project, two things happen:
|
||||
|
||||
### 1. `.multica/project/resources.json`
|
||||
|
||||
A structured pass-through of the API response, written into the agent's working directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"project_id": "…",
|
||||
"project_title": "Agent UX 2026",
|
||||
"resources": [
|
||||
{
|
||||
"id": "…",
|
||||
"resource_type": "github_repo",
|
||||
"resource_ref": {
|
||||
"url": "https://github.com/multica-ai/multica",
|
||||
"default_branch_hint": "main"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Skills, helper scripts, or the agent itself can parse this file when they need the *exact* set of resources for the run.
|
||||
|
||||
### 2. A "Project Context" section in the meta-skill prompt
|
||||
|
||||
The agent's `CLAUDE.md` / `AGENTS.md` (depending on provider) now includes a human-readable summary:
|
||||
|
||||
```
|
||||
## Project Context
|
||||
|
||||
This issue belongs to **Agent UX 2026**.
|
||||
|
||||
Project resources (also written to `.multica/project/resources.json`):
|
||||
|
||||
- **GitHub repo**: https://github.com/multica-ai/multica (default branch: `main`)
|
||||
|
||||
Resources are pointers — open them only when relevant to the task. For
|
||||
`github_repo` resources, use `multica repo checkout <url>` to fetch the code.
|
||||
```
|
||||
|
||||
The text is intentionally minimal. The full payload is on disk; the prompt only orients the agent so it knows the project exists and what's attached.
|
||||
|
||||
### Failure mode
|
||||
|
||||
Resource fetch is **best-effort**. If the API call fails, the project section is omitted from the prompt and the file is not written, but the task still starts. Agents never block on missing project context.
|
||||
|
||||
## Adding a new resource type
|
||||
|
||||
The whole point of the abstraction is that new types are cheap. The full path:
|
||||
|
||||
1. **Server validator** (`server/internal/handler/project_resource.go`) — add a case in `validateAndNormalizeResourceRef` that parses and normalizes the new payload.
|
||||
2. **Daemon meta-skill formatter** (`server/internal/daemon/execenv/runtime_config.go`) — add a case in `formatProjectResource` so the agent prompt renders the new type as a readable bullet.
|
||||
3. **TypeScript types** (`packages/core/types/project.ts`) — extend `ProjectResourceType` and add the payload interface.
|
||||
4. **UI renderer** (`packages/views/projects/components/project-resources-section.tsx`) — add a case in `ResourceRow` for the new type.
|
||||
|
||||
There is **no schema migration**, no new sqlc query, no new endpoint, **and no CLI change** — the CLI's generic `--ref '<json>'` flag accepts any payload the validator understands, so day-one support for a new type is purely the four steps above. (You may *optionally* add a per-type CLI shortcut later; not required.)
|
||||
|
||||
The same `project_resource` table and the same three CRUD calls handle every type.
|
||||
|
||||
## Workspace repos vs. project repos
|
||||
|
||||
The repo list shown to the agent (`## Repositories` block in `CLAUDE.md` / `AGENTS.md`) is chosen by the daemon claim handler with this precedence:
|
||||
|
||||
- **Project has at least one `github_repo` resource** → only those repos are surfaced to the agent. Workspace-bound repos are intentionally hidden so the agent doesn't have to guess which one belongs to this issue.
|
||||
- **Project has no `github_repo` resources (or the issue isn't in a project)** → fall back to the workspace's repo list as before.
|
||||
|
||||
This keeps the agent's working set tight: when a project is explicit about its repos, that's the authoritative answer. The structured resource list at `.multica/project/resources.json` always carries the full set, so a skill that wants to inspect everything still can.
|
||||
|
||||
## What's intentionally **not** in scope here
|
||||
|
||||
- **Cross-project sharing.** Each resource lives on exactly one project today.
|
||||
- **Per-skill resource scoping.** All resources are visible to every skill on the agent's run; type-aware filtering is a follow-up.
|
||||
- **Caching / sync.** `github_repo` is just metadata — checkout still happens via `multica repo checkout` on demand. Cached document text for Notion / Google Docs will arrive with those types.
|
||||
|
||||
These are deliberate omissions — the goal of the first cut is to validate the abstraction with the smallest set of moving parts.
|
||||
@@ -55,6 +55,9 @@ import type {
|
||||
CreateProjectRequest,
|
||||
UpdateProjectRequest,
|
||||
ListProjectsResponse,
|
||||
ProjectResource,
|
||||
CreateProjectResourceRequest,
|
||||
ListProjectResourcesResponse,
|
||||
Label,
|
||||
CreateLabelRequest,
|
||||
UpdateLabelRequest,
|
||||
@@ -1074,6 +1077,32 @@ export class ApiClient {
|
||||
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Project resources
|
||||
async listProjectResources(
|
||||
projectId: string,
|
||||
): Promise<ListProjectResourcesResponse> {
|
||||
return this.fetch(`/api/projects/${projectId}/resources`);
|
||||
}
|
||||
|
||||
async createProjectResource(
|
||||
projectId: string,
|
||||
data: CreateProjectResourceRequest,
|
||||
): Promise<ProjectResource> {
|
||||
return this.fetch(`/api/projects/${projectId}/resources`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProjectResource(
|
||||
projectId: string,
|
||||
resourceId: string,
|
||||
): Promise<void> {
|
||||
await this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
// Labels
|
||||
async listLabels(): Promise<ListLabelsResponse> {
|
||||
return this.fetch(`/api/labels`);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export { projectKeys, projectListOptions, projectDetailOptions } from "./queries";
|
||||
export { useCreateProject, useUpdateProject, useDeleteProject } from "./mutations";
|
||||
export { useProjectDraftStore } from "./draft-store";
|
||||
export {
|
||||
projectResourceKeys,
|
||||
projectResourcesOptions,
|
||||
useCreateProjectResource,
|
||||
useDeleteProjectResource,
|
||||
} from "./resource-queries";
|
||||
|
||||
87
packages/core/projects/resource-queries.ts
Normal file
87
packages/core/projects/resource-queries.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { projectKeys } from "./queries";
|
||||
import type {
|
||||
CreateProjectResourceRequest,
|
||||
ListProjectResourcesResponse,
|
||||
ProjectResource,
|
||||
} from "../types";
|
||||
|
||||
export const projectResourceKeys = {
|
||||
list: (wsId: string, projectId: string) =>
|
||||
[...projectKeys.detail(wsId, projectId), "resources"] as const,
|
||||
};
|
||||
|
||||
export function projectResourcesOptions(wsId: string, projectId: string) {
|
||||
return queryOptions({
|
||||
queryKey: projectResourceKeys.list(wsId, projectId),
|
||||
queryFn: () => api.listProjectResources(projectId),
|
||||
select: (data) => data.resources,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateProjectResource(wsId: string, projectId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateProjectResourceRequest) =>
|
||||
api.createProjectResource(projectId, data),
|
||||
onSuccess: (created) => {
|
||||
qc.setQueryData<ListProjectResourcesResponse>(
|
||||
projectResourceKeys.list(wsId, projectId),
|
||||
(old) =>
|
||||
old && !old.resources.some((r) => r.id === created.id)
|
||||
? {
|
||||
...old,
|
||||
resources: [...old.resources, created],
|
||||
total: old.total + 1,
|
||||
}
|
||||
: old,
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: projectResourceKeys.list(wsId, projectId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteProjectResource(wsId: string, projectId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (resourceId: string) =>
|
||||
api.deleteProjectResource(projectId, resourceId),
|
||||
onMutate: async (resourceId) => {
|
||||
await qc.cancelQueries({
|
||||
queryKey: projectResourceKeys.list(wsId, projectId),
|
||||
});
|
||||
const prev = qc.getQueryData<ListProjectResourcesResponse>(
|
||||
projectResourceKeys.list(wsId, projectId),
|
||||
);
|
||||
qc.setQueryData<ListProjectResourcesResponse>(
|
||||
projectResourceKeys.list(wsId, projectId),
|
||||
(old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
resources: old.resources.filter(
|
||||
(r: ProjectResource) => r.id !== resourceId,
|
||||
),
|
||||
total: old.total - 1,
|
||||
}
|
||||
: old,
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prev) {
|
||||
qc.setQueryData(projectResourceKeys.list(wsId, projectId), ctx.prev);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: projectResourceKeys.list(wsId, projectId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -48,7 +48,19 @@ export type * from "./api";
|
||||
export type { Attachment } from "./attachment";
|
||||
export type { ChatSession, ChatMessage, ChatPendingTask, PendingChatTaskItem, PendingChatTasksResponse, SendChatMessageResponse } from "./chat";
|
||||
export type { StorageAdapter } from "./storage";
|
||||
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
|
||||
export type {
|
||||
Project,
|
||||
ProjectStatus,
|
||||
ProjectPriority,
|
||||
CreateProjectRequest,
|
||||
UpdateProjectRequest,
|
||||
ListProjectsResponse,
|
||||
ProjectResource,
|
||||
ProjectResourceType,
|
||||
GithubRepoResourceRef,
|
||||
CreateProjectResourceRequest,
|
||||
ListProjectResourcesResponse,
|
||||
} from "./project";
|
||||
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
|
||||
export type {
|
||||
Autopilot,
|
||||
|
||||
@@ -26,6 +26,9 @@ export interface CreateProjectRequest {
|
||||
priority?: ProjectPriority;
|
||||
lead_type?: "member" | "agent";
|
||||
lead_id?: string;
|
||||
// Resources to attach in the same transaction as the project. Server returns
|
||||
// 4xx (and rolls back) if any one is invalid or duplicate.
|
||||
resources?: CreateProjectResourceRequest[];
|
||||
}
|
||||
|
||||
export interface UpdateProjectRequest {
|
||||
@@ -42,3 +45,39 @@ export interface ListProjectsResponse {
|
||||
projects: Project[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ProjectResource is a typed pointer from a project to an external resource.
|
||||
// The resource_ref shape depends on resource_type (e.g. github_repo carries
|
||||
// { url, default_branch_hint? }). New types add a case in
|
||||
// validateAndNormalizeResourceRef on the server and a renderer in the UI;
|
||||
// no schema or type changes required.
|
||||
export type ProjectResourceType = "github_repo";
|
||||
|
||||
export interface GithubRepoResourceRef {
|
||||
url: string;
|
||||
default_branch_hint?: string;
|
||||
}
|
||||
|
||||
export interface ProjectResource {
|
||||
id: string;
|
||||
project_id: string;
|
||||
workspace_id: string;
|
||||
resource_type: ProjectResourceType;
|
||||
resource_ref: GithubRepoResourceRef | Record<string, unknown>;
|
||||
label: string | null;
|
||||
position: number;
|
||||
created_at: string;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
export interface CreateProjectResourceRequest {
|
||||
resource_type: ProjectResourceType;
|
||||
resource_ref: GithubRepoResourceRef | Record<string, unknown>;
|
||||
label?: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface ListProjectResourcesResponse {
|
||||
resources: ProjectResource[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react";
|
||||
import { ChevronRight, FolderGit, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCreateProject } from "@multica/core/projects/mutations";
|
||||
import { useProjectDraftStore } from "@multica/core/projects";
|
||||
@@ -78,6 +78,13 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
// Repos selected to attach as github_repo resources after the project is
|
||||
// created. Stored as URLs (not full ProjectResource rows) — they're not
|
||||
// persisted until handleSubmit fires the createProjectResource calls.
|
||||
const [selectedRepos, setSelectedRepos] = useState<string[]>([]);
|
||||
const [repoPopoverOpen, setRepoPopoverOpen] = useState(false);
|
||||
const [customRepoUrl, setCustomRepoUrl] = useState("");
|
||||
const workspaceRepos = workspace?.repos ?? [];
|
||||
|
||||
// Sync field changes to draft store
|
||||
const updateTitle = (v: string) => { setTitle(v); setDraft({ title: v }); };
|
||||
@@ -114,6 +121,14 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
priority,
|
||||
lead_type: leadType,
|
||||
lead_id: leadId,
|
||||
// Server attaches these in the same transaction as the project.
|
||||
resources:
|
||||
selectedRepos.length > 0
|
||||
? selectedRepos.map((url) => ({
|
||||
resource_type: "github_repo" as const,
|
||||
resource_ref: { url },
|
||||
}))
|
||||
: undefined,
|
||||
});
|
||||
clearDraft();
|
||||
onClose();
|
||||
@@ -126,6 +141,19 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRepo = (url: string) => {
|
||||
setSelectedRepos((prev) =>
|
||||
prev.includes(url) ? prev.filter((u) => u !== url) : [...prev, url],
|
||||
);
|
||||
};
|
||||
|
||||
const addCustomRepo = () => {
|
||||
const url = customRepoUrl.trim();
|
||||
if (!url) return;
|
||||
setSelectedRepos((prev) => (prev.includes(url) ? prev : [...prev, url]));
|
||||
setCustomRepoUrl("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent
|
||||
@@ -353,6 +381,105 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover open={repoPopoverOpen} onOpenChange={setRepoPopoverOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<PillButton>
|
||||
<FolderGit className="size-3" />
|
||||
<span>
|
||||
{selectedRepos.length === 0
|
||||
? "Repos"
|
||||
: `${selectedRepos.length} repo${selectedRepos.length === 1 ? "" : "s"}`}
|
||||
</span>
|
||||
</PillButton>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-72 p-2 space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Attach GitHub repos to this project
|
||||
</div>
|
||||
{workspaceRepos.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{workspaceRepos.map((repo) => {
|
||||
const checked = selectedRepos.includes(repo.url);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={repo.url}
|
||||
onClick={() => toggleRepo(repo.url)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-accent transition-colors",
|
||||
checked && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
readOnly
|
||||
className="size-3.5"
|
||||
/>
|
||||
<FolderGit className="size-3.5" />
|
||||
<span className="truncate flex-1 text-left">{repo.url}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No workspace-level repos yet. Paste a URL below to attach one
|
||||
ad-hoc.
|
||||
</p>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
addCustomRepo();
|
||||
}}
|
||||
className="flex items-center gap-1.5 pt-1 border-t"
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
value={customRepoUrl}
|
||||
onChange={(e) => setCustomRepoUrl(e.target.value)}
|
||||
placeholder="https://github.com/owner/repo"
|
||||
className="flex-1 bg-transparent text-xs px-2 py-1 outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={!customRepoUrl.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
{selectedRepos.length > 0 && (
|
||||
<div className="space-y-1 pt-1 border-t">
|
||||
<div className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Selected
|
||||
</div>
|
||||
{selectedRepos.map((url) => (
|
||||
<div
|
||||
key={url}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<FolderGit className="size-3 text-muted-foreground" />
|
||||
<span className="truncate flex-1">{url}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleRepo(url)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { AppLink, useNavigation } from "../../navigation";
|
||||
import { TitleEditor, ContentEditor, type ContentEditorRef } from "../../editor";
|
||||
import { PriorityIcon } from "../../issues/components/priority-icon";
|
||||
import { ProjectResourcesSection } from "./project-resources-section";
|
||||
import { IssuesHeader } from "../../issues/components/issues-header";
|
||||
import { BoardView } from "../../issues/components/board-view";
|
||||
import { ListView } from "../../issues/components/list-view";
|
||||
@@ -494,6 +495,9 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
/>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
<ProjectResourcesSection projectId={projectId} />
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
240
packages/views/projects/components/project-resources-section.tsx
Normal file
240
packages/views/projects/components/project-resources-section.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, FolderGit, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
projectResourcesOptions,
|
||||
useCreateProjectResource,
|
||||
useDeleteProjectResource,
|
||||
} from "@multica/core/projects";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useCurrentWorkspace } from "@multica/core/paths";
|
||||
import type {
|
||||
GithubRepoResourceRef,
|
||||
ProjectResource,
|
||||
} from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
|
||||
// Project Resources sidebar section.
|
||||
//
|
||||
// Today only renders github_repo, but the rendering layer is type-dispatched
|
||||
// so adding a new type means: (1) extend the API validator, (2) add a render
|
||||
// case here. No changes to the schema or query layer.
|
||||
export function ProjectResourcesSection({ projectId }: { projectId: string }) {
|
||||
const wsId = useWorkspaceId();
|
||||
const workspace = useCurrentWorkspace();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
|
||||
const { data: resources = [] } = useQuery(
|
||||
projectResourcesOptions(wsId, projectId),
|
||||
);
|
||||
const createResource = useCreateProjectResource(wsId, projectId);
|
||||
const deleteResource = useDeleteProjectResource(wsId, projectId);
|
||||
|
||||
const attachedUrls = new Set(
|
||||
resources
|
||||
.filter((r) => r.resource_type === "github_repo")
|
||||
.map((r) => (r.resource_ref as GithubRepoResourceRef).url),
|
||||
);
|
||||
|
||||
const handleAttach = async (url: string) => {
|
||||
try {
|
||||
await createResource.mutateAsync({
|
||||
resource_type: "github_repo",
|
||||
resource_ref: { url },
|
||||
});
|
||||
toast.success("Repository attached");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to attach";
|
||||
toast.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (resource: ProjectResource) => {
|
||||
try {
|
||||
await deleteResource.mutateAsync(resource.id);
|
||||
toast.success("Resource removed");
|
||||
} catch {
|
||||
toast.error("Failed to remove resource");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className={`flex w-full items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors mb-2 hover:bg-accent/70 ${open ? "" : "text-muted-foreground hover:text-foreground"}`}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
Resources
|
||||
<ChevronRight
|
||||
className={`!size-3 shrink-0 stroke-[2.5] text-muted-foreground transition-transform ${open ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="pl-2 space-y-1.5">
|
||||
{resources.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No resources attached.
|
||||
</p>
|
||||
)}
|
||||
{resources.map((resource) => (
|
||||
<ResourceRow
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
onRemove={() => handleRemove(resource)}
|
||||
/>
|
||||
))}
|
||||
<Popover open={addOpen} onOpenChange={setAddOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Add resource
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-72 p-2 space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Attach a GitHub repo
|
||||
</div>
|
||||
{workspace?.repos && workspace.repos.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{workspace.repos.map((repo) => {
|
||||
const isAttached = attachedUrls.has(repo.url);
|
||||
return (
|
||||
<button
|
||||
key={repo.url}
|
||||
type="button"
|
||||
disabled={isAttached || createResource.isPending}
|
||||
onClick={async () => {
|
||||
await handleAttach(repo.url);
|
||||
setAddOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs text-left hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<FolderGit className="size-3.5" />
|
||||
<span className="truncate flex-1">{repo.url}</span>
|
||||
{isAttached && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
attached
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<CustomRepoForm
|
||||
onSubmit={async (url) => {
|
||||
await handleAttach(url);
|
||||
setAddOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceRow({
|
||||
resource,
|
||||
onRemove,
|
||||
}: {
|
||||
resource: ProjectResource;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
if (resource.resource_type === "github_repo") {
|
||||
const ref = resource.resource_ref as GithubRepoResourceRef;
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs group">
|
||||
<FolderGit className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<a
|
||||
href={ref.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate flex-1 hover:underline"
|
||||
>
|
||||
{resource.label || ref.url}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity rounded-sm p-0.5 hover:bg-accent"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="size-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="truncate flex-1">
|
||||
{resource.label || resource.resource_type}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="rounded-sm p-0.5 hover:bg-accent"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomRepoForm({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: (url: string) => Promise<void> | void;
|
||||
}) {
|
||||
const [url, setUrl] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const handle = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(trimmed);
|
||||
setUrl("");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<form onSubmit={handle} className="flex items-center gap-1.5 pt-1 border-t">
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://github.com/owner/repo"
|
||||
className="flex-1 bg-transparent text-xs px-2 py-1 outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={!url.trim() || submitting}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -58,6 +59,32 @@ var projectStatusCmd = &cobra.Command{
|
||||
RunE: runProjectStatus,
|
||||
}
|
||||
|
||||
var projectResourceCmd = &cobra.Command{
|
||||
Use: "resource",
|
||||
Short: "Manage resources attached to a project",
|
||||
}
|
||||
|
||||
var projectResourceListCmd = &cobra.Command{
|
||||
Use: "list <project-id>",
|
||||
Short: "List resources attached to a project",
|
||||
Args: exactArgs(1),
|
||||
RunE: runProjectResourceList,
|
||||
}
|
||||
|
||||
var projectResourceAddCmd = &cobra.Command{
|
||||
Use: "add <project-id>",
|
||||
Short: "Attach a resource to a project (e.g. --type github_repo --url <url>)",
|
||||
Args: exactArgs(1),
|
||||
RunE: runProjectResourceAdd,
|
||||
}
|
||||
|
||||
var projectResourceRemoveCmd = &cobra.Command{
|
||||
Use: "remove <project-id> <resource-id>",
|
||||
Short: "Detach a resource from a project",
|
||||
Args: exactArgs(2),
|
||||
RunE: runProjectResourceRemove,
|
||||
}
|
||||
|
||||
var validProjectStatuses = []string{
|
||||
"planned", "in_progress", "paused", "completed", "cancelled",
|
||||
}
|
||||
@@ -69,6 +96,11 @@ func init() {
|
||||
projectCmd.AddCommand(projectUpdateCmd)
|
||||
projectCmd.AddCommand(projectDeleteCmd)
|
||||
projectCmd.AddCommand(projectStatusCmd)
|
||||
projectCmd.AddCommand(projectResourceCmd)
|
||||
|
||||
projectResourceCmd.AddCommand(projectResourceListCmd)
|
||||
projectResourceCmd.AddCommand(projectResourceAddCmd)
|
||||
projectResourceCmd.AddCommand(projectResourceRemoveCmd)
|
||||
|
||||
// project list
|
||||
projectListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
@@ -83,8 +115,25 @@ func init() {
|
||||
projectCreateCmd.Flags().String("status", "", "Project status")
|
||||
projectCreateCmd.Flags().String("icon", "", "Project icon (emoji)")
|
||||
projectCreateCmd.Flags().String("lead", "", "Lead name (member or agent)")
|
||||
projectCreateCmd.Flags().StringArray("repo", nil, "Attach a github_repo resource by URL (may be repeated)")
|
||||
projectCreateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// project resource list
|
||||
projectResourceListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
// project resource add — generic shape: any --type with a JSON --ref payload
|
||||
// works without further CLI changes. github_repo is supported via the
|
||||
// dedicated --url / --default-branch-hint shortcuts as a convenience.
|
||||
projectResourceAddCmd.Flags().String("type", "github_repo", "Resource type (e.g. github_repo, notion_page — see docs)")
|
||||
projectResourceAddCmd.Flags().String("url", "", "Shortcut: the repo URL (only used when --type github_repo)")
|
||||
projectResourceAddCmd.Flags().String("default-branch-hint", "", "Shortcut: optional default branch hint (only used when --type github_repo)")
|
||||
projectResourceAddCmd.Flags().String("ref", "", "Generic JSON resource_ref payload — overrides the per-type shortcuts when set")
|
||||
projectResourceAddCmd.Flags().String("label", "", "Optional human-readable label")
|
||||
projectResourceAddCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// project resource remove
|
||||
projectResourceRemoveCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
// project update
|
||||
projectUpdateCmd.Flags().String("title", "", "New title")
|
||||
projectUpdateCmd.Flags().String("description", "", "New description")
|
||||
@@ -227,6 +276,27 @@ func runProjectCreate(cmd *cobra.Command, _ []string) error {
|
||||
body["lead_id"] = aID
|
||||
}
|
||||
|
||||
// Bundle resources into the create payload so the server attaches them in
|
||||
// the same transaction; this avoids leaving a half-attached project on
|
||||
// failure.
|
||||
repos, _ := cmd.Flags().GetStringArray("repo")
|
||||
if len(repos) > 0 {
|
||||
resources := make([]map[string]any, 0, len(repos))
|
||||
for _, repoURL := range repos {
|
||||
repoURL = strings.TrimSpace(repoURL)
|
||||
if repoURL == "" {
|
||||
continue
|
||||
}
|
||||
resources = append(resources, map[string]any{
|
||||
"resource_type": "github_repo",
|
||||
"resource_ref": map[string]any{"url": repoURL},
|
||||
})
|
||||
}
|
||||
if len(resources) > 0 {
|
||||
body["resources"] = resources
|
||||
}
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/projects", body, &result); err != nil {
|
||||
return fmt.Errorf("create project: %w", err)
|
||||
@@ -362,6 +432,148 @@ func runProjectStatus(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project resource commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func runProjectResourceList(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.GetJSON(ctx, "/api/projects/"+args[0]+"/resources", &result); err != nil {
|
||||
return fmt.Errorf("list project resources: %w", err)
|
||||
}
|
||||
resourcesRaw, _ := result["resources"].([]any)
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, resourcesRaw)
|
||||
}
|
||||
|
||||
headers := []string{"ID", "TYPE", "REF", "LABEL"}
|
||||
rows := make([][]string, 0, len(resourcesRaw))
|
||||
for _, raw := range resourcesRaw {
|
||||
r, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
truncateID(strVal(r, "id")),
|
||||
strVal(r, "resource_type"),
|
||||
summarizeResourceRef(r["resource_ref"]),
|
||||
strVal(r, "label"),
|
||||
})
|
||||
}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runProjectResourceAdd(cmd *cobra.Command, args []string) error {
|
||||
resourceType, _ := cmd.Flags().GetString("type")
|
||||
resourceType = strings.TrimSpace(resourceType)
|
||||
if resourceType == "" {
|
||||
return fmt.Errorf("--type is required")
|
||||
}
|
||||
|
||||
body := map[string]any{"resource_type": resourceType}
|
||||
|
||||
// --ref takes precedence: any new resource type works through this path
|
||||
// without a CLI change. Per-type shortcuts (--url etc.) only apply when
|
||||
// --ref is empty.
|
||||
if rawRef, _ := cmd.Flags().GetString("ref"); strings.TrimSpace(rawRef) != "" {
|
||||
var ref any
|
||||
if err := json.Unmarshal([]byte(rawRef), &ref); err != nil {
|
||||
return fmt.Errorf("--ref is not valid JSON: %w", err)
|
||||
}
|
||||
body["resource_ref"] = ref
|
||||
} else {
|
||||
switch resourceType {
|
||||
case "github_repo":
|
||||
urlVal, _ := cmd.Flags().GetString("url")
|
||||
urlVal = strings.TrimSpace(urlVal)
|
||||
if urlVal == "" {
|
||||
return fmt.Errorf("github_repo requires --url (or pass a JSON payload via --ref)")
|
||||
}
|
||||
ref := map[string]any{"url": urlVal}
|
||||
if hint, _ := cmd.Flags().GetString("default-branch-hint"); hint != "" {
|
||||
ref["default_branch_hint"] = strings.TrimSpace(hint)
|
||||
}
|
||||
body["resource_ref"] = ref
|
||||
default:
|
||||
return fmt.Errorf("type %q has no built-in CLI shortcut; pass the payload via --ref '<json>'", resourceType)
|
||||
}
|
||||
}
|
||||
|
||||
if label, _ := cmd.Flags().GetString("label"); label != "" {
|
||||
body["label"] = label
|
||||
}
|
||||
|
||||
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/projects/"+args[0]+"/resources", body, &result); err != nil {
|
||||
return fmt.Errorf("add project resource: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "table" {
|
||||
headers := []string{"ID", "TYPE", "REF"}
|
||||
rows := [][]string{{
|
||||
truncateID(strVal(result, "id")),
|
||||
strVal(result, "resource_type"),
|
||||
summarizeResourceRef(result["resource_ref"]),
|
||||
}}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
|
||||
func runProjectResourceRemove(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/projects/"+args[0]+"/resources/"+args[1]); err != nil {
|
||||
return fmt.Errorf("remove project resource: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Resource %s removed from project %s.\n", truncateID(args[1]), truncateID(args[0]))
|
||||
return nil
|
||||
}
|
||||
|
||||
// summarizeResourceRef extracts the most useful single string from a
|
||||
// resource_ref object — for github_repo this is the URL.
|
||||
func summarizeResourceRef(raw any) string {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if u, ok := m["url"].(string); ok && u != "" {
|
||||
return u
|
||||
}
|
||||
if data, err := json.Marshal(m); err == nil {
|
||||
return string(data)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -349,6 +349,9 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
r.Get("/", h.GetProject)
|
||||
r.Put("/", h.UpdateProject)
|
||||
r.Delete("/", h.DeleteProject)
|
||||
r.Get("/resources", h.ListProjectResources)
|
||||
r.Post("/resources", h.CreateProjectResource)
|
||||
r.Delete("/resources/{resourceId}", h.DeleteProjectResource)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1168,6 +1168,9 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
|
||||
AgentInstructions: instructions,
|
||||
AgentSkills: convertSkillsForEnv(skills),
|
||||
Repos: convertReposForEnv(task.Repos),
|
||||
ProjectID: task.ProjectID,
|
||||
ProjectTitle: task.ProjectTitle,
|
||||
ProjectResources: convertProjectResourcesForEnv(task.ProjectResources),
|
||||
ChatSessionID: task.ChatSessionID,
|
||||
AutopilotRunID: task.AutopilotRunID,
|
||||
AutopilotID: task.AutopilotID,
|
||||
@@ -1688,6 +1691,22 @@ func convertReposForEnv(repos []RepoData) []execenv.RepoContextForEnv {
|
||||
return result
|
||||
}
|
||||
|
||||
func convertProjectResourcesForEnv(resources []ProjectResourceData) []execenv.ProjectResourceForEnv {
|
||||
if len(resources) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]execenv.ProjectResourceForEnv, len(resources))
|
||||
for i, r := range resources {
|
||||
result[i] = execenv.ProjectResourceForEnv{
|
||||
ID: r.ID,
|
||||
ResourceType: r.ResourceType,
|
||||
ResourceRef: r.ResourceRef,
|
||||
Label: r.Label,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// shortID returns the first 8 characters of an ID for readable logs.
|
||||
func shortID(id string) string {
|
||||
if len(id) <= 8 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package execenv
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -45,9 +46,76 @@ func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Project resources are best-effort: a write failure logs but does not
|
||||
// block task startup. Missing resources surface as the agent simply not
|
||||
// seeing the file, which matches the "scoped, not dumped" design (the
|
||||
// meta skill content always lists what the agent should expect).
|
||||
if err := writeProjectResources(workDir, ctx); err != nil {
|
||||
// Caller logs warnings; avoid noisy returns for non-fatal context.
|
||||
return fmt.Errorf("write project resources: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// projectResourceFile is the on-disk JSON written into the agent's working
|
||||
// directory. Schema is intentionally a thin pass-through of the API response
|
||||
// so consumers (skills, future tooling) don't need a separate parser.
|
||||
type projectResourceFile struct {
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
ProjectTitle string `json:"project_title,omitempty"`
|
||||
Resources []ProjectResourceForEnv `json:"resources"`
|
||||
}
|
||||
|
||||
// MarshalJSON renders the resource_ref field as raw JSON instead of a base64
|
||||
// blob. The struct's other fields are simple strings.
|
||||
func (p ProjectResourceForEnv) MarshalJSON() ([]byte, error) {
|
||||
type alias struct {
|
||||
ID string `json:"id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceRef json.RawMessage `json:"resource_ref"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
ref := p.ResourceRef
|
||||
if len(ref) == 0 {
|
||||
ref = json.RawMessage("{}")
|
||||
}
|
||||
return json.Marshal(alias{
|
||||
ID: p.ID,
|
||||
ResourceType: p.ResourceType,
|
||||
ResourceRef: ref,
|
||||
Label: p.Label,
|
||||
})
|
||||
}
|
||||
|
||||
// writeProjectResources writes .multica/project/resources.json into the
|
||||
// working directory when the task carries project context. The file is
|
||||
// always written when a project is attached (even with zero resources) so
|
||||
// agents can rely on its presence as a signal that a project exists.
|
||||
func writeProjectResources(workDir string, ctx TaskContextForEnv) error {
|
||||
if ctx.ProjectID == "" && len(ctx.ProjectResources) == 0 {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Join(workDir, ".multica", "project")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
resources := ctx.ProjectResources
|
||||
if resources == nil {
|
||||
resources = []ProjectResourceForEnv{}
|
||||
}
|
||||
payload := projectResourceFile{
|
||||
ProjectID: ctx.ProjectID,
|
||||
ProjectTitle: ctx.ProjectTitle,
|
||||
Resources: resources,
|
||||
}
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, "resources.json"), data, 0o644)
|
||||
}
|
||||
|
||||
// resolveSkillsDir returns the directory where skills should be written
|
||||
// based on the agent provider.
|
||||
func resolveSkillsDir(workDir, provider string) (string, error) {
|
||||
|
||||
@@ -18,6 +18,18 @@ type RepoContextForEnv struct {
|
||||
Description string // human-readable description
|
||||
}
|
||||
|
||||
// ProjectResourceForEnv describes a single resource attached to the issue's
|
||||
// project. The resource_ref payload is type-specific JSON; the agent reads
|
||||
// resources.json on disk for the full structure. This struct only carries
|
||||
// fields the meta-skill template needs to render a human-readable summary
|
||||
// (URL for github_repo, generic label otherwise).
|
||||
type ProjectResourceForEnv struct {
|
||||
ID string // server-assigned UUID
|
||||
ResourceType string // e.g. "github_repo"
|
||||
ResourceRef json.RawMessage // raw JSONB payload from the API
|
||||
Label string // optional user-supplied label
|
||||
}
|
||||
|
||||
// PrepareParams holds all inputs needed to set up an execution environment.
|
||||
type PrepareParams struct {
|
||||
WorkspacesRoot string // base path for all envs (e.g., ~/multica_workspaces)
|
||||
@@ -37,8 +49,11 @@ type TaskContextForEnv struct {
|
||||
AgentName string
|
||||
AgentInstructions string // agent identity/persona instructions, injected into CLAUDE.md
|
||||
AgentSkills []SkillContextForEnv
|
||||
Repos []RepoContextForEnv // workspace repos available for checkout
|
||||
ChatSessionID string // non-empty for chat tasks
|
||||
Repos []RepoContextForEnv // workspace repos available for checkout
|
||||
ProjectID string // issue's project, when present
|
||||
ProjectTitle string // human-readable project title
|
||||
ProjectResources []ProjectResourceForEnv // resources attached to the project
|
||||
ChatSessionID string // non-empty for chat tasks
|
||||
AutopilotRunID string // non-empty for autopilot run_only tasks
|
||||
AutopilotID string
|
||||
AutopilotTitle string
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package execenv
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -120,6 +121,139 @@ func TestPrepareDirectoryMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareWithProjectResources(t *testing.T) {
|
||||
t.Parallel()
|
||||
workspacesRoot := t.TempDir()
|
||||
|
||||
taskCtx := TaskContextForEnv{
|
||||
IssueID: "11111111-2222-3333-4444-555555555555",
|
||||
ProjectID: "22222222-3333-4444-5555-666666666666",
|
||||
ProjectTitle: "Agent UX 2026",
|
||||
ProjectResources: []ProjectResourceForEnv{
|
||||
{
|
||||
ID: "33333333-4444-5555-6666-777777777777",
|
||||
ResourceType: "github_repo",
|
||||
ResourceRef: json.RawMessage(`{"url":"https://github.com/multica-ai/multica","default_branch_hint":"main"}`),
|
||||
},
|
||||
},
|
||||
}
|
||||
env, err := Prepare(PrepareParams{
|
||||
WorkspacesRoot: workspacesRoot,
|
||||
WorkspaceID: "ws-test-pr",
|
||||
TaskID: "11111111-2222-3333-4444-555555555555",
|
||||
AgentName: "Test Agent",
|
||||
Provider: "claude",
|
||||
Task: taskCtx,
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare failed: %v", err)
|
||||
}
|
||||
defer env.Cleanup(true)
|
||||
|
||||
// resources.json should exist and decode back to what we wrote.
|
||||
resourcesPath := filepath.Join(env.WorkDir, ".multica", "project", "resources.json")
|
||||
raw, err := os.ReadFile(resourcesPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read resources.json: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
ProjectTitle string `json:"project_title"`
|
||||
Resources []struct {
|
||||
ID string `json:"id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceRef json.RawMessage `json:"resource_ref"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("resources.json unmarshal: %v\n%s", err, string(raw))
|
||||
}
|
||||
if got.ProjectID != taskCtx.ProjectID {
|
||||
t.Errorf("resources.json project_id = %q, want %q", got.ProjectID, taskCtx.ProjectID)
|
||||
}
|
||||
if got.ProjectTitle != taskCtx.ProjectTitle {
|
||||
t.Errorf("resources.json project_title = %q, want %q", got.ProjectTitle, taskCtx.ProjectTitle)
|
||||
}
|
||||
if len(got.Resources) != 1 || got.Resources[0].ResourceType != "github_repo" {
|
||||
t.Fatalf("resources.json resources mismatch: %+v", got.Resources)
|
||||
}
|
||||
|
||||
// CLAUDE.md should mention the project context block.
|
||||
if err := InjectRuntimeConfig(env.WorkDir, "claude", taskCtx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig: %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(env.WorkDir, "CLAUDE.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read CLAUDE.md: %v", err)
|
||||
}
|
||||
s := string(content)
|
||||
for _, want := range []string{
|
||||
"## Project Context",
|
||||
"Agent UX 2026",
|
||||
"GitHub repo",
|
||||
"https://github.com/multica-ai/multica",
|
||||
"default branch: `main`",
|
||||
".multica/project/resources.json",
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("CLAUDE.md missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When the issue's project has its own github_repo resources, those should be
|
||||
// the only repos rendered in the meta-skill — workspace-level repos must not
|
||||
// leak into the agent prompt to avoid confusing it about which repo to use.
|
||||
//
|
||||
// The handler-side override is exercised in handler tests; this test confirms
|
||||
// the rendering side: given a TaskContextForEnv where Repos was already
|
||||
// narrowed by the server to project repos only, the meta skill renders just
|
||||
// those.
|
||||
func TestProjectReposReplaceWorkspaceReposInMetaSkill(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
ctx := TaskContextForEnv{
|
||||
IssueID: "11111111-2222-3333-4444-555555555555",
|
||||
ProjectID: "22222222-3333-4444-5555-666666666666",
|
||||
ProjectTitle: "Project A",
|
||||
Repos: []RepoContextForEnv{
|
||||
{URL: "https://github.com/org/project-repo", Description: ""},
|
||||
},
|
||||
ProjectResources: []ProjectResourceForEnv{
|
||||
{
|
||||
ID: "33333333-4444-5555-6666-777777777777",
|
||||
ResourceType: "github_repo",
|
||||
ResourceRef: []byte(`{"url":"https://github.com/org/project-repo"}`),
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig: %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read CLAUDE.md: %v", err)
|
||||
}
|
||||
s := string(content)
|
||||
if !strings.Contains(s, "https://github.com/org/project-repo") {
|
||||
t.Errorf("CLAUDE.md missing project repo URL")
|
||||
}
|
||||
if strings.Contains(s, "https://github.com/org/workspace-repo") {
|
||||
t.Errorf("CLAUDE.md should not contain workspace repo when project has its own")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteProjectResourcesSkippedWhenNone(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
if err := writeProjectResources(dir, TaskContextForEnv{}); err != nil {
|
||||
t.Fatalf("writeProjectResources: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".multica", "project", "resources.json")); !os.IsNotExist(err) {
|
||||
t.Errorf("expected no resources.json to be written when project context is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareWithRepoContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
workspacesRoot := t.TempDir()
|
||||
|
||||
@@ -1,12 +1,47 @@
|
||||
package execenv
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// formatProjectResource renders a single resource as a human-readable bullet.
|
||||
// Unknown resource types fall back to a JSON-encoded ref so the agent can
|
||||
// still read what the user attached. New resource types should add a case
|
||||
// here AND in the API validator (handler/project_resource.go).
|
||||
func formatProjectResource(r ProjectResourceForEnv) string {
|
||||
label := r.Label
|
||||
switch r.ResourceType {
|
||||
case "github_repo":
|
||||
var payload struct {
|
||||
URL string `json:"url"`
|
||||
DefaultBranchHint string `json:"default_branch_hint,omitempty"`
|
||||
}
|
||||
_ = json.Unmarshal(r.ResourceRef, &payload)
|
||||
out := fmt.Sprintf("**GitHub repo**: %s", payload.URL)
|
||||
if payload.DefaultBranchHint != "" {
|
||||
out += fmt.Sprintf(" (default branch: `%s`)", payload.DefaultBranchHint)
|
||||
}
|
||||
if label != "" {
|
||||
out += " — " + label
|
||||
}
|
||||
return out
|
||||
default:
|
||||
ref := string(r.ResourceRef)
|
||||
if ref == "" {
|
||||
ref = "{}"
|
||||
}
|
||||
out := fmt.Sprintf("**%s**: `%s`", r.ResourceType, ref)
|
||||
if label != "" {
|
||||
out += " — " + label
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// InjectRuntimeConfig writes the meta skill content into the runtime-specific
|
||||
// config file so the agent discovers its environment through its native mechanism.
|
||||
//
|
||||
@@ -138,6 +173,26 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("\nThe checkout command creates a git worktree with a dedicated branch. You can check out one or more repos as needed.\n\n")
|
||||
}
|
||||
|
||||
// Inject project-scoped context (resources attached to the issue's project).
|
||||
// The full structured payload is also available at .multica/project/resources.json
|
||||
// so skills can consume it programmatically.
|
||||
if ctx.ProjectID != "" || len(ctx.ProjectResources) > 0 {
|
||||
b.WriteString("## Project Context\n\n")
|
||||
if ctx.ProjectTitle != "" {
|
||||
fmt.Fprintf(&b, "This issue belongs to **%s**.\n\n", ctx.ProjectTitle)
|
||||
}
|
||||
if len(ctx.ProjectResources) > 0 {
|
||||
b.WriteString("Project resources (also written to `.multica/project/resources.json`):\n\n")
|
||||
for _, r := range ctx.ProjectResources {
|
||||
fmt.Fprintf(&b, "- %s\n", formatProjectResource(r))
|
||||
}
|
||||
b.WriteString("\nResources are pointers — open them only when relevant to the task. ")
|
||||
b.WriteString("For `github_repo` resources, use `multica repo checkout <url>` to fetch the code.\n\n")
|
||||
} else {
|
||||
b.WriteString("This project has no resources attached yet.\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("### Workflow\n\n")
|
||||
|
||||
if ctx.ChatSessionID != "" {
|
||||
|
||||
@@ -22,6 +22,15 @@ type RepoData struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// ProjectResourceData mirrors handler.ProjectResourceData — a single project
|
||||
// resource as delivered to the daemon. resource_ref is type-specific JSON.
|
||||
type ProjectResourceData struct {
|
||||
ID string `json:"id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceRef json.RawMessage `json:"resource_ref"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
// Task represents a claimed task from the server.
|
||||
// Agent data (name, skills) is populated by the claim endpoint.
|
||||
type Task struct {
|
||||
@@ -31,7 +40,10 @@ type Task struct {
|
||||
IssueID string `json:"issue_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Agent *AgentData `json:"agent,omitempty"`
|
||||
Repos []RepoData `json:"repos,omitempty"`
|
||||
Repos []RepoData `json:"repos,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
|
||||
ProjectTitle string `json:"project_title,omitempty"` // human-readable project title for context injection
|
||||
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // project-scoped resources to expose to the agent
|
||||
PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue
|
||||
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
|
||||
TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
|
||||
|
||||
@@ -120,6 +120,20 @@ type RepoData struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// ProjectResourceData is the wire shape for a project resource included in a
|
||||
// claim response. The daemon reads this list and writes it into the agent's
|
||||
// working directory so skills/agents can discover project-scoped context.
|
||||
//
|
||||
// resource_ref is type-specific JSON; the daemon doesn't interpret it beyond
|
||||
// well-known fields like url for github_repo. New types can be added without
|
||||
// changing this struct.
|
||||
type ProjectResourceData struct {
|
||||
ID string `json:"id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceRef json.RawMessage `json:"resource_ref"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
type AgentTaskResponse struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
@@ -138,7 +152,10 @@ type AgentTaskResponse struct {
|
||||
MaxAttempts int32 `json:"max_attempts"`
|
||||
ParentTaskID *string `json:"parent_task_id,omitempty"`
|
||||
Agent *TaskAgentData `json:"agent,omitempty"`
|
||||
Repos []RepoData `json:"repos,omitempty"`
|
||||
Repos []RepoData `json:"repos,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
|
||||
ProjectTitle string `json:"project_title,omitempty"` // for surfacing in agent context
|
||||
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // resources attached to the project
|
||||
CreatedAt string `json:"created_at"`
|
||||
PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue
|
||||
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue
|
||||
|
||||
@@ -873,10 +873,62 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Include workspace ID and repos so the daemon can set up worktrees.
|
||||
//
|
||||
// Repo precedence: project-bound github_repo resources override workspace
|
||||
// repos when present. Mixing both would just confuse the agent — if a
|
||||
// project explicitly attached its repos, those are the authoritative set
|
||||
// for issues inside that project. When the project has no github_repo
|
||||
// resources (or no project at all), we fall back to the workspace repos.
|
||||
if task.IssueID.Valid {
|
||||
if issue, err := h.Queries.GetIssue(r.Context(), task.IssueID); err == nil {
|
||||
resp.WorkspaceID = uuidToString(issue.WorkspaceID)
|
||||
if ws, err := h.Queries.GetWorkspace(r.Context(), issue.WorkspaceID); err == nil && ws.Repos != nil {
|
||||
|
||||
var projectRepos []RepoData
|
||||
if issue.ProjectID.Valid {
|
||||
resp.ProjectID = uuidToString(issue.ProjectID)
|
||||
if proj, err := h.Queries.GetProject(r.Context(), issue.ProjectID); err == nil {
|
||||
resp.ProjectTitle = proj.Title
|
||||
}
|
||||
if rows := h.listProjectResourcesForProject(r.Context(), issue.ProjectID); len(rows) > 0 {
|
||||
out := make([]ProjectResourceData, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
label := ""
|
||||
if row.Label.Valid {
|
||||
label = row.Label.String
|
||||
}
|
||||
ref := json.RawMessage(row.ResourceRef)
|
||||
if len(ref) == 0 {
|
||||
ref = json.RawMessage("{}")
|
||||
}
|
||||
out = append(out, ProjectResourceData{
|
||||
ID: uuidToString(row.ID),
|
||||
ResourceType: row.ResourceType,
|
||||
ResourceRef: ref,
|
||||
Label: label,
|
||||
})
|
||||
// Lift github_repo resources into the daemon's repo list
|
||||
// so `multica repo checkout` and the meta-skill render
|
||||
// them as the issue's repos.
|
||||
if row.ResourceType == "github_repo" {
|
||||
var payload struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if json.Unmarshal(row.ResourceRef, &payload) == nil && payload.URL != "" {
|
||||
desc := ""
|
||||
if row.Label.Valid {
|
||||
desc = row.Label.String
|
||||
}
|
||||
projectRepos = append(projectRepos, RepoData{URL: payload.URL, Description: desc})
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.ProjectResources = out
|
||||
}
|
||||
}
|
||||
|
||||
if len(projectRepos) > 0 {
|
||||
resp.Repos = projectRepos
|
||||
} else if ws, err := h.Queries.GetWorkspace(r.Context(), issue.WorkspaceID); err == nil && ws.Repos != nil {
|
||||
var repos []RepoData
|
||||
if json.Unmarshal(ws.Repos, &repos) == nil && len(repos) > 0 {
|
||||
resp.Repos = repos
|
||||
|
||||
@@ -57,13 +57,24 @@ func (h *Handler) loadProjectIssueStats(ctx context.Context, projectID pgtype.UU
|
||||
}
|
||||
|
||||
type CreateProjectRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Icon *string `json:"icon"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
LeadType *string `json:"lead_type"`
|
||||
LeadID *string `json:"lead_id"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Icon *string `json:"icon"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
LeadType *string `json:"lead_type"`
|
||||
LeadID *string `json:"lead_id"`
|
||||
Resources []CreateProjectResourceRequestPayload `json:"resources,omitempty"`
|
||||
}
|
||||
|
||||
// CreateProjectResourceRequestPayload mirrors CreateProjectResourceRequest but
|
||||
// is embedded inside the project create payload. Kept as a separate type so a
|
||||
// future change to the standalone request can't silently break this surface.
|
||||
type CreateProjectResourceRequestPayload struct {
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceRef json.RawMessage `json:"resource_ref"`
|
||||
Label *string `json:"label"`
|
||||
Position *int32 `json:"position"`
|
||||
}
|
||||
|
||||
type UpdateProjectRequest struct {
|
||||
@@ -188,7 +199,25 @@ func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
project, err := h.Queries.CreateProject(r.Context(), db.CreateProjectParams{
|
||||
|
||||
// Pre-validate every resource payload before opening a transaction so an
|
||||
// invalid ref produces a clean 400 with no DB work.
|
||||
normalizedRefs := make([]json.RawMessage, len(req.Resources))
|
||||
for i, res := range req.Resources {
|
||||
res.ResourceType = strings.TrimSpace(res.ResourceType)
|
||||
if res.ResourceType == "" {
|
||||
writeError(w, http.StatusBadRequest, "resources[].resource_type is required")
|
||||
return
|
||||
}
|
||||
ref, err := validateAndNormalizeResourceRef(res.ResourceType, res.ResourceRef)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "resources["+strconv.Itoa(i)+"]: "+err.Error())
|
||||
return
|
||||
}
|
||||
normalizedRefs[i] = ref
|
||||
}
|
||||
|
||||
createParams := db.CreateProjectParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Title: req.Title,
|
||||
Description: ptrToText(req.Description),
|
||||
@@ -197,14 +226,99 @@ func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
LeadType: leadType,
|
||||
LeadID: leadID,
|
||||
Priority: priority,
|
||||
})
|
||||
}
|
||||
|
||||
// Without resources, keep the simple non-tx path.
|
||||
if len(req.Resources) == 0 {
|
||||
project, err := h.Queries.CreateProject(r.Context(), createParams)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create project")
|
||||
return
|
||||
}
|
||||
resp := projectToResponse(project)
|
||||
h.publish(protocol.EventProjectCreated, workspaceID, "member", userID, map[string]any{"project": resp})
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
return
|
||||
}
|
||||
|
||||
// Transactional path: project + all resources are atomic.
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
project, err := qtx.CreateProject(r.Context(), createParams)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create project")
|
||||
return
|
||||
}
|
||||
|
||||
creator, _ := h.parseUserUUIDOrZero(userID)
|
||||
resourceRows := make([]db.ProjectResource, 0, len(req.Resources))
|
||||
for i, res := range req.Resources {
|
||||
var label pgtype.Text
|
||||
if res.Label != nil && strings.TrimSpace(*res.Label) != "" {
|
||||
label = pgtype.Text{String: strings.TrimSpace(*res.Label), Valid: true}
|
||||
}
|
||||
var position int32 = int32(i)
|
||||
if res.Position != nil {
|
||||
position = *res.Position
|
||||
}
|
||||
row, err := qtx.CreateProjectResource(r.Context(), db.CreateProjectResourceParams{
|
||||
ProjectID: project.ID,
|
||||
WorkspaceID: project.WorkspaceID,
|
||||
ResourceType: res.ResourceType,
|
||||
ResourceRef: normalizedRefs[i],
|
||||
Label: label,
|
||||
Position: position,
|
||||
CreatedBy: creator,
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "resources["+strconv.Itoa(i)+"]: this resource is already attached")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to attach resource at index "+strconv.Itoa(i))
|
||||
return
|
||||
}
|
||||
resourceRows = append(resourceRows, row)
|
||||
}
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to commit project create")
|
||||
return
|
||||
}
|
||||
|
||||
resp := projectToResponse(project)
|
||||
resourceResp := make([]ProjectResourceResponse, len(resourceRows))
|
||||
for i, row := range resourceRows {
|
||||
resourceResp[i] = projectResourceToResponse(row)
|
||||
}
|
||||
h.publish(protocol.EventProjectCreated, workspaceID, "member", userID, map[string]any{"project": resp})
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
for _, rr := range resourceResp {
|
||||
h.publish(protocol.EventProjectResourceCreated, workspaceID, "member", userID, map[string]any{
|
||||
"resource": rr,
|
||||
"project_id": resp.ID,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"id": resp.ID,
|
||||
"workspace_id": resp.WorkspaceID,
|
||||
"title": resp.Title,
|
||||
"description": resp.Description,
|
||||
"icon": resp.Icon,
|
||||
"status": resp.Status,
|
||||
"priority": resp.Priority,
|
||||
"lead_type": resp.LeadType,
|
||||
"lead_id": resp.LeadID,
|
||||
"created_at": resp.CreatedAt,
|
||||
"updated_at": resp.UpdatedAt,
|
||||
"issue_count": resp.IssueCount,
|
||||
"done_count": resp.DoneCount,
|
||||
"resources": resourceResp,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
283
server/internal/handler/project_resource.go
Normal file
283
server/internal/handler/project_resource.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// ProjectResourceResponse is the JSON shape returned by the project resource API.
|
||||
type ProjectResourceResponse struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceRef json.RawMessage `json:"resource_ref"`
|
||||
Label *string `json:"label"`
|
||||
Position int32 `json:"position"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
CreatedBy *string `json:"created_by"`
|
||||
}
|
||||
|
||||
func projectResourceToResponse(r db.ProjectResource) ProjectResourceResponse {
|
||||
ref := json.RawMessage(r.ResourceRef)
|
||||
if len(ref) == 0 {
|
||||
ref = json.RawMessage("{}")
|
||||
}
|
||||
return ProjectResourceResponse{
|
||||
ID: uuidToString(r.ID),
|
||||
ProjectID: uuidToString(r.ProjectID),
|
||||
WorkspaceID: uuidToString(r.WorkspaceID),
|
||||
ResourceType: r.ResourceType,
|
||||
ResourceRef: ref,
|
||||
Label: textToPtr(r.Label),
|
||||
Position: r.Position,
|
||||
CreatedAt: timestampToString(r.CreatedAt),
|
||||
CreatedBy: uuidToPtr(r.CreatedBy),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateProjectResourceRequest is the body for POST /api/projects/{id}/resources.
|
||||
type CreateProjectResourceRequest struct {
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceRef json.RawMessage `json:"resource_ref"`
|
||||
Label *string `json:"label"`
|
||||
Position *int32 `json:"position"`
|
||||
}
|
||||
|
||||
// validateAndNormalizeResourceRef checks the payload for a known resource_type.
|
||||
// New types are added here without schema migration; unknown types are rejected
|
||||
// at the API boundary so a typo can't slip through and produce a resource the
|
||||
// daemon/UI doesn't understand.
|
||||
func validateAndNormalizeResourceRef(resourceType string, ref json.RawMessage) (json.RawMessage, error) {
|
||||
if len(ref) == 0 {
|
||||
return nil, errors.New("resource_ref is required")
|
||||
}
|
||||
switch resourceType {
|
||||
case "github_repo":
|
||||
return validateGithubRepoRef(ref)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown resource_type %q", resourceType)
|
||||
}
|
||||
}
|
||||
|
||||
type githubRepoRef struct {
|
||||
URL string `json:"url"`
|
||||
DefaultBranchHint string `json:"default_branch_hint,omitempty"`
|
||||
}
|
||||
|
||||
func validateGithubRepoRef(ref json.RawMessage) (json.RawMessage, error) {
|
||||
var payload githubRepoRef
|
||||
if err := json.Unmarshal(ref, &payload); err != nil {
|
||||
return nil, fmt.Errorf("invalid github_repo payload: %w", err)
|
||||
}
|
||||
payload.URL = strings.TrimSpace(payload.URL)
|
||||
if payload.URL == "" {
|
||||
return nil, errors.New("github_repo: url is required")
|
||||
}
|
||||
if u, err := url.Parse(payload.URL); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, errors.New("github_repo: url must be a valid http(s) URL")
|
||||
}
|
||||
payload.DefaultBranchHint = strings.TrimSpace(payload.DefaultBranchHint)
|
||||
out, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadProjectForResource resolves the project, enforces workspace ownership,
|
||||
// and returns its DB row. Used by all project_resource handlers.
|
||||
func (h *Handler) loadProjectForResource(w http.ResponseWriter, r *http.Request, projectIDParam string) (db.Project, bool) {
|
||||
projectUUID, ok := parseUUIDOrBadRequest(w, projectIDParam, "project id")
|
||||
if !ok {
|
||||
return db.Project{}, false
|
||||
}
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, h.resolveWorkspaceID(r), "workspace id")
|
||||
if !ok {
|
||||
return db.Project{}, false
|
||||
}
|
||||
project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
|
||||
ID: projectUUID, WorkspaceID: wsUUID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "project not found")
|
||||
return db.Project{}, false
|
||||
}
|
||||
return project, true
|
||||
}
|
||||
|
||||
// ListProjectResources returns the resources attached to a project.
|
||||
func (h *Handler) ListProjectResources(w http.ResponseWriter, r *http.Request) {
|
||||
project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
resources, err := h.Queries.ListProjectResources(r.Context(), project.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list project resources")
|
||||
return
|
||||
}
|
||||
resp := make([]ProjectResourceResponse, len(resources))
|
||||
for i, res := range resources {
|
||||
resp[i] = projectResourceToResponse(res)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"resources": resp, "total": len(resp)})
|
||||
}
|
||||
|
||||
// CreateProjectResource attaches a new resource to a project.
|
||||
func (h *Handler) CreateProjectResource(w http.ResponseWriter, r *http.Request) {
|
||||
project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req CreateProjectResourceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
req.ResourceType = strings.TrimSpace(req.ResourceType)
|
||||
if req.ResourceType == "" {
|
||||
writeError(w, http.StatusBadRequest, "resource_type is required")
|
||||
return
|
||||
}
|
||||
normalizedRef, err := validateAndNormalizeResourceRef(req.ResourceType, req.ResourceRef)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var label pgtype.Text
|
||||
if req.Label != nil && strings.TrimSpace(*req.Label) != "" {
|
||||
label = pgtype.Text{String: strings.TrimSpace(*req.Label), Valid: true}
|
||||
}
|
||||
var position int32
|
||||
if req.Position != nil {
|
||||
position = *req.Position
|
||||
} else {
|
||||
// Append after existing resources.
|
||||
count, _ := h.Queries.CountProjectResources(r.Context(), project.ID)
|
||||
position = int32(count)
|
||||
}
|
||||
|
||||
creator, _ := h.parseUserUUIDOrZero(userID)
|
||||
resource, err := h.Queries.CreateProjectResource(r.Context(), db.CreateProjectResourceParams{
|
||||
ProjectID: project.ID,
|
||||
WorkspaceID: project.WorkspaceID,
|
||||
ResourceType: req.ResourceType,
|
||||
ResourceRef: normalizedRef,
|
||||
Label: label,
|
||||
Position: position,
|
||||
CreatedBy: creator,
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "this resource is already attached to the project")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to create project resource")
|
||||
return
|
||||
}
|
||||
|
||||
resp := projectResourceToResponse(resource)
|
||||
h.publish(
|
||||
protocol.EventProjectResourceCreated,
|
||||
uuidToString(project.WorkspaceID),
|
||||
"member",
|
||||
userID,
|
||||
map[string]any{"resource": resp, "project_id": uuidToString(project.ID)},
|
||||
)
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// DeleteProjectResource removes a resource from a project.
|
||||
func (h *Handler) DeleteProjectResource(w http.ResponseWriter, r *http.Request) {
|
||||
project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
resourceUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "resourceId"), "resource id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
resource, err := h.Queries.GetProjectResourceInWorkspace(r.Context(), db.GetProjectResourceInWorkspaceParams{
|
||||
ID: resourceUUID, WorkspaceID: project.WorkspaceID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "project resource not found")
|
||||
return
|
||||
}
|
||||
if uuidToString(resource.ProjectID) != uuidToString(project.ID) {
|
||||
writeError(w, http.StatusNotFound, "project resource not found")
|
||||
return
|
||||
}
|
||||
if err := h.Queries.DeleteProjectResource(r.Context(), resource.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete project resource")
|
||||
return
|
||||
}
|
||||
h.publish(
|
||||
protocol.EventProjectResourceDeleted,
|
||||
uuidToString(project.WorkspaceID),
|
||||
"member",
|
||||
userID,
|
||||
map[string]any{
|
||||
"project_id": uuidToString(project.ID),
|
||||
"resource_id": uuidToString(resource.ID),
|
||||
},
|
||||
)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// parseUserUUIDOrZero converts a user ID string to a pgtype.UUID, returning a
|
||||
// zero value on any error so the caller can store NULL for created_by when the
|
||||
// authenticated principal is not a workspace member (e.g. internal-server use).
|
||||
func (h *Handler) parseUserUUIDOrZero(userID string) (pgtype.UUID, bool) {
|
||||
if userID == "" {
|
||||
return pgtype.UUID{}, false
|
||||
}
|
||||
u, err := parseUUIDLoose(userID)
|
||||
if err != nil {
|
||||
return pgtype.UUID{}, false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
|
||||
// parseUUIDLoose mirrors util.ParseUUID but lives here to avoid pulling util
|
||||
// into a tiny one-off helper. Keep the body minimal.
|
||||
func parseUUIDLoose(s string) (pgtype.UUID, error) {
|
||||
var u pgtype.UUID
|
||||
if err := u.Scan(s); err != nil {
|
||||
return pgtype.UUID{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// listProjectResourcesForProject is a small helper used by the daemon claim
|
||||
// handler to attach project resources to outgoing tasks.
|
||||
func (h *Handler) listProjectResourcesForProject(ctx context.Context, projectID pgtype.UUID) []db.ProjectResource {
|
||||
if !projectID.Valid {
|
||||
return nil
|
||||
}
|
||||
rows, err := h.Queries.ListProjectResources(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return rows
|
||||
}
|
||||
207
server/internal/handler/project_resource_test.go
Normal file
207
server/internal/handler/project_resource_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProjectResourceLifecycle(t *testing.T) {
|
||||
// Create a project to attach resources to.
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Resource lifecycle project",
|
||||
})
|
||||
testHandler.CreateProject(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateProject: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var project ProjectResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
|
||||
t.Fatalf("decode CreateProject: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
req := newRequest("DELETE", "/api/projects/"+project.ID, nil)
|
||||
req = withURLParam(req, "id", project.ID)
|
||||
testHandler.DeleteProject(httptest.NewRecorder(), req)
|
||||
}()
|
||||
|
||||
// Attach a github_repo resource.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
||||
"resource_type": "github_repo",
|
||||
"resource_ref": map[string]any{"url": "https://github.com/multica-ai/multica"},
|
||||
})
|
||||
req = withURLParam(req, "id", project.ID)
|
||||
testHandler.CreateProjectResource(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateProjectResource: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var created ProjectResourceResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
||||
t.Fatalf("decode CreateProjectResource: %v", err)
|
||||
}
|
||||
if created.ResourceType != "github_repo" {
|
||||
t.Errorf("created.ResourceType = %q, want github_repo", created.ResourceType)
|
||||
}
|
||||
var ref struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(created.ResourceRef, &ref); err != nil {
|
||||
t.Fatalf("decode resource_ref: %v", err)
|
||||
}
|
||||
if ref.URL != "https://github.com/multica-ai/multica" {
|
||||
t.Errorf("created.ResourceRef.url = %q", ref.URL)
|
||||
}
|
||||
|
||||
// Listing must include the new resource.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/projects/"+project.ID+"/resources", nil)
|
||||
req = withURLParam(req, "id", project.ID)
|
||||
testHandler.ListProjectResources(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListProjectResources: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var listResp struct {
|
||||
Resources []ProjectResourceResponse `json:"resources"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&listResp); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
if listResp.Total != 1 || len(listResp.Resources) != 1 {
|
||||
t.Fatalf("list returned %d resources, want 1", listResp.Total)
|
||||
}
|
||||
if listResp.Resources[0].ID != created.ID {
|
||||
t.Errorf("list[0].ID = %q, want %q", listResp.Resources[0].ID, created.ID)
|
||||
}
|
||||
|
||||
// Duplicate attach must conflict (UNIQUE on project_id + type + ref).
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
||||
"resource_type": "github_repo",
|
||||
"resource_ref": map[string]any{"url": "https://github.com/multica-ai/multica"},
|
||||
})
|
||||
req = withURLParam(req, "id", project.ID)
|
||||
testHandler.CreateProjectResource(w, req)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("duplicate CreateProjectResource: expected 409, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Invalid URL must reject at the validator level.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
||||
"resource_type": "github_repo",
|
||||
"resource_ref": map[string]any{"url": "not-a-url"},
|
||||
})
|
||||
req = withURLParam(req, "id", project.ID)
|
||||
testHandler.CreateProjectResource(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("invalid URL: expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Unknown resource_type must reject.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
||||
"resource_type": "unknown_type",
|
||||
"resource_ref": map[string]any{"foo": "bar"},
|
||||
})
|
||||
req = withURLParam(req, "id", project.ID)
|
||||
testHandler.CreateProjectResource(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("unknown type: expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Delete the resource.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("DELETE", "/api/projects/"+project.ID+"/resources/"+created.ID, nil)
|
||||
req = withURLParams(req, "id", project.ID, "resourceId", created.ID)
|
||||
testHandler.DeleteProjectResource(w, req)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("DeleteProjectResource: expected 204, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// After deletion the list should be empty.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/projects/"+project.ID+"/resources", nil)
|
||||
req = withURLParam(req, "id", project.ID)
|
||||
testHandler.ListProjectResources(w, req)
|
||||
if err := json.NewDecoder(w.Body).Decode(&listResp); err != nil {
|
||||
t.Fatalf("decode post-delete list: %v", err)
|
||||
}
|
||||
if listResp.Total != 0 {
|
||||
t.Errorf("post-delete list: total = %d, want 0", listResp.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProjectAttachesResources(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Project with bundled resources",
|
||||
"resources": []map[string]any{
|
||||
{
|
||||
"resource_type": "github_repo",
|
||||
"resource_ref": map[string]any{"url": "https://github.com/multica-ai/multica"},
|
||||
},
|
||||
},
|
||||
})
|
||||
testHandler.CreateProject(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateProject with resources: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
ID string `json:"id"`
|
||||
Resources []ProjectResourceResponse `json:"resources"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
r := newRequest("DELETE", "/api/projects/"+resp.ID, nil)
|
||||
r = withURLParam(r, "id", resp.ID)
|
||||
testHandler.DeleteProject(httptest.NewRecorder(), r)
|
||||
}()
|
||||
|
||||
if len(resp.Resources) != 1 || resp.Resources[0].ResourceType != "github_repo" {
|
||||
t.Fatalf("response resources mismatch: %+v", resp.Resources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProjectRollsBackOnInvalidResource(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Project that should not exist",
|
||||
"resources": []map[string]any{
|
||||
{
|
||||
"resource_type": "github_repo",
|
||||
"resource_ref": map[string]any{"url": "not-a-url"},
|
||||
},
|
||||
},
|
||||
})
|
||||
testHandler.CreateProject(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("CreateProject with invalid resource: expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Confirm no project survived (transactional rollback). Listing all projects
|
||||
// in the workspace and checking for the title is enough.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/projects?workspace_id="+testWorkspaceID, nil)
|
||||
testHandler.ListProjects(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListProjects: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var list struct {
|
||||
Projects []ProjectResponse `json:"projects"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&list); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
for _, p := range list.Projects {
|
||||
if p.Title == "Project that should not exist" {
|
||||
t.Errorf("invalid resource should have rolled back project create, but found %s", p.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
server/migrations/065_project_resources.down.sql
Normal file
1
server/migrations/065_project_resources.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS project_resource;
|
||||
19
server/migrations/065_project_resources.up.sql
Normal file
19
server/migrations/065_project_resources.up.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Project Resources: typed pointers from a project to external resources
|
||||
-- (github_repo for now; notion_page / gdoc / url / file later). The shape is
|
||||
-- intentionally polymorphic — resource_type is a free string and resource_ref
|
||||
-- is JSONB, so adding a new type requires zero schema changes.
|
||||
CREATE TABLE project_resource (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
||||
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_ref JSONB NOT NULL,
|
||||
label TEXT,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID,
|
||||
UNIQUE (project_id, resource_type, resource_ref)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_project_resource_project ON project_resource(project_id, position);
|
||||
CREATE INDEX idx_project_resource_workspace ON project_resource(workspace_id);
|
||||
@@ -365,6 +365,18 @@ type Project struct {
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
type ProjectResource struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceRef []byte `json:"resource_ref"`
|
||||
Label pgtype.Text `json:"label"`
|
||||
Position int32 `json:"position"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
}
|
||||
|
||||
type Skill struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
|
||||
196
server/pkg/db/generated/project_resource.sql.go
Normal file
196
server/pkg/db/generated/project_resource.sql.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: project_resource.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const countProjectResources = `-- name: CountProjectResources :one
|
||||
SELECT count(*) FROM project_resource WHERE project_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) CountProjectResources(ctx context.Context, projectID pgtype.UUID) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countProjectResources, projectID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createProjectResource = `-- name: CreateProjectResource :one
|
||||
INSERT INTO project_resource (
|
||||
project_id, workspace_id, resource_type, resource_ref, label, position, created_by
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7
|
||||
) RETURNING id, project_id, workspace_id, resource_type, resource_ref, label, position, created_at, created_by
|
||||
`
|
||||
|
||||
type CreateProjectResourceParams struct {
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceRef []byte `json:"resource_ref"`
|
||||
Label pgtype.Text `json:"label"`
|
||||
Position int32 `json:"position"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateProjectResource(ctx context.Context, arg CreateProjectResourceParams) (ProjectResource, error) {
|
||||
row := q.db.QueryRow(ctx, createProjectResource,
|
||||
arg.ProjectID,
|
||||
arg.WorkspaceID,
|
||||
arg.ResourceType,
|
||||
arg.ResourceRef,
|
||||
arg.Label,
|
||||
arg.Position,
|
||||
arg.CreatedBy,
|
||||
)
|
||||
var i ProjectResource
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.WorkspaceID,
|
||||
&i.ResourceType,
|
||||
&i.ResourceRef,
|
||||
&i.Label,
|
||||
&i.Position,
|
||||
&i.CreatedAt,
|
||||
&i.CreatedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteProjectResource = `-- name: DeleteProjectResource :exec
|
||||
DELETE FROM project_resource WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteProjectResource(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteProjectResource, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getProjectResource = `-- name: GetProjectResource :one
|
||||
SELECT id, project_id, workspace_id, resource_type, resource_ref, label, position, created_at, created_by FROM project_resource
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetProjectResource(ctx context.Context, id pgtype.UUID) (ProjectResource, error) {
|
||||
row := q.db.QueryRow(ctx, getProjectResource, id)
|
||||
var i ProjectResource
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.WorkspaceID,
|
||||
&i.ResourceType,
|
||||
&i.ResourceRef,
|
||||
&i.Label,
|
||||
&i.Position,
|
||||
&i.CreatedAt,
|
||||
&i.CreatedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getProjectResourceInWorkspace = `-- name: GetProjectResourceInWorkspace :one
|
||||
SELECT id, project_id, workspace_id, resource_type, resource_ref, label, position, created_at, created_by FROM project_resource
|
||||
WHERE id = $1 AND workspace_id = $2
|
||||
`
|
||||
|
||||
type GetProjectResourceInWorkspaceParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetProjectResourceInWorkspace(ctx context.Context, arg GetProjectResourceInWorkspaceParams) (ProjectResource, error) {
|
||||
row := q.db.QueryRow(ctx, getProjectResourceInWorkspace, arg.ID, arg.WorkspaceID)
|
||||
var i ProjectResource
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.WorkspaceID,
|
||||
&i.ResourceType,
|
||||
&i.ResourceRef,
|
||||
&i.Label,
|
||||
&i.Position,
|
||||
&i.CreatedAt,
|
||||
&i.CreatedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listProjectResources = `-- name: ListProjectResources :many
|
||||
SELECT id, project_id, workspace_id, resource_type, resource_ref, label, position, created_at, created_by FROM project_resource
|
||||
WHERE project_id = $1
|
||||
ORDER BY position ASC, created_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListProjectResources(ctx context.Context, projectID pgtype.UUID) ([]ProjectResource, error) {
|
||||
rows, err := q.db.Query(ctx, listProjectResources, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ProjectResource{}
|
||||
for rows.Next() {
|
||||
var i ProjectResource
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.WorkspaceID,
|
||||
&i.ResourceType,
|
||||
&i.ResourceRef,
|
||||
&i.Label,
|
||||
&i.Position,
|
||||
&i.CreatedAt,
|
||||
&i.CreatedBy,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listProjectResourcesForProjects = `-- name: ListProjectResourcesForProjects :many
|
||||
SELECT id, project_id, workspace_id, resource_type, resource_ref, label, position, created_at, created_by FROM project_resource
|
||||
WHERE project_id = ANY($1::uuid[])
|
||||
ORDER BY project_id, position ASC, created_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListProjectResourcesForProjects(ctx context.Context, projectIds []pgtype.UUID) ([]ProjectResource, error) {
|
||||
rows, err := q.db.Query(ctx, listProjectResourcesForProjects, projectIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ProjectResource{}
|
||||
for rows.Next() {
|
||||
var i ProjectResource
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.WorkspaceID,
|
||||
&i.ResourceType,
|
||||
&i.ResourceRef,
|
||||
&i.Label,
|
||||
&i.Position,
|
||||
&i.CreatedAt,
|
||||
&i.CreatedBy,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
30
server/pkg/db/queries/project_resource.sql
Normal file
30
server/pkg/db/queries/project_resource.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- name: ListProjectResources :many
|
||||
SELECT * FROM project_resource
|
||||
WHERE project_id = $1
|
||||
ORDER BY position ASC, created_at ASC;
|
||||
|
||||
-- name: ListProjectResourcesForProjects :many
|
||||
SELECT * FROM project_resource
|
||||
WHERE project_id = ANY(sqlc.arg('project_ids')::uuid[])
|
||||
ORDER BY project_id, position ASC, created_at ASC;
|
||||
|
||||
-- name: GetProjectResource :one
|
||||
SELECT * FROM project_resource
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetProjectResourceInWorkspace :one
|
||||
SELECT * FROM project_resource
|
||||
WHERE id = $1 AND workspace_id = $2;
|
||||
|
||||
-- name: CreateProjectResource :one
|
||||
INSERT INTO project_resource (
|
||||
project_id, workspace_id, resource_type, resource_ref, label, position, created_by
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7
|
||||
) RETURNING *;
|
||||
|
||||
-- name: DeleteProjectResource :exec
|
||||
DELETE FROM project_resource WHERE id = $1;
|
||||
|
||||
-- name: CountProjectResources :one
|
||||
SELECT count(*) FROM project_resource WHERE project_id = $1;
|
||||
@@ -69,9 +69,11 @@ const (
|
||||
EventChatSessionRead = "chat:session_read"
|
||||
|
||||
// Project events
|
||||
EventProjectCreated = "project:created"
|
||||
EventProjectUpdated = "project:updated"
|
||||
EventProjectDeleted = "project:deleted"
|
||||
EventProjectCreated = "project:created"
|
||||
EventProjectUpdated = "project:updated"
|
||||
EventProjectDeleted = "project:deleted"
|
||||
EventProjectResourceCreated = "project_resource:created"
|
||||
EventProjectResourceDeleted = "project_resource:deleted"
|
||||
|
||||
// Label events
|
||||
EventLabelCreated = "label:created"
|
||||
|
||||
Reference in New Issue
Block a user