mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 06:39:01 +02:00
* feat(projects): typed project resources + agent runtime injection
Adds a `project_resource` table that lets a project carry typed pointers
(github_repo today, more later) and surfaces them at agent runtime.
Server
- migration 065: project_resource (resource_type TEXT + resource_ref JSONB)
- sqlc CRUD + handler at /api/projects/{id}/resources
- claim handler attaches project_id/title + resources to issue tasks
Daemon
- TaskContextForEnv carries project context
- writes .multica/project/resources.json into workdir
- adds "## Project Context" block to CLAUDE.md / AGENTS.md / GEMINI.md
via type-dispatched formatter so new resource types just add a case
CLI
- multica project create --repo <url> attaches repos in one step
- multica project resource add/list/remove
Frontend
- Project create modal: Repos pill (workspace repos + ad-hoc URL)
- Project detail sidebar: collapsible Resources section with attach/remove
Docs
- New "Project Resources" chapter explaining the abstraction and
exactly what code to touch when adding a new resource type
Co-authored-by: multica-agent <github@multica.ai>
* fix(projects): transactional resources[] on create + generic CLI ref + test fix
Addresses review feedback on PR #1926:
1. CI red: TestProjectResourceLifecycle delete step called withURLParam
twice, which replaced the chi route context and dropped the project id.
Switched to the existing withURLParams helper from daemon_test.go.
2. POST /api/projects now accepts resources[] and attaches them in the
same transaction as the project. Invalid refs roll back the whole
create — no more half-attached projects on failure. Web modal + CLI
`project create --repo` both use the new bundled payload.
3. CLI `project resource add` now accepts a generic --ref '<json>' flag
so a new resource_type works without a CLI change. Per-type
shortcuts (--url for github_repo) remain as a convenience but are no
longer the only way in. Docs updated to drop the CLI from the
"files you must touch" list.
Adds two new server handler tests:
- TestCreateProjectAttachesResources (resources[] happy path)
- TestCreateProjectRollsBackOnInvalidResource (transactional rollback)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
208 lines
7.2 KiB
Go
208 lines
7.2 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
|