mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user