feat(mcp): project intel + collection create + library list + bulk-update + note/decide (TASK-968 partial) (#348)

* feat(mcp): wire project intel + collection create + library list + bulk-update + note/decide (TASK-968 partial)

Continues TASK-968 past PR #347's stars/roles/webhooks slice. Nine more
commands land here, one more joins noRemoteEquivalent.

New commands:
  - project next               → alias /dashboard (matches CLI's verbatim
                                  --format json output)
  - project ready              → custom: extracts suggested_next as
                                  {count, results}
  - project stale              → custom: filters dashboard.attention to
                                  interesting types (stalled/blocked/
                                  overdue/orphaned_task), sorted
  - collection create          → custom: parses --fields DSL
                                  (key:type[:opts];...) into
                                  CollectionSchema, builds settings
  - library list               → composes /convention-library +
                                  /playbook-library based on --type
  - item bulk-update           → iterates refs with per-item RMW;
                                  per-item failures surface in results
                                  rather than aborting
  - item note                  → RMW append using
                                  models.AppendImplementationNote
  - item decide                → RMW append using
                                  models.AppendDecisionLogEntry

Extended noRemoteEquivalent:
  - project reconcile          → shells out to `gh` CLI for live PR
                                  state, same locality reasoning as
                                  the github commands

The project-intelligence dispatchers reproduce the CLI's --format json
shapes exactly:
  - `next` returns dashJSON verbatim (CLI does the same)
  - `ready` returns {count, results} extracted from suggested_next
  - `stale` returns {count, results} after filterAgentAttention's
    type filter + (type, ItemRef, ItemTitle) sort

Aliasing all three to /dashboard would diverge — agents would see an
unexpected wrapper shape.

`item bulk-update` mirrors the CLI's per-item RMW loop, including the
"existing fields survive" guarantee. The response shape
{updated, total, results[]} makes per-item outcomes available so the
agent can inspect what succeeded vs. failed without re-querying.
Per-item refs accept string / []string / []any for schema-permissive
callers.

`item note` / `item decide` reuse models.AppendImplementationNote /
AppendDecisionLogEntry so CLI-created and MCP-created entries are
indistinguishable. The created_by field uses the requesting user's
name (or email fallback) so audit trails work in multi-user MCP
deployments — the CLI hardcodes "user" since it's single-user-per-
process.

Tests:
  - Per-command happy + missing-input rejection.
  - project ready/stale shape pinned (count, results); stale's filter
    + sort order verified.
  - parseCollectionFieldsDSL pinned: title-cased labels, status:select
    auto-required+default, malformed entries rejected, empty input
    returns empty fields[].
  - library list type-filter skips other endpoint when --type set;
    unknown --type rejected with clear error.
  - bulk-update: per-item failure doesn't abort batch; existing
    fields survive RMW; status/priority required gating.
  - note/decide: AppendImplementationNote/Decision entries land in
    fields with correct created_by user label.
  - project reconcile rejected with stable noRemoteEquivalent message.
  - Integration smoke against real *server.Server: create+bulk-update
    +note → project ready/stale → collection create → library list.

Cumulative TASK-968 progress: 33/~50 commands wired across PR #346,
#347, this PR. Remaining sections: project standup + changelog
(multi-call composition); library activate (model-helper composition);
attachments (multipart, separate PR).

Parent: PLAN-943.

* fix(mcp): preserve all dashboard.attention fields by switching to map-based decoding per Codex review (round 1)

Codex caught that projectAttention's typed-struct round-trip dropped
the `collection` field from the dashboard's attention entries — and
would have dropped any future field additions silently. Same risk
applied to projectSuggestion.

Fix: stop decoding into a reduced typed struct. The dispatcher now
unmarshals dashboard JSON into map[string]any, pulls named arrays via
dashboardArrayField helper, and operates on the maps directly through
filterAgentAttention's filter+sort. Result: every field the server
emitted on each attention/suggestion entry survives to the response,
no maintenance burden when handlers add new fields.

filterAgentAttention now operates on []map[string]any with
typed-string asserts at the comparator. Same filter set (stalled /
blocked / overdue / orphaned_task) and same (type, item_ref,
item_title) sort order — output ordering still pinned by the
existing test.

Test added: TestDispatch_ProjectStale_PreservesAllFields seeds an
attention entry with every documented field PLUS a forward-compat
`future_field` and asserts all of them flow through to the response.
That regression-pins the wire-shape forwarder behaviour.

Parent: PLAN-943.
This commit is contained in:
xarmian
2026-05-01 14:09:59 -04:00
committed by GitHub
parent e2127868d8
commit 1fc41f2f4a
4 changed files with 1643 additions and 0 deletions
+18
View File
@@ -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
+612
View File
@@ -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 <ref> <summary>
// [--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
}
+840
View File
@@ -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)
}
}
+173
View File
@@ -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 <name>
// [--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