diff --git a/internal/mcp/dispatch_http.go b/internal/mcp/dispatch_http.go index 2bef185d..02d41a71 100644 --- a/internal/mcp/dispatch_http.go +++ b/internal/mcp/dispatch_http.go @@ -160,6 +160,12 @@ var noRemoteEquivalent = map[string]struct{}{ "github link": {}, "github status": {}, "github unlink": {}, + // `project reconcile` shells out to `gh` CLI to compare stored + // PR metadata against live GitHub state — same locality argument + // as the github commands. Agents that want this functionality + // have their own GitHub tools and can update items via + // `item update --field github_pr=...`. + "project reconcile": {}, } // Dispatch satisfies the Dispatcher interface. cliArgs are accepted @@ -256,6 +262,18 @@ func (d *HTTPHandlerDispatcher) Dispatch(ctx context.Context, cmdPath, _ []strin return d.dispatchItemRelated(ctx, input, user) case "item implemented-by": return d.dispatchItemImplementedBy(ctx, input, user) + case "item bulk-update": + return d.dispatchItemBulkUpdate(ctx, input, user) + case "item note": + return d.dispatchItemNote(ctx, input, user) + case "item decide": + return d.dispatchItemDecide(ctx, input, user) + case "project ready": + return d.dispatchProjectReady(ctx, input, user) + case "project stale": + return d.dispatchProjectStale(ctx, input, user) + case "library list": + return d.dispatchLibraryList(ctx, input, user) } // Item link create/delete commands. The asymmetry between which diff --git a/internal/mcp/dispatch_http_project.go b/internal/mcp/dispatch_http_project.go new file mode 100644 index 00000000..e1480c54 --- /dev/null +++ b/internal/mcp/dispatch_http_project.go @@ -0,0 +1,612 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sort" + "strings" + "time" + + "github.com/mark3labs/mcp-go/mcp" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// dispatchProjectReady reproduces `pad project ready --format json` — +// the CLI returns `{count, results}` extracted from the dashboard's +// SuggestedNext slice, NOT the full dashboard payload. (Compare with +// `pad project next` which returns the raw dashboard JSON; both surface +// the same suggestions but with different framing.) +// +// Aliasing to /dashboard would be a behavioural divergence: the +// agent would see an unexpected wrapper shape and have to know to dig +// into `suggested_next`. Mirroring the CLI's `{count, results}` shape +// keeps the MCP transport equivalent to ExecDispatcher. +func (d *HTTPHandlerDispatcher) dispatchProjectReady( + ctx context.Context, + input map[string]any, + user *models.User, +) (*mcp.CallToolResult, error) { + const cmdKey = "project ready" + dash, errRes := d.fetchDashboardJSON(ctx, input, user, cmdKey) + if errRes != nil { + return errRes, nil + } + suggestions := dashboardArrayField(dash, "suggested_next") + return packageStructuredResponse(cmdKey, map[string]any{ + "count": len(suggestions), + "results": suggestions, + }) +} + +// dispatchProjectStale reproduces `pad project stale --format json` — +// CLI filters the dashboard's Attention slice to "interesting" types +// (stalled / blocked / overdue / orphaned_task) before returning +// `{count, results}`. Sorting matches cmd/pad/query.go's +// filterAgentAttention: type, ItemRef, ItemTitle. +// +// Operates on the raw map[string]any decoded from the dashboard JSON +// so any field server.DashboardAttention adds in future versions +// (collection, plus anything not yet wired) flows through unchanged. +// Codex review on PR #348 round 1 caught the previous typed-struct +// approach dropping `collection` from the response. +func (d *HTTPHandlerDispatcher) dispatchProjectStale( + ctx context.Context, + input map[string]any, + user *models.User, +) (*mcp.CallToolResult, error) { + const cmdKey = "project stale" + dash, errRes := d.fetchDashboardJSON(ctx, input, user, cmdKey) + if errRes != nil { + return errRes, nil + } + attention := filterAgentAttention(dashboardArrayField(dash, "attention")) + return packageStructuredResponse(cmdKey, map[string]any{ + "count": len(attention), + "results": attention, + }) +} + +// fetchDashboardJSON hits the workspace dashboard endpoint and decodes +// the response into a generic map[string]any so the dispatcher +// preserves every field the server emits — no maintenance burden when +// new fields land on DashboardAttention / DashboardSuggestion. +// +// Returns the raw object so callers can pull specific fields +// (suggested_next, attention) via dashboardArrayField without the +// typed-struct round-trip. +func (d *HTTPHandlerDispatcher) fetchDashboardJSON( + ctx context.Context, + input map[string]any, + user *models.User, + cmdKey string, +) (map[string]any, *mcp.CallToolResult) { + workspace, _ := input["workspace"].(string) + if workspace == "" { + return nil, mcp.NewToolResultErrorf("%s: workspace is required", cmdKey) + } + path := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/dashboard" + req, err := d.buildAuthedRequest(ctx, http.MethodGet, path, nil, user) + if err != nil { + return nil, mcp.NewToolResultErrorf("%s: build dashboard request: %s", cmdKey, err.Error()) + } + rec := httptest.NewRecorder() + d.Handler.ServeHTTP(rec, req) + if rec.Code >= 400 { + body := strings.TrimSpace(rec.Body.String()) + if body == "" { + body = http.StatusText(rec.Code) + } + return nil, mcp.NewToolResultErrorf("%s: %d %s", cmdKey, rec.Code, body) + } + var dash map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &dash); err != nil { + return nil, mcp.NewToolResultErrorf("%s: parse dashboard: %s", cmdKey, err.Error()) + } + return dash, nil +} + +// dashboardArrayField pulls a named array out of a decoded dashboard +// payload, returning a typed []map[string]any so the callers can +// filter / sort by string fields without the json.Number / interface{} +// dance per element. Missing/empty/non-array values normalize to an +// empty slice so the {count, results} responses always emit a usable +// shape. +func dashboardArrayField(dash map[string]any, key string) []map[string]any { + raw, ok := dash[key].([]any) + if !ok { + return []map[string]any{} + } + out := make([]map[string]any, 0, len(raw)) + for _, e := range raw { + if m, ok := e.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// filterAgentAttention mirrors cmd/pad/query.go's helper of the same +// name — keeps only the attention types agents care about (stalled, +// blocked, overdue, orphaned_task) and sorts deterministically by +// (type, item_ref, item_title). Same stable ordering as the CLI so +// `--format json` outputs match between transports. +// +// Operates on map[string]any (not a typed struct) so attention +// entries pass through to the response with EVERY field the server +// emitted, not just the ones we knew to declare. Codex review on PR +// #348 caught the previous typed approach dropping `collection`. +func filterAgentAttention(attention []map[string]any) []map[string]any { + interesting := map[string]bool{ + "stalled": true, + "blocked": true, + "overdue": true, + "orphaned_task": true, + } + results := make([]map[string]any, 0, len(attention)) + for _, item := range attention { + typ, _ := item["type"].(string) + if interesting[typ] { + results = append(results, item) + } + } + sort.SliceStable(results, func(i, j int) bool { + ti, _ := results[i]["type"].(string) + tj, _ := results[j]["type"].(string) + if ti != tj { + return ti < tj + } + ri, _ := results[i]["item_ref"].(string) + rj, _ := results[j]["item_ref"].(string) + if ri != rj { + return ri < rj + } + titI, _ := results[i]["item_title"].(string) + titJ, _ := results[j]["item_title"].(string) + return titI < titJ + }) + return results +} + +// --- item bulk-update --- + +// dispatchItemBulkUpdate iterates the input's `ref` array and applies +// --status / --priority via the same read-modify-write semantics the +// item.update path uses (so existing fields survive). Mirrors the +// CLI's bulkUpdateCmd: at-least-one-of-status-or-priority gating, per- +// item GET → field merge → PATCH, and a per-item success/error report. +// +// The cmdhelp surface marks `ref` as required AND repeatable — agents +// pass it as either []any (typical JSON array) or []string. Anything +// else is rejected so the dispatcher doesn't silently iterate over +// nothing. +// +// Per-item failures don't abort the bulk operation; they get +// individually reported in the response so an agent can inspect what +// succeeded vs. failed without having to retry the whole batch. +func (d *HTTPHandlerDispatcher) dispatchItemBulkUpdate( + ctx context.Context, + input map[string]any, + user *models.User, +) (*mcp.CallToolResult, error) { + const cmdKey = "item bulk-update" + + workspace, _ := input["workspace"].(string) + if workspace == "" { + return mcp.NewToolResultErrorf("%s: workspace is required", cmdKey), nil + } + + refs, err := bulkUpdateRefs(input["ref"]) + if err != nil { + return mcp.NewToolResultErrorf("%s: %s", cmdKey, err.Error()), nil + } + if len(refs) == 0 { + return mcp.NewToolResultErrorf("%s: at least one ref is required", cmdKey), nil + } + + status, _ := input["status"].(string) + priority, _ := input["priority"].(string) + if status == "" && priority == "" { + return mcp.NewToolResultErrorf("%s: at least one of --status or --priority is required", cmdKey), nil + } + + type bulkResult struct { + Ref string `json:"ref"` + Updated bool `json:"updated"` + Error string `json:"error,omitempty"` + } + results := make([]bulkResult, 0, len(refs)) + successes := 0 + + for _, ref := range refs { + // Per-item RMW: GET, merge fields, PATCH. Same shape + // dispatchItemUpdate uses, but inlined here so a per-item + // failure produces a {ref, error} entry instead of aborting. + itemPath := "/api/v1/workspaces/" + url.PathEscape(workspace) + + "/items/" + url.PathEscape(ref) + + getReq, err := d.buildAuthedRequest(ctx, http.MethodGet, itemPath, nil, user) + if err != nil { + results = append(results, bulkResult{Ref: ref, Error: fmt.Sprintf("build request: %s", err.Error())}) + continue + } + getRec := httptest.NewRecorder() + d.Handler.ServeHTTP(getRec, getReq) + if getRec.Code >= 400 { + results = append(results, bulkResult{ + Ref: ref, + Error: fmt.Sprintf("read item: %d %s", getRec.Code, strings.TrimSpace(getRec.Body.String())), + }) + continue + } + var existing struct { + Fields string `json:"fields"` + } + if err := json.Unmarshal(getRec.Body.Bytes(), &existing); err != nil { + results = append(results, bulkResult{Ref: ref, Error: fmt.Sprintf("parse item: %s", err.Error())}) + continue + } + + merged := map[string]any{} + if existing.Fields != "" && existing.Fields != "{}" { + if err := json.Unmarshal([]byte(existing.Fields), &merged); err != nil { + results = append(results, bulkResult{Ref: ref, Error: fmt.Sprintf("parse existing fields: %s", err.Error())}) + continue + } + } + if status != "" { + merged["status"] = status + } + if priority != "" { + merged["priority"] = priority + } + fieldsJSON, err := json.Marshal(merged) + if err != nil { + results = append(results, bulkResult{Ref: ref, Error: fmt.Sprintf("encode fields: %s", err.Error())}) + continue + } + fieldsStr := string(fieldsJSON) + patchBody, err := json.Marshal(map[string]any{"fields": fieldsStr}) + if err != nil { + results = append(results, bulkResult{Ref: ref, Error: fmt.Sprintf("encode body: %s", err.Error())}) + continue + } + + patchReq, err := d.buildAuthedRequest(ctx, http.MethodPatch, itemPath, patchBody, user) + if err != nil { + results = append(results, bulkResult{Ref: ref, Error: fmt.Sprintf("build PATCH: %s", err.Error())}) + continue + } + patchRec := httptest.NewRecorder() + d.Handler.ServeHTTP(patchRec, patchReq) + if patchRec.Code >= 400 { + results = append(results, bulkResult{ + Ref: ref, + Error: fmt.Sprintf("update: %d %s", patchRec.Code, strings.TrimSpace(patchRec.Body.String())), + }) + continue + } + + results = append(results, bulkResult{Ref: ref, Updated: true}) + successes++ + } + + payload := map[string]any{ + "updated": successes, + "total": len(refs), + "results": results, + } + return packageStructuredResponse(cmdKey, payload) +} + +// bulkUpdateRefs canonicalizes the `ref` input — accepts repeatable +// shapes the cmdhelp registry generates (string for a single value, +// []any from JSON arrays, []string from typed callers) into a clean +// []string. Empty / non-string entries are rejected so we don't +// silently skip elements an agent expected to be processed. +func bulkUpdateRefs(raw any) ([]string, error) { + switch v := raw.(type) { + case nil: + return nil, nil + case string: + if v == "" { + return nil, nil + } + return []string{v}, nil + case []string: + out := make([]string, 0, len(v)) + for i, s := range v { + if s == "" { + return nil, fmt.Errorf("ref[%d] is empty", i) + } + out = append(out, s) + } + return out, nil + case []any: + out := make([]string, 0, len(v)) + for i, e := range v { + s, ok := e.(string) + if !ok { + return nil, fmt.Errorf("ref[%d] must be a string, got %T", i, e) + } + if s == "" { + return nil, fmt.Errorf("ref[%d] is empty", i) + } + out = append(out, s) + } + return out, nil + default: + return nil, fmt.Errorf("ref must be a string or array of strings, got %T", raw) + } +} + +// --- item note + decide (RMW append) --- + +// dispatchItemNote handles `pad item note +// [--details ...]` — appends an implementation-note entry to the +// item's structured-fields blob, then PATCHes. +// +// Same RMW shape as dispatchItemUpdate but using +// models.AppendImplementationNote so the entry gets the right shape +// + ID + timestamp the CLI applies. +// +// Emits the updated item (the PATCH response) like every other +// dispatcher — agents see the same shape they'd get from a follow-up +// `item show`. +func (d *HTTPHandlerDispatcher) dispatchItemNote( + ctx context.Context, + input map[string]any, + user *models.User, +) (*mcp.CallToolResult, error) { + const cmdKey = "item note" + + workspace, _ := input["workspace"].(string) + ref, _ := input["ref"].(string) + summary, _ := input["summary"].(string) + if workspace == "" { + return mcp.NewToolResultErrorf("%s: workspace is required", cmdKey), nil + } + if ref == "" { + return mcp.NewToolResultErrorf("%s: ref is required", cmdKey), nil + } + if summary == "" { + return mcp.NewToolResultErrorf("%s: summary is required", cmdKey), nil + } + details, _ := input["details"].(string) + details = strings.TrimSpace(details) + + itemPath := "/api/v1/workspaces/" + url.PathEscape(workspace) + + "/items/" + url.PathEscape(ref) + currentFields, errRes := d.prefetchItemFields(ctx, user, cmdKey, itemPath) + if errRes != nil { + return errRes, nil + } + + updated, err := models.AppendImplementationNote(currentFields, models.ItemImplementationNote{ + ID: newStructuredEntryID("note"), + Summary: strings.TrimSpace(summary), + Details: details, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + CreatedBy: userActorLabel(user), + }) + if err != nil { + return mcp.NewToolResultErrorf("%s: append note: %s", cmdKey, err.Error()), nil + } + + body, err := json.Marshal(map[string]any{"fields": updated}) + if err != nil { + return mcp.NewToolResultErrorf("%s: encode body: %s", cmdKey, err.Error()), nil + } + return d.executeRequest(ctx, cmdKey, user, http.MethodPatch, itemPath, body) +} + +// dispatchItemDecide is the decision-log analogue of +// dispatchItemNote — same RMW shape, just using +// AppendDecisionLogEntry on a different fields slot. +func (d *HTTPHandlerDispatcher) dispatchItemDecide( + ctx context.Context, + input map[string]any, + user *models.User, +) (*mcp.CallToolResult, error) { + const cmdKey = "item decide" + + workspace, _ := input["workspace"].(string) + ref, _ := input["ref"].(string) + decision, _ := input["decision"].(string) + if workspace == "" { + return mcp.NewToolResultErrorf("%s: workspace is required", cmdKey), nil + } + if ref == "" { + return mcp.NewToolResultErrorf("%s: ref is required", cmdKey), nil + } + if decision == "" { + return mcp.NewToolResultErrorf("%s: decision is required", cmdKey), nil + } + rationale, _ := input["rationale"].(string) + rationale = strings.TrimSpace(rationale) + + itemPath := "/api/v1/workspaces/" + url.PathEscape(workspace) + + "/items/" + url.PathEscape(ref) + currentFields, errRes := d.prefetchItemFields(ctx, user, cmdKey, itemPath) + if errRes != nil { + return errRes, nil + } + + updated, err := models.AppendDecisionLogEntry(currentFields, models.ItemDecisionLogEntry{ + ID: newStructuredEntryID("decision"), + Decision: strings.TrimSpace(decision), + Rationale: rationale, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + CreatedBy: userActorLabel(user), + }) + if err != nil { + return mcp.NewToolResultErrorf("%s: append decision: %s", cmdKey, err.Error()), nil + } + + body, err := json.Marshal(map[string]any{"fields": updated}) + if err != nil { + return mcp.NewToolResultErrorf("%s: encode body: %s", cmdKey, err.Error()), nil + } + return d.executeRequest(ctx, cmdKey, user, http.MethodPatch, itemPath, body) +} + +// prefetchItemFields GETs the item at itemPath and returns its +// `fields` JSON string. Surfaces 404s and parse errors as +// IsError-flagged tool results so the dispatcher's caller can return +// them directly without further wrapping. +// +// Used by note/decide which append into the existing fields blob — +// they need the current value so AppendImplementationNote / +// AppendDecisionLogEntry can preserve other entries. +func (d *HTTPHandlerDispatcher) prefetchItemFields( + ctx context.Context, + user *models.User, + cmdKey, itemPath string, +) (string, *mcp.CallToolResult) { + req, err := d.buildAuthedRequest(ctx, http.MethodGet, itemPath, nil, user) + if err != nil { + return "", mcp.NewToolResultErrorf("%s: build prefetch: %s", cmdKey, err.Error()) + } + rec := httptest.NewRecorder() + d.Handler.ServeHTTP(rec, req) + if rec.Code >= 400 { + body := strings.TrimSpace(rec.Body.String()) + if body == "" { + body = http.StatusText(rec.Code) + } + return "", mcp.NewToolResultErrorf("%s: prefetch: %d %s", cmdKey, rec.Code, body) + } + var existing struct { + Fields string `json:"fields"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &existing); err != nil { + return "", mcp.NewToolResultErrorf("%s: parse current item: %s", cmdKey, err.Error()) + } + return existing.Fields, nil +} + +// newStructuredEntryID mirrors the CLI's helper for note/decision +// IDs (cmd/pad/notes.go). The actual collision-avoidance is handled +// by combining the prefix + a unix-nano timestamp — same shape so +// CLI-created and MCP-created entries are indistinguishable in +// downstream consumers. +func newStructuredEntryID(prefix string) string { + return fmt.Sprintf("%s-%d", prefix, time.Now().UTC().UnixNano()) +} + +// userActorLabel produces a stable string label for the actor that +// created a structured entry. Mirrors the CLI's "user" label for +// CLI-driven entries; for MCP we use the requesting user's name (or +// email fallback) so audit-log review can tell who appended what +// when multiple users share the same MCP server. +func userActorLabel(user *models.User) string { + if user == nil { + return "user" + } + if user.Name != "" { + return user.Name + } + if user.Email != "" { + return user.Email + } + return "user" +} + +// dispatchLibraryList composes the /convention-library and +// /playbook-library endpoints to mirror `pad library list --format +// json`. The CLI's JSON output shape varies on --type: +// +// - --type conventions → returns the convention library (lib). +// - --type playbooks → returns the playbook library (plib). +// - (no --type) → returns {conventions: lib, playbooks: plib}. +// +// `--category` is intentionally not applied here — the CLI also +// doesn't filter the JSON output by category (it's purely a +// human-readable rendering filter). Agents that want category +// filtering can apply it client-side over the returned categories[]. +// +// The endpoints are global (no workspace), so we don't read +// `workspace` from input. Both endpoints require an authenticated +// user; the route table-level Apply hook handles that uniformly. +func (d *HTTPHandlerDispatcher) dispatchLibraryList( + ctx context.Context, + input map[string]any, + user *models.User, +) (*mcp.CallToolResult, error) { + const cmdKey = "library list" + typ, _ := input["type"].(string) + typ = strings.ToLower(strings.TrimSpace(typ)) + + wantConventions := typ == "" || typ == "conventions" + wantPlaybooks := typ == "" || typ == "playbooks" + if !wantConventions && !wantPlaybooks { + return mcp.NewToolResultErrorf( + "%s: unknown --type %q (expected: conventions, playbooks, or empty for both)", + cmdKey, typ, + ), nil + } + + var conventions any + var playbooks any + + if wantConventions { + v, errRes := d.fetchLibraryEndpoint(ctx, user, cmdKey, "/api/v1/convention-library") + if errRes != nil { + return errRes, nil + } + conventions = v + } + if wantPlaybooks { + v, errRes := d.fetchLibraryEndpoint(ctx, user, cmdKey, "/api/v1/playbook-library") + if errRes != nil { + return errRes, nil + } + playbooks = v + } + + // Single-type mode returns the library payload directly (matches + // the CLI). Both-types mode wraps in {conventions, playbooks}. + switch { + case wantConventions && wantPlaybooks: + return packageStructuredResponse(cmdKey, map[string]any{ + "conventions": conventions, + "playbooks": playbooks, + }) + case wantConventions: + return packageStructuredResponse(cmdKey, conventions) + default: + return packageStructuredResponse(cmdKey, playbooks) + } +} + +// fetchLibraryEndpoint GETs one of the library endpoints and decodes +// the JSON body into a generic any so the caller can stuff it into +// the composed response without losing the wire shape. +func (d *HTTPHandlerDispatcher) fetchLibraryEndpoint( + ctx context.Context, + user *models.User, + cmdKey, path string, +) (any, *mcp.CallToolResult) { + req, err := d.buildAuthedRequest(ctx, http.MethodGet, path, nil, user) + if err != nil { + return nil, mcp.NewToolResultErrorf("%s: build %s: %s", cmdKey, path, err.Error()) + } + rec := httptest.NewRecorder() + d.Handler.ServeHTTP(rec, req) + if rec.Code >= 400 { + body := strings.TrimSpace(rec.Body.String()) + if body == "" { + body = http.StatusText(rec.Code) + } + return nil, mcp.NewToolResultErrorf("%s: %s: %d %s", cmdKey, path, rec.Code, body) + } + var decoded any + if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { + return nil, mcp.NewToolResultErrorf("%s: parse %s: %s", cmdKey, path, err.Error()) + } + return decoded, nil +} diff --git a/internal/mcp/dispatch_http_project_test.go b/internal/mcp/dispatch_http_project_test.go new file mode 100644 index 00000000..372123c8 --- /dev/null +++ b/internal/mcp/dispatch_http_project_test.go @@ -0,0 +1,840 @@ +package mcp + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// --- project next / ready / stale --- + +func TestRouteTable_ProjectNextAliasesDashboard(t *testing.T) { + // `pad project next --format json` returns the FULL dashboard + // JSON verbatim (cmd/pad/main.go nextCmd's `cli.PrintJSON(dashJSON)` + // path). The MCP route-table entry is a straight alias; this test + // pins that the URL is the dashboard endpoint and the agent gets + // the same payload they'd get from `project dashboard`. + m, p, _, err := routeTable["project next"](map[string]any{"workspace": "docapp"}) + if err != nil { + t.Fatalf("routeTable[project next]: %v", err) + } + if m != http.MethodGet { + t.Errorf("method = %q", m) + } + if p != "/api/v1/workspaces/docapp/dashboard" { + t.Errorf("path = %q", p) + } +} + +func TestDispatch_ProjectReady_ReturnsCountResultsShape(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/workspaces/docapp/dashboard", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "suggested_next":[ + {"item_ref":"TASK-1","item_title":"First","reason":"high priority"}, + {"item_ref":"TASK-2","item_title":"Second","reason":"in_progress"} + ] + }`)) + }) + d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "u"})} + res, err := d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{"workspace": "docapp"}), + []string{"project", "ready"}, nil, + ) + if err != nil || res.IsError { + t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res) + } + payload, ok := res.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("not structured: %#v", res.StructuredContent) + } + if payload["count"].(float64) != 2 { + t.Errorf("count = %v, want 2", payload["count"]) + } + results, _ := payload["results"].([]any) + if len(results) != 2 { + t.Fatalf("results length = %d, want 2", len(results)) + } +} + +func TestDispatch_ProjectStale_FiltersInterestingTypes(t *testing.T) { + // Only stalled / blocked / overdue / orphaned_task survive the + // filter; idle, info, etc. are excluded. Mirrors the CLI's + // filterAgentAttention behaviour. + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/workspaces/docapp/dashboard", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "attention":[ + {"type":"stalled","item_ref":"TASK-1","item_title":"Stalled item","reason":"no activity","collection":"tasks","item_slug":"slug-1"}, + {"type":"info","item_ref":"TASK-2","item_title":"Just FYI","reason":"new"}, + {"type":"blocked","item_ref":"TASK-3","item_title":"Blocked","reason":"dep","collection":"tasks","item_slug":"slug-3"}, + {"type":"orphaned_task","item_ref":"TASK-4","item_title":"Orphan","reason":"no parent","collection":"tasks","item_slug":"slug-4"} + ] + }`)) + }) + d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "u"})} + res, err := d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{"workspace": "docapp"}), + []string{"project", "stale"}, nil, + ) + if err != nil || res.IsError { + t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res) + } + payload, _ := res.StructuredContent.(map[string]any) + if c := payload["count"].(float64); c != 3 { + t.Errorf("count = %v, want 3 (info filtered out)", c) + } + results, _ := payload["results"].([]any) + if len(results) != 3 { + t.Fatalf("results length = %d, want 3", len(results)) + } + // Sort order: type then item_ref. Expected: blocked TASK-3, + // orphaned_task TASK-4, stalled TASK-1. + wantOrder := []string{"blocked", "orphaned_task", "stalled"} + for i, w := range wantOrder { + entry, _ := results[i].(map[string]any) + if entry["type"] != w { + t.Errorf("results[%d].type = %v, want %v", i, entry["type"], w) + } + } +} + +func TestDispatch_ProjectStale_PreservesAllFields(t *testing.T) { + // Codex review on PR #348 round 1 caught the previous + // typed-struct approach dropping `collection` (and would have + // dropped any future server-side field addition). Pin that + // every field on each attention entry survives the filter + + // sort path. Treat the dispatcher's attention slice as a + // transparent wire-shape forwarder. + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/workspaces/docapp/dashboard", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "attention":[ + { + "type":"stalled", + "item_slug":"slug-stale", + "item_ref":"TASK-9", + "item_title":"Stale Task", + "collection":"tasks", + "reason":"no activity 7d", + "future_field":"forward-compat" + } + ] + }`)) + }) + d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "u"})} + res, err := d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{"workspace": "docapp"}), + []string{"project", "stale"}, nil, + ) + if err != nil || res.IsError { + t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res) + } + payload, _ := res.StructuredContent.(map[string]any) + results, _ := payload["results"].([]any) + if len(results) != 1 { + t.Fatalf("expected 1 result; got %d", len(results)) + } + entry, _ := results[0].(map[string]any) + wantFields := map[string]string{ + "type": "stalled", + "item_slug": "slug-stale", + "item_ref": "TASK-9", + "item_title": "Stale Task", + "collection": "tasks", + "reason": "no activity 7d", + "future_field": "forward-compat", + } + for k, v := range wantFields { + if got, _ := entry[k].(string); got != v { + t.Errorf("entry[%q] = %q, want %q", k, got, v) + } + } +} + +func TestDispatch_ProjectStaleAndReady_RequireWorkspace(t *testing.T) { + d := &HTTPHandlerDispatcher{ + Handler: errorHandler(t, "must not be called when workspace missing"), + UserResolver: fixedUserResolver(&models.User{ID: "u"}), + } + for _, cmd := range []string{"project ready", "project stale"} { + t.Run(cmd, func(t *testing.T) { + ctx := WithDispatchInput(context.Background(), map[string]any{}) + res, err := d.Dispatch(ctx, strings.Split(cmd, " "), nil) + if err != nil { + t.Fatalf("Dispatch err: %v", err) + } + if !res.IsError { + t.Errorf("expected IsError when workspace missing") + } + }) + } +} + +// --- project reconcile (noRemoteEquivalent) --- + +func TestDispatch_ProjectReconcileRejectedAsCLIOnly(t *testing.T) { + d := &HTTPHandlerDispatcher{ + Handler: errorHandler(t, "reconcile must not reach handler"), + UserResolver: fixedUserResolver(&models.User{ID: "u"}), + } + res, err := d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{"workspace": "docapp"}), + []string{"project", "reconcile"}, nil, + ) + if err != nil { + t.Fatalf("Dispatch err: %v", err) + } + if !res.IsError { + t.Errorf("expected IsError; got %#v", res) + } + if !containsToolText(res, "no remote equivalent") { + t.Errorf("expected stable noRemoteEquivalent message; got %#v", res) + } +} + +// --- collection create --- + +func TestMapCollectionCreate_RequiresNameAndWorkspace(t *testing.T) { + if _, _, _, err := mapCollectionCreate(map[string]any{"name": "X"}); err == nil { + t.Errorf("expected error when workspace missing") + } + if _, _, _, err := mapCollectionCreate(map[string]any{"workspace": "ws"}); err == nil { + t.Errorf("expected error when name missing") + } +} + +func TestMapCollectionCreate_ParsesFieldsDSL(t *testing.T) { + _, p, body, err := mapCollectionCreate(map[string]any{ + "workspace": "docapp", + "name": "Bugs", + "icon": "🐞", + "description": "Defect tracker", + "fields": "status:select:new,triaged,fixing;severity:select:low,medium,high;component:text", + }) + if err != nil { + t.Fatalf("mapCollectionCreate: %v", err) + } + if p != "/api/v1/workspaces/docapp/collections" { + t.Errorf("path = %q", p) + } + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("decode body: %v", err) + } + if got["name"] != "Bugs" { + t.Errorf("name = %v", got["name"]) + } + if got["icon"] != "🐞" { + t.Errorf("icon = %v", got["icon"]) + } + schemaStr, _ := got["schema"].(string) + var schema map[string]any + if err := json.Unmarshal([]byte(schemaStr), &schema); err != nil { + t.Fatalf("decode schema: %v", err) + } + fields, _ := schema["fields"].([]any) + if len(fields) != 3 { + t.Fatalf("fields length = %d, want 3", len(fields)) + } + first, _ := fields[0].(map[string]any) + if first["key"] != "status" || first["type"] != "select" { + t.Errorf("first field unexpected: %v", first) + } + if first["required"] != true { + t.Errorf("status select should be required: %v", first) + } + if first["default"] != "new" { + t.Errorf("status default should be first option (new); got %v", first["default"]) + } + opts, _ := first["options"].([]any) + wantOpts := []string{"new", "triaged", "fixing"} + if len(opts) != len(wantOpts) { + t.Fatalf("options length = %d, want %d", len(opts), len(wantOpts)) + } + for i, w := range wantOpts { + if opts[i] != w { + t.Errorf("options[%d] = %v, want %v", i, opts[i], w) + } + } + // Settings defaults populated. + settingsStr, _ := got["settings"].(string) + var settings map[string]any + if err := json.Unmarshal([]byte(settingsStr), &settings); err != nil { + t.Fatalf("decode settings: %v", err) + } + if settings["layout"] != "fields-primary" { + t.Errorf("default layout = %v", settings["layout"]) + } + if settings["default_view"] != "list" { + t.Errorf("default_view = %v", settings["default_view"]) + } + if settings["board_group_by"] != "status" { + t.Errorf("board_group_by = %v", settings["board_group_by"]) + } +} + +func TestParseCollectionFieldsDSL_LabelTitleCasesUnderscoredKey(t *testing.T) { + got, err := parseCollectionFieldsDSL("due_date:date") + if err != nil { + t.Fatalf("err: %v", err) + } + fields, _ := got["fields"].([]struct { + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` + Options []string `json:"options,omitempty"` + Required bool `json:"required,omitempty"` + Default string `json:"default,omitempty"` + }) + // The slice is the inner-typed struct; reflect via JSON round-trip. + jb, _ := json.Marshal(got["fields"]) + var rt []map[string]any + _ = json.Unmarshal(jb, &rt) + if len(rt) != 1 { + t.Fatalf("fields length = %d, want 1 (got %v)", len(rt), fields) + } + if rt[0]["label"] != "Due Date" { + t.Errorf("label = %v, want \"Due Date\"", rt[0]["label"]) + } +} + +func TestParseCollectionFieldsDSL_RejectsMalformedEntry(t *testing.T) { + _, err := parseCollectionFieldsDSL("bare-key-no-type") + if err == nil { + t.Errorf("expected error for entry with no type") + } +} + +func TestParseCollectionFieldsDSL_EmptyReturnsEmptyFields(t *testing.T) { + got, err := parseCollectionFieldsDSL("") + if err != nil { + t.Fatalf("err: %v", err) + } + jb, _ := json.Marshal(got["fields"]) + if string(jb) != "[]" { + t.Errorf("expected empty fields array; got %s", jb) + } +} + +// --- library list --- + +func TestDispatch_LibraryList_BothEndpoints(t *testing.T) { + mux := http.NewServeMux() + convCalls, pbCalls := 0, 0 + mux.HandleFunc("/api/v1/convention-library", func(w http.ResponseWriter, _ *http.Request) { + convCalls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"categories":[{"name":"git","conventions":[{"title":"C1"}]}]}`)) + }) + mux.HandleFunc("/api/v1/playbook-library", func(w http.ResponseWriter, _ *http.Request) { + pbCalls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"categories":[{"name":"flow","playbooks":[{"title":"P1"}]}]}`)) + }) + d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "u"})} + res, err := d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{}), + []string{"library", "list"}, nil, + ) + if err != nil || res.IsError { + t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res) + } + if convCalls != 1 || pbCalls != 1 { + t.Errorf("expected 1 call each; got conv=%d, pb=%d", convCalls, pbCalls) + } + payload, ok := res.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("expected composed map; got %#v", res.StructuredContent) + } + if _, ok := payload["conventions"]; !ok { + t.Errorf("missing conventions: %v", payload) + } + if _, ok := payload["playbooks"]; !ok { + t.Errorf("missing playbooks: %v", payload) + } +} + +func TestDispatch_LibraryList_TypeFilterSkipsOtherEndpoint(t *testing.T) { + mux := http.NewServeMux() + convCalls, pbCalls := 0, 0 + mux.HandleFunc("/api/v1/convention-library", func(w http.ResponseWriter, _ *http.Request) { + convCalls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"categories":[]}`)) + }) + mux.HandleFunc("/api/v1/playbook-library", func(w http.ResponseWriter, _ *http.Request) { + pbCalls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"categories":[]}`)) + }) + d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "u"})} + + // type=conventions skips the playbook endpoint. + _, _ = d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{"type": "conventions"}), + []string{"library", "list"}, nil, + ) + if pbCalls != 0 { + t.Errorf("playbook endpoint should not be called for type=conventions; got %d", pbCalls) + } + convCalls = 0 + + // type=playbooks skips conventions. + _, _ = d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{"type": "playbooks"}), + []string{"library", "list"}, nil, + ) + if convCalls != 0 { + t.Errorf("conventions endpoint should not be called for type=playbooks; got %d", convCalls) + } +} + +func TestDispatch_LibraryList_RejectsUnknownType(t *testing.T) { + d := &HTTPHandlerDispatcher{ + Handler: errorHandler(t, "no endpoint should be hit for unknown type"), + UserResolver: fixedUserResolver(&models.User{ID: "u"}), + } + res, err := d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{"type": "bogus"}), + []string{"library", "list"}, nil, + ) + if err != nil { + t.Fatalf("Dispatch err: %v", err) + } + if !res.IsError { + t.Errorf("expected IsError for unknown --type") + } +} + +// --- item bulk-update --- + +func TestBulkUpdateRefs_AcceptsCommonShapes(t *testing.T) { + cases := []struct { + name string + in any + want []string + }{ + {"single string", "TASK-5", []string{"TASK-5"}}, + {"[]string", []string{"TASK-1", "TASK-2"}, []string{"TASK-1", "TASK-2"}}, + {"[]any", []any{"TASK-3", "TASK-4"}, []string{"TASK-3", "TASK-4"}}, + {"nil", nil, nil}, + {"empty string", "", nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := bulkUpdateRefs(tc.in) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != len(tc.want) { + t.Fatalf("length = %d, want %d", len(got), len(tc.want)) + } + for i, v := range tc.want { + if got[i] != v { + t.Errorf("[%d] = %q, want %q", i, got[i], v) + } + } + }) + } +} + +func TestBulkUpdateRefs_RejectsBadEntries(t *testing.T) { + if _, err := bulkUpdateRefs([]any{"OK", 42}); err == nil { + t.Errorf("expected error for non-string entry") + } + if _, err := bulkUpdateRefs([]any{"OK", ""}); err == nil { + t.Errorf("expected error for empty entry") + } + if _, err := bulkUpdateRefs(map[string]any{}); err == nil { + t.Errorf("expected error for unsupported type") + } +} + +func TestDispatch_ItemBulkUpdate_RequiresStatusOrPriority(t *testing.T) { + d := &HTTPHandlerDispatcher{ + Handler: errorHandler(t, "must not run without status or priority"), + UserResolver: fixedUserResolver(&models.User{ID: "u"}), + } + res, err := d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{ + "workspace": "docapp", + "ref": []any{"TASK-1"}, + }), + []string{"item", "bulk-update"}, nil, + ) + if err != nil { + t.Fatalf("Dispatch err: %v", err) + } + if !res.IsError { + t.Errorf("expected IsError") + } + if !containsToolText(res, "at least one") { + t.Errorf("error should explain status/priority requirement; got %#v", res) + } +} + +func TestDispatch_ItemBulkUpdate_PerItemFailureDoesNotAbort(t *testing.T) { + // First ref fails GET (404); second succeeds. The dispatcher + // must report both — successes get Updated:true, failures get + // Error populated. Mirrors the CLI's per-item green/red output. + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/workspaces/docapp/items/TASK-9", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + mux.HandleFunc("/api/v1/workspaces/docapp/items/TASK-1", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ref":"TASK-1","fields":"{\"status\":\"open\"}"}`)) + case http.MethodPatch: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ref":"TASK-1"}`)) + } + }) + d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "u"})} + res, err := d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{ + "workspace": "docapp", + "ref": []any{"TASK-9", "TASK-1"}, + "status": "done", + }), + []string{"item", "bulk-update"}, nil, + ) + if err != nil || res.IsError { + t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res) + } + payload, _ := res.StructuredContent.(map[string]any) + if payload["updated"].(float64) != 1 { + t.Errorf("updated = %v, want 1", payload["updated"]) + } + if payload["total"].(float64) != 2 { + t.Errorf("total = %v, want 2", payload["total"]) + } + results, _ := payload["results"].([]any) + if len(results) != 2 { + t.Fatalf("results length = %d, want 2", len(results)) + } + first, _ := results[0].(map[string]any) + if first["error"] == nil || first["error"] == "" { + t.Errorf("first result should have error: %v", first) + } + second, _ := results[1].(map[string]any) + if second["updated"] != true { + t.Errorf("second result should be updated: %v", second) + } +} + +func TestDispatch_ItemBulkUpdate_MergesExistingFields(t *testing.T) { + // Bulk-update applies the same RMW merge item.update uses: the + // existing priority "high" must survive when only --status is + // being changed. + mux := http.NewServeMux() + patchedFields := "" + mux.HandleFunc("/api/v1/workspaces/docapp/items/TASK-1", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ref":"TASK-1","fields":"{\"status\":\"open\",\"priority\":\"high\",\"category\":\"bug\"}"}`)) + case http.MethodPatch: + body := make([]byte, r.ContentLength) + _, _ = r.Body.Read(body) + var got map[string]any + _ = json.Unmarshal(body, &got) + patchedFields, _ = got["fields"].(string) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ref":"TASK-1"}`)) + } + }) + d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "u"})} + _, _ = d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{ + "workspace": "docapp", + "ref": []any{"TASK-1"}, + "status": "in-progress", + }), + []string{"item", "bulk-update"}, nil, + ) + var fields map[string]any + if err := json.Unmarshal([]byte(patchedFields), &fields); err != nil { + t.Fatalf("decode patched fields: %v", err) + } + if fields["status"] != "in-progress" { + t.Errorf("status not updated: %v", fields) + } + if fields["priority"] != "high" { + t.Errorf("priority should survive RMW: %v", fields) + } + if fields["category"] != "bug" { + t.Errorf("category should survive RMW: %v", fields) + } +} + +// --- item note + decide --- + +func TestDispatch_ItemNote_AppendsToFields(t *testing.T) { + mux := http.NewServeMux() + patched := "" + mux.HandleFunc("/api/v1/workspaces/docapp/items/TASK-5", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ref":"TASK-5","fields":"{\"status\":\"open\"}"}`)) + case http.MethodPatch: + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + var got map[string]any + _ = json.Unmarshal(buf, &got) + patched, _ = got["fields"].(string) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ref":"TASK-5"}`)) + } + }) + d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "u", Name: "Dave"})} + res, err := d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{ + "workspace": "docapp", + "ref": "TASK-5", + "summary": "Investigated mutex bug", + "details": "Race in handler; needs lock around shared state", + }), + []string{"item", "note"}, nil, + ) + if err != nil || res.IsError { + t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res) + } + var fields map[string]any + if err := json.Unmarshal([]byte(patched), &fields); err != nil { + t.Fatalf("decode patched fields: %v", err) + } + notes, ok := fields["implementation_notes"].([]any) + if !ok || len(notes) != 1 { + t.Fatalf("expected one implementation_note; got %#v", fields["implementation_notes"]) + } + note, _ := notes[0].(map[string]any) + if note["summary"] != "Investigated mutex bug" { + t.Errorf("summary = %v", note["summary"]) + } + if note["details"] != "Race in handler; needs lock around shared state" { + t.Errorf("details = %v", note["details"]) + } + if note["created_by"] != "Dave" { + t.Errorf("created_by = %v, want Dave (user.Name fallback)", note["created_by"]) + } +} + +func TestDispatch_ItemNote_RequiresArgs(t *testing.T) { + d := &HTTPHandlerDispatcher{ + Handler: errorHandler(t, "must not run when args missing"), + UserResolver: fixedUserResolver(&models.User{ID: "u"}), + } + for _, missing := range []string{"workspace", "ref", "summary"} { + t.Run("missing-"+missing, func(t *testing.T) { + input := map[string]any{ + "workspace": "ws", "ref": "TASK-1", "summary": "x", + } + delete(input, missing) + res, err := d.Dispatch( + WithDispatchInput(context.Background(), input), + []string{"item", "note"}, nil, + ) + if err != nil { + t.Fatalf("Dispatch err: %v", err) + } + if !res.IsError { + t.Errorf("expected IsError when %s missing", missing) + } + }) + } +} + +func TestDispatch_ItemDecide_AppendsToDecisionLog(t *testing.T) { + mux := http.NewServeMux() + patched := "" + mux.HandleFunc("/api/v1/workspaces/docapp/items/TASK-5", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ref":"TASK-5","fields":"{}"}`)) + case http.MethodPatch: + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + var got map[string]any + _ = json.Unmarshal(buf, &got) + patched, _ = got["fields"].(string) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ref":"TASK-5"}`)) + } + }) + d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "u", Name: "Dave"})} + res, err := d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{ + "workspace": "docapp", + "ref": "TASK-5", + "decision": "Use Redis for caching", + "rationale": "Memory pressure on the in-memory cache", + }), + []string{"item", "decide"}, nil, + ) + if err != nil || res.IsError { + t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res) + } + var fields map[string]any + if err := json.Unmarshal([]byte(patched), &fields); err != nil { + t.Fatalf("decode patched: %v", err) + } + log, ok := fields["decision_log"].([]any) + if !ok || len(log) != 1 { + t.Fatalf("expected one decision_log entry; got %#v", fields["decision_log"]) + } + entry, _ := log[0].(map[string]any) + if entry["decision"] != "Use Redis for caching" { + t.Errorf("decision = %v", entry["decision"]) + } + if entry["rationale"] != "Memory pressure on the in-memory cache" { + t.Errorf("rationale = %v", entry["rationale"]) + } +} + +// --- Integration smoke --- + +func TestHTTPHandlerDispatcher_Integration_Slice3(t *testing.T) { + srv, st := newPadServer(t) + wsRec := doJSONReq(t, srv, http.MethodPost, "/api/v1/workspaces", + map[string]any{"name": "DocApp"}) + if wsRec.Code != http.StatusCreated { + t.Fatalf("create workspace: %d %s", wsRec.Code, wsRec.Body.String()) + } + var ws models.Workspace + if err := json.Unmarshal(wsRec.Body.Bytes(), &ws); err != nil { + t.Fatalf("decode workspace: %v", err) + } + owner, err := st.CreateUser(models.UserCreate{Email: "dave@example.com", Name: "Dave", Password: "x"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + if err := st.AddWorkspaceMember(ws.ID, owner.ID, "owner"); err != nil { + t.Fatalf("add owner: %v", err) + } + + d := &HTTPHandlerDispatcher{Handler: srv, UserResolver: fixedUserResolver(owner)} + + // Create two items, then bulk-update them. + mkItem := func(title string) string { + ctx := WithDispatchInput(context.Background(), map[string]any{ + "workspace": ws.Slug, "collection": "tasks", "title": title, + "priority": "high", + }) + res, err := d.Dispatch(ctx, []string{"item", "create"}, nil) + if err != nil || res.IsError { + t.Fatalf("create %q: err=%v IsError=%v: %#v", title, err, res != nil && res.IsError, res) + } + m, _ := res.StructuredContent.(map[string]any) + ref, _ := m["ref"].(string) + if ref == "" { + t.Fatalf("no ref: %#v", m) + } + return ref + } + ref1 := mkItem("Item one") + ref2 := mkItem("Item two") + + bulkRes, err := d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{ + "workspace": ws.Slug, + "ref": []any{ref1, ref2}, + "status": "in-progress", + }), + []string{"item", "bulk-update"}, nil, + ) + if err != nil || bulkRes.IsError { + t.Fatalf("bulk-update: err=%v IsError=%v: %#v", err, bulkRes != nil && bulkRes.IsError, bulkRes) + } + bulk, _ := bulkRes.StructuredContent.(map[string]any) + if bulk["updated"].(float64) != 2 { + t.Errorf("expected both refs updated; got %v", bulk["updated"]) + } + + // Verify priority survived via item show. + showRes, err := d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{ + "workspace": ws.Slug, "ref": ref1, + }), + []string{"item", "show"}, nil, + ) + if err != nil || showRes.IsError { + t.Fatalf("item show: %#v", showRes) + } + shown, _ := showRes.StructuredContent.(map[string]any) + fieldsStr, _ := shown["fields"].(string) + var fields map[string]any + _ = json.Unmarshal([]byte(fieldsStr), &fields) + if fields["priority"] != "high" { + t.Errorf("priority should survive bulk-update; got %v", fields["priority"]) + } + if fields["status"] != "in-progress" { + t.Errorf("status should be updated; got %v", fields["status"]) + } + + // Add a note via item.note. + noteRes, err := d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{ + "workspace": ws.Slug, "ref": ref1, + "summary": "Investigated dependency", + "details": "Found root cause", + }), + []string{"item", "note"}, nil, + ) + if err != nil || noteRes.IsError { + t.Fatalf("item note: %#v", noteRes) + } + + // project ready / project stale shouldn't 500 against the real handler. + for _, cmd := range []string{"project ready", "project stale"} { + res, err := d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{"workspace": ws.Slug}), + strings.Split(cmd, " "), nil, + ) + if err != nil || res.IsError { + t.Errorf("%s: err=%v IsError=%v: %#v", cmd, err, res != nil && res.IsError, res) + } + } + + // Collection create end-to-end. + collRes, err := d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{ + "workspace": ws.Slug, + "name": "Bugs", + "icon": "🐞", + "description": "Defect tracker", + "fields": "status:select:new,fixing,resolved;severity:text", + }), + []string{"collection", "create"}, nil, + ) + if err != nil || collRes.IsError { + t.Fatalf("collection create: %#v", collRes) + } + + // Library list (global, no workspace). + libRes, err := d.Dispatch( + WithDispatchInput(context.Background(), map[string]any{}), + []string{"library", "list"}, nil, + ) + if err != nil || libRes.IsError { + t.Fatalf("library list: %#v", libRes) + } + libPayload, _ := libRes.StructuredContent.(map[string]any) + if _, has := libPayload["conventions"]; !has { + t.Errorf("library list (composed) should include conventions: %v", libPayload) + } + if _, has := libPayload["playbooks"]; !has { + t.Errorf("library list (composed) should include playbooks: %v", libPayload) + } +} diff --git a/internal/mcp/dispatch_http_routes.go b/internal/mcp/dispatch_http_routes.go index e5c25d9d..ebcb0435 100644 --- a/internal/mcp/dispatch_http_routes.go +++ b/internal/mcp/dispatch_http_routes.go @@ -305,9 +305,182 @@ func init() { // can pass an explicit `workspace` param to scope if they want). "workspace audit-log": mapWorkspaceAuditLog, "workspace invite": mapWorkspaceInvite, + + // --- TASK-968 follow-up: project intelligence + admin extras --- + // `project next` returns the full dashboard JSON — same shape the + // CLI's `--format json` output emits (cmd/pad/main.go nextCmd + // returns dashJSON verbatim). `ready` and `stale` get custom + // dispatchers because their CLI JSON output is `{count, results}` + // post-filter, not the raw dashboard. + "project next": routeSpec{ + method: http.MethodGet, + pathTemplate: "/api/v1/workspaces/{workspace}/dashboard", + }.toRouteMapper(), + + // --- Admin: collections --- + "collection create": mapCollectionCreate, + + // --- Admin: library --- + // `library list` is global (no workspace) — composes the + // /convention-library and /playbook-library endpoints based on + // --type. Custom dispatcher because the response is composed + // from multiple endpoints when --type is unset. } } +// mapCollectionCreate dispatches `pad collection create +// [--fields key:type[:opts]; ...] [--icon ...] [--description ...] +// [--layout ...] [--default-view ...] [--board-group-by ...]`. +// +// POST /api/v1/workspaces/{ws}/collections with body matching +// models.CollectionCreate. Mirrors the CLI's DSL parsing in +// cmd/pad/main.go's collectionsCreateCmd: +// +// - Split --fields on `;`, then each on `:` (max 3 parts): +// `key:type[:opts]` where opts are comma-separated. +// - Auto-fill Label as title-cased(key with `_` → ` `). +// - First select-typed `status` field is marked required + default +// (matches CLI; the handler also enforces this on its side). +// - Layout defaults to "fields-primary"; default_view to "list"; +// board_group_by to "status" (matches CLI's flag defaults). +// +// Schema and Settings are JSON-encoded into strings before the POST +// because CollectionCreate.Schema and .Settings are `string`-typed +// columns the handler decodes downstream. +func mapCollectionCreate(input map[string]any) (string, string, []byte, error) { + workspace, _ := input["workspace"].(string) + if workspace == "" { + return "", "", nil, fmt.Errorf("workspace is required") + } + name, _ := input["name"].(string) + if name == "" { + return "", "", nil, fmt.Errorf("name is required") + } + + dsl, _ := input["fields"].(string) + schema, err := parseCollectionFieldsDSL(dsl) + if err != nil { + return "", "", nil, fmt.Errorf("parse --fields: %w", err) + } + schemaJSON, err := json.Marshal(schema) + if err != nil { + return "", "", nil, fmt.Errorf("encode schema: %w", err) + } + + layout, _ := input["layout"].(string) + if layout == "" { + layout = "fields-primary" + } + defaultView, _ := input["default_view"].(string) + if defaultView == "" { + defaultView = "list" + } + boardGroupBy, _ := input["board_group_by"].(string) + if boardGroupBy == "" { + boardGroupBy = "status" + } + settings := map[string]any{ + "layout": layout, + "default_view": defaultView, + "board_group_by": boardGroupBy, + } + settingsJSON, err := json.Marshal(settings) + if err != nil { + return "", "", nil, fmt.Errorf("encode settings: %w", err) + } + + payload := map[string]any{ + "name": name, + "schema": string(schemaJSON), + "settings": string(settingsJSON), + } + if v, _ := input["icon"].(string); v != "" { + payload["icon"] = v + } + if v, _ := input["description"].(string); v != "" { + payload["description"] = v + } + body, err := json.Marshal(payload) + if err != nil { + return "", "", nil, fmt.Errorf("encode body: %w", err) + } + urlPath := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/collections" + return http.MethodPost, urlPath, body, nil +} + +// parseCollectionFieldsDSL parses the CLI's --fields DSL into a +// {fields: [...]} map ready for json.Marshal. Empty input returns an +// empty Fields slice (the handler accepts that — collections without +// custom fields are valid). +// +// Matches cmd/pad/main.go's collectionsCreateCmd parsing exactly: +// +// - Splits on `;`. Whitespace and empty entries between are skipped. +// - Each entry splits on `:` (max 3 parts). +// - Fewer than 2 parts → error (caller wraps as "parse --fields"). +// - Third part splits on `,` for select options. +// - status select gets required:true + default := first option. +// +// Lives here so the MCP collection.create surface stays in lockstep +// with the CLI without an internal/cli or cmd/pad import. +func parseCollectionFieldsDSL(dsl string) (map[string]any, error) { + type fieldDef struct { + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` + Options []string `json:"options,omitempty"` + Required bool `json:"required,omitempty"` + Default string `json:"default,omitempty"` + } + out := map[string]any{"fields": []fieldDef{}} + if dsl == "" { + return out, nil + } + + fields := []fieldDef{} + for _, raw := range strings.Split(dsl, ";") { + f := strings.TrimSpace(raw) + if f == "" { + continue + } + parts := strings.SplitN(f, ":", 3) + if len(parts) < 2 { + return nil, fmt.Errorf("invalid field definition %q (expected key:type[:options])", f) + } + fd := fieldDef{ + Key: parts[0], + Label: titleCaseLabel(parts[0]), + Type: parts[1], + } + if len(parts) == 3 && parts[2] != "" { + fd.Options = strings.Split(parts[2], ",") + } + if fd.Type == "select" && fd.Key == "status" { + fd.Required = true + if len(fd.Options) > 0 { + fd.Default = fd.Options[0] + } + } + fields = append(fields, fd) + } + out["fields"] = fields + return out, nil +} + +// titleCaseLabel converts a snake_case key into a Title Case label +// the same way the CLI does ("due_date" → "Due Date"). Avoids +// pulling in golang.org/x/text/cases for a one-line transformation. +func titleCaseLabel(key string) string { + parts := strings.Split(strings.ReplaceAll(key, "_", " "), " ") + for i, p := range parts { + if p == "" { + continue + } + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + return strings.Join(parts, " ") +} + // mapItemStarred dispatches `pad item starred [--all]`. // // GET /api/v1/workspaces/{ws}/starred?include_terminal=true when