Files
pad/internal/server/handlers_project_intel.go
T
xarmian ae62c097ca refactor(mcp): consolidate project next/standup/changelog onto REST endpoints (TASK-1916) (#802)
* refactor(mcp): consolidate project next/standup/changelog onto REST endpoints (TASK-1916)

dispatchProjectNext/Standup/Changelog were a second server-side copy of
the next/standup/changelog reshaping contract, written before TASK-1894
shipped dedicated REST endpoints for the same data. Replace the ~200
lines of duplicate reshaping with thin proxies that validate workspace
(preserving the pad_set_workspace hint) and forward to
GET /next|/standup|/changelog, relying on packageHTTPResponse's existing
array-wrap (BUG-985) and the REST handlers' own days-default and
per-status best-effort semantics rather than replicating them.

dispatch_http_slice4.go is deleted; its unrelated dispatchLibraryActivate
moves to dispatch_http_library.go now that the file's other three
methods are gone. KEEP IN SYNC comments across
handlers_project_intel.go/server.go/tests collapse from "three
reproductions" to "CLI + REST, MCP proxies to REST."

* fix(server): pass nav-lenient visibleIDs to changelog's parent enrichment (codex R1 P1, TASK-1916)

handleGetProjectChangelog passed guestResourceFilter's narrowed collIDs
into enrichItemsWithParent instead of the nav-lenient visibleCollectionIDs
set handleListItems uses for the same enrichment call. For a guest whose
granted item's parent lives in an item-grant-only collection (nav-visible
but excluded from the narrowed full-access set), this silently dropped
the parent link fields, causing itemMatchesParentFilter to exclude the
item from ?parent= results even though the guest can otherwise see it.

The root cause predates TASK-1916 (introduced alongside the REST endpoint
in TASK-1894), but this consolidation imports it into MCP wire behavior
via dispatchProjectChangelog's proxy, so it's in scope to fix here.

projectIntelVisibility now returns the unnarrowed visibleCollectionIDs
result (navVisibleIDs) alongside the existing (collIDs, itemIDs) pair;
handleGetProjectChangelog uses navVisibleIDs for enrichItemsWithParent
while keeping collIDs for the list query, mirroring handleListItems'
pattern exactly. handleGetProjectStandup and handleGetProjectNext have no
parallel enrichItemsWithParent call (verified by reading both, and
buildDashboardResponse) so neither needed the same treatment.

Added TestProjectChangelogEndpoint_GuestParentFilter_ItemGrantOnlyCollection,
confirmed to fail against the pre-fix code and pass against the fix.
2026-07-03 22:29:40 -04:00

443 lines
17 KiB
Go

