Files
multica/server/internal/handler/project_resource.go
Bohan Jiang 44608713bb feat(projects): typed project resources + agent runtime injection (#1926)
* 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>
2026-04-30 14:00:43 +08:00

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
}