mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +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>
284 lines
8.9 KiB
Go
284 lines
8.9 KiB
Go
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
|
|
}
|