package server
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
// This file implements the browser/REST surface for the "project
// intelligence" reads (PLAN-1888 / TASK-1894): next, standup, changelog.
//
// KEEP IN SYNC — these three handlers are the canonical server-side
// reshaping contract, alongside cmd/pad/main.go's nextCmd / standupCmd /
// changelogCmd (the CLI implementations of `pad project
// next|standup|changelog --format json`). The two must agree on defaults,
// per-status best-effort semantics, and output shape.
//
// internal/mcp/dispatch_http_project.go's dispatchProjectNext /
// dispatchProjectStandup / dispatchProjectChangelog are NOT a third
// reproduction — as of TASK-1916 they're thin proxies that forward to these
// handlers (GET /next, /standup, /changelog) and package the response, so
// reshaping/default changes made here reach the MCP transport automatically
// with no matching change required there.
//
// Asymmetry resolved (TASK-1894 codex round 1 / BUG-1917): handleGetProjectStandup
// scopes its own completed/in_progress ListItems calls through
// projectIntelVisibility (see below), and its dashboard-derived sections
// (blockers, suggested_next) come from buildDashboardResponse — both now sit
// on the same bearer-gated visibleCollectionIDs (server.go), so a bearer-authed
// platform admin who is only a restricted member of the workspace gets
// correctly-scoped completed/in_progress AND blockers/suggested_next from
// standup. handleGetProjectNext stays on buildDashboardResponse's visibility
// so it keeps exact parity with dashboard.suggested_next; that parity was the
// reason the two couldn't diverge on gating in the first place.
// parseDaysParam reads a "days" query param with a fallback default. Mirrors
// the CLI's `n > 0` gate (standupCmd/changelogCmd flag defaults); the MCP
// HTTP transport forwards `days` as-is and relies on this same gate rather
// than replicating it (TASK-1916). Absent, non-numeric, zero, or negative
// all silently fall back to the default rather than erroring — this also
// matches this codebase's existing REST convention for lenient numeric
// query params (handleGetWorkspaceGraph's
// `depth`, GetReport's `window`), so days is not a divergence from either
// sibling or the surrounding REST style.
func parseDaysParam(r *http.Request, def int) int {
raw := strings.TrimSpace(r.URL.Query().Get("days"))
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return def
}
return n
}
// itemMatchesParentFilter mirrors the CLI's parent-filter matching
// (cmd/pad/main.go's changelogCmd): case-insensitive comparison against the
// item's parent link id, parent ref, or parent title. Lets an agent/user
// pass a UUID, an issue ref like "PLAN-3", or the human-readable title.
func itemMatchesParentFilter(item models.Item, parent string) bool {
for _, v := range []string{item.ParentLinkID, item.ParentRef, item.ParentTitle} {
if v != "" && strings.EqualFold(v, parent) {
return true
}
}
return false
}
// projectIntelVisibility computes the (collectionIDs, itemIDs)
// visibility-scoping pair for the item-list calls the standup/changelog
// handlers make directly (outside of buildDashboardResponse), using
// visibleCollectionIDs (server.go), which is bearer-gated (BUG-1917).
//
// This does NOT delegate to reportVisibleCollections despite the shared
// admin-bypass gate: reportVisibleCollections returns (scopeToVisible bool,
// ids []string) and deliberately drops item-level grant granularity, because
// aggregate report counts have no item-level filtering (a collection is
// either fully in-scope or fully excluded — see its doc comment). standup and
// changelog list actual items, so they need the same item-level grant
// filtering handleListItems applies (ItemListParams.ItemIDs) — hence the
// (collIDs, itemIDs) shape and the guestResourceFilter call below, matching
// handleListItems' pattern exactly except for the visibility call itself.
//
// collIDs == nil covers BOTH "unrestricted" (nil/cookie-admin user) and
// "all-access member" (store.VisibleCollectionIDs returns nil) — both mean
// "no collection filtering", identically to visibleCollectionIDs' contract,
// so no separate branch is needed for the all-access-member case.
//
// navVisibleIDs is the UNNARROWED result of visibleCollectionIDs — returned
// separately from collIDs because they serve different purposes and must
// NOT be conflated (codex R1 P1, TASK-1916): collIDs (narrowed to
// guestResourceFilter's fullCollIDs when the caller holds any item-level
// grants) is correct for the LIST query, exactly matching
// handleListItems' params.CollectionIDs narrowing. But
// enrichItemsWithParent's visibility check must use the nav-lenient
// visibleCollectionIDs set (which still includes item-grant-only
// collections, "so the collection appears in navigation" — see
// requireCollectionFullyVisible's doc comment), exactly matching
// handleListItems' enrichItemsWithParent(..., visibleIDs) call. Passing the
// narrowed collIDs to enrichItemsWithParent instead (the pre-fix bug) drops
// ParentLinkID/ParentRef/ParentTitle for any item whose parent lives in an
// item-grant-only collection, silently breaking changelog's ?parent= filter
// for that combination.
func (s *Server) projectIntelVisibility(r *http.Request, workspaceID string) (collIDs, itemIDs, navVisibleIDs []string, err error) {
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
return nil, nil, nil, err
}
fullCollIDs, grantedItemIDs, err := s.guestResourceFilter(r, workspaceID)
if err != nil {
return nil, nil, nil, err
}
collIDs = visibleIDs
if len(grantedItemIDs) > 0 {
collIDs = fullCollIDs
itemIDs = grantedItemIDs
}
return collIDs, itemIDs, visibleIDs, nil
}
// listTerminalItemsSince fetches items in each of models.DefaultTerminalStatuses
// (one ListItems call per status — mirrors the CLI's loop rather than OR-ing
// statuses into a single query), keeping only items updated after cutoff, up
// to limit items considered per status. A store error for one status is
// swallowed and the loop continues — matches the CLI's best-effort
// semantics: a transient failure on one status must not blank out the whole
// report. The MCP transport inherits this by proxying here (TASK-1916)
// rather than replicating the loop.
func (s *Server) listTerminalItemsSince(
workspaceID string, collIDs, itemIDs []string, cutoff time.Time, limit int,
) []models.Item {
var out []models.Item
for _, status := range models.DefaultTerminalStatuses {
items, err := s.store.ListItems(workspaceID, models.ItemListParams{
CollectionIDs: collIDs,
ItemIDs: itemIDs,
Fields: map[string]string{"status": status},
Sort: "updated_at:desc",
Limit: limit,
})
if err != nil {
continue
}
for _, item := range items {
if item.UpdatedAt.After(cutoff) {
out = append(out, item)
}
}
}
return out
}
// --- GET /workspaces/{slug}/next ---
// handleGetProjectNext returns the dashboard's suggested_next array — the
// same data `pad project next --format json` prints (cmd/pad/main.go's
// nextCmd, post BUG-987 bug 6: ONLY the suggested_next array, not the whole
// dashboard). The MCP HTTP transport's dispatchProjectNext
// (internal/mcp/dispatch_http_project.go) proxies straight to this endpoint
// (TASK-1916), so it returns the exact same shape. Returned as a bare JSON
// array (matching the CLI's
// `cli.PrintJSON(dash.SuggestedNext)` and the handleListItems bare-array
// convention), never null (buildDashboardResponse initializes SuggestedNext
// to []DashboardSuggestion{}).
func (s *Server) handleGetProjectNext(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
resp, err := s.buildDashboardResponse(workspaceID, r)
if err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.SuggestedNext)
}
// --- GET /workspaces/{slug}/standup ---
// StandupItem is one row in a StandupResponse list. Field-for-field mirror
// of cmd/pad/main.go's standupCmd `standupItem` JSON type. The MCP HTTP
// transport's dispatchProjectStandup proxies to this endpoint (TASK-1916)
// rather than defining its own type, so this is the sole server-side shape.
type StandupItem struct {
Ref string `json:"ref"`
Title string `json:"title"`
Status string `json:"status,omitempty"`
Priority string `json:"priority,omitempty"`
Reason string `json:"reason,omitempty"`
}
// StandupResponse mirrors cmd/pad/main.go's standupCmd `standupJSON` type
// exactly; the MCP HTTP transport proxies to this endpoint (TASK-1916) and
// passes the encoded response through verbatim.
type StandupResponse struct {
Date string `json:"date"`
Days int `json:"days"`
Completed []StandupItem `json:"completed"`
InProgress []StandupItem `json:"in_progress"`
Blockers []StandupItem `json:"blockers"`
SuggestedNext []StandupItem `json:"suggested_next"`
}
// handleGetProjectStandup serves GET /workspaces/{slug}/standup?days=N.
//
// days (optional, default 1) — lookback window for "completed" items.
// See parseDaysParam for the lenient-default semantics.
//
// Mirrors `pad project standup --format json` (cmd/pad/main.go standupCmd)
// exactly: dashboard for active items / blockers / suggested-next, plus a
// per-terminal-status ListItems loop (limit 20, best-effort) for
// "completed", plus an unbounded in-progress ListItems call. The MCP HTTP
// transport's dispatchProjectStandup proxies straight here (TASK-1916).
func (s *Server) handleGetProjectStandup(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
days := parseDaysParam(r, 1)
cutoff := time.Now().AddDate(0, 0, -days)
dash, err := s.buildDashboardResponse(workspaceID, r)
if err != nil {
writeInternalError(w, err)
return
}
collIDs, itemIDs, _, err := s.projectIntelVisibility(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
completed := s.listTerminalItemsSince(workspaceID, collIDs, itemIDs, cutoff, 20)
// In-progress items: unbounded, best-effort (a store error yields an
// empty list rather than failing the whole standup — matches the CLI's
// `inProgressItems = nil` fallback).
inProgress, err := s.store.ListItems(workspaceID, models.ItemListParams{
CollectionIDs: collIDs,
ItemIDs: itemIDs,
Fields: map[string]string{"status": "in-progress"},
Sort: "updated_at:desc",
})
if err != nil {
inProgress = nil
}
resp := StandupResponse{
Date: time.Now().Format("2006-01-02"),
Days: days,
Completed: make([]StandupItem, 0, len(completed)),
InProgress: make([]StandupItem, 0, len(inProgress)),
Blockers: make([]StandupItem, 0, len(dash.Attention)),
SuggestedNext: make([]StandupItem, 0, len(dash.SuggestedNext)),
}
for _, item := range completed {
resp.Completed = append(resp.Completed, StandupItem{
Ref: item.Ref,
Title: item.Title,
Status: extractFieldValue(item.Fields, "status"),
})
}
for _, item := range inProgress {
resp.InProgress = append(resp.InProgress, StandupItem{
Ref: item.Ref,
Title: item.Title,
Priority: extractFieldValue(item.Fields, "priority"),
})
}
for _, a := range dash.Attention {
resp.Blockers = append(resp.Blockers, StandupItem{
Ref: a.ItemRef,
Title: a.ItemTitle,
Reason: a.Reason,
})
}
for _, sug := range dash.SuggestedNext {
resp.SuggestedNext = append(resp.SuggestedNext, StandupItem{
Ref: sug.ItemRef,
Title: sug.ItemTitle,
Reason: sug.Reason,
})
}
writeJSON(w, http.StatusOK, resp)
}
// --- GET /workspaces/{slug}/changelog ---
// ChangelogItem is one row in a ChangelogGroup. Field-for-field mirror of
// cmd/pad/main.go's changelogCmd `changelogItem` JSON type. The MCP HTTP
// transport's dispatchProjectChangelog proxies to this endpoint (TASK-1916)
// rather than defining its own type.
type ChangelogItem struct {
Ref string `json:"ref"`
Title string `json:"title"`
Status string `json:"status"`
}
// ChangelogGroup is one collection's bucket of completed items within the
// changelog window. Mirrors changelogCmd's `changelogGroup`.
type ChangelogGroup struct {
Collection string `json:"collection"`
Icon string `json:"icon,omitempty"`
Count int `json:"count"`
Items []ChangelogItem `json:"items"`
}
// ChangelogResponse mirrors changelogCmd's `changelogJSON` type exactly; the
// MCP HTTP transport proxies to this endpoint (TASK-1916) and passes the
// encoded response through verbatim.
type ChangelogResponse struct {
Period string `json:"period"`
Since string `json:"since"`
Total int `json:"total"`
Groups []ChangelogGroup `json:"groups"`
}
// handleGetProjectChangelog serves:
//
// GET /workspaces/{slug}/changelog?days=N&since=YYYY-MM-DD&parent=REF
//
// days (optional, default 7) — lookback window. See parseDaysParam.
// since (optional, YYYY-MM-DD) — takes precedence over days when both are
// given (silent since-wins, matching the CLI's if/else — REST
// doesn't get to be the odd one out). Malformed since → 400
// bad_request (matches the CLI's hard error; the MCP transport gets
// this for free by proxying — see TASK-1916's note on
// dispatchProjectChangelog about the resulting generic-hint
// divergence from the pre-consolidation bespoke wording).
// parent (optional, ref/slug/UUID/title) — scope to items whose parent
// matches, via itemMatchesParentFilter.
//
// Mirrors `pad project changelog --format json` (cmd/pad/main.go
// changelogCmd) exactly: a per-terminal-status ListItems loop (limit 100,
// best-effort), cutoff + parent filtering, grouped by collection preserving
// first-seen order. The MCP HTTP transport's dispatchProjectChangelog
// proxies straight here (TASK-1916).
func (s *Server) handleGetProjectChangelog(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
days := parseDaysParam(r, 7)
since := strings.TrimSpace(r.URL.Query().Get("since"))
parent := strings.TrimSpace(r.URL.Query().Get("parent"))
var cutoff time.Time
if since != "" {
parsed, err := time.Parse("2006-01-02", since)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request",
"invalid 'since' date (expected YYYY-MM-DD): "+err.Error())
return
}
cutoff = parsed
} else {
cutoff = time.Now().AddDate(0, 0, -days)
}
collIDs, itemIDs, navVisibleIDs, err := s.projectIntelVisibility(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
items := s.listTerminalItemsSince(workspaceID, collIDs, itemIDs, cutoff, 100)
if parent != "" {
// The parent filter needs the items' own parent-link metadata
// (parent_link_id / parent_ref / parent_title) populated —
// batch-enrich before filtering, same helper handleListItems uses.
//
// Pass navVisibleIDs (nav-lenient), NOT collIDs (narrowed to
// full-access collections when the caller holds item-level
// grants) — mirrors handleListItems, which lists with the
// narrowed set but enriches with the original visibleIDs
// (codex R1 P1, TASK-1916: using collIDs here dropped parent
// fields for items whose parent lives in an item-grant-only
// collection, silently breaking ?parent= for that guest).
s.enrichItemsWithParent(workspaceID, items, navVisibleIDs)
filtered := items[:0]
for _, item := range items {
if itemMatchesParentFilter(item, parent) {
filtered = append(filtered, item)
}
}
items = filtered
}
// Group by collection slug, preserving first-seen ordering (matches the
// CLI's groupOrder slice).
groupOrder := make([]string, 0)
groups := make(map[string]*ChangelogGroup)
for _, item := range items {
key := item.CollectionSlug
if key == "" {
key = "other"
}
g, exists := groups[key]
if !exists {
name := item.CollectionName
if name == "" {
name = key
}
g = &ChangelogGroup{Collection: name, Icon: item.CollectionIcon, Items: []ChangelogItem{}}
groups[key] = g
groupOrder = append(groupOrder, key)
}
g.Items = append(g.Items, ChangelogItem{
Ref: item.Ref,
Title: item.Title,
Status: extractFieldValue(item.Fields, "status"),
})
g.Count = len(g.Items)
}
periodLabel := "last " + strconv.Itoa(days) + " days"
if since != "" {
periodLabel = "since " + since
}
if parent != "" {
periodLabel += " (parent: " + parent + ")"
}
resp := ChangelogResponse{
Period: periodLabel,
Since: cutoff.Format("2006-01-02"),
Total: len(items),
Groups: make([]ChangelogGroup, 0, len(groupOrder)),
}
for _, key := range groupOrder {
resp.Groups = append(resp.Groups, *groups[key])
}
writeJSON(w, http.StatusOK, resp)
}