Files
multica/server/internal/handler/issue_metadata.go
Bohan Jiang 0c767c0052 feat(issues): per-issue metadata KV (MUL-2017) (#2845)
* feat(issues): per-issue metadata KV (MUL-2017)

Adds a small JSONB KV map to every issue for agent pipeline state (attempts,
PR number, pipeline status, ...). Keys match a narrow regex, values are
primitives (string / number / bool), capped at 50 keys per issue and 8KB
per blob. Defense-in-depth via two CHECK constraints (object shape + size).

All mutations are single-key atomic (jsonb_set / `- key`). `UpdateIssue`
intentionally does NOT touch metadata: a whole-blob overwrite would race
with concurrent agent writes.

  GET    /api/issues/:id/metadata
  PUT    /api/issues/:id/metadata/:key   body: { "value": <primitive> }
  DELETE /api/issues/:id/metadata/:key

Containment filter on list: GET /api/issues?metadata=<json-object> uses
PG `@>` against a `jsonb_path_ops` GIN index. Mirrored across ListIssues,
CountIssues, ListOpenIssues, and the hand-rolled ListGroupedIssues SQL so
CLI/API and UI grouped views stay consistent.

CLI: multica issue metadata {list,get,set,delete}
  multica issue list --metadata key=value (repeatable, AND)
  set has --type to override the default value-sniffing
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): metadata test bugs + wire realtime + read-only display (MUL-2017)

- Fix two failing handler tests blocking backend CI:
  - reset decode target after delete so map merge does not mask removal
  - url.PathEscape the key segment so spaces no longer panic NewRequest
- Wire issue_metadata:changed end to end so the detail / list / my-issues
  caches stay in sync with set/delete events (other tabs, CLI writes).
- Add a read-only Metadata strip to the issue detail sidebar; hidden when
  the issue has no keys so it stays quiet in the common case.

Co-authored-by: multica-agent <github@multica.ai>

* feat(runtime): teach agents to read/write issue metadata (MUL-2017)

Add an `## Issue Metadata` section to the runtime brief plus a
`metadata list` step on entry and a `metadata set`/`delete` step on
exit. Section only emits when the task carries an issue id (comment- or
assignment-triggered); chat / quick-create / run-only autopilot stay
clean so they don't fire failing CLI calls.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): bump metadata migration to 105 and drop attempts as example (MUL-2017)

main is now at 104_drop_runtime_timezone; the migrator picks
LatestVersion() by sorted filename, so a slot before the tail would
let DBs that have already run 099–104 think they're up-to-date while
the issue.metadata column is missing — runtime would then fail with
column does not exist. Renumbering to 105 puts the migration at the
tail and forces it to run.

Also drop attempts as a positive example across docs/code comments and
test fixtures — the runtime instruction prompt already lists it under
"What NOT to pin" (runtime bookkeeping). Replace with pr_number, which
is in the recommended-keys set, so docs/tests speak the same language
as the prompt.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 16:35:45 +08:00

242 lines
7.9 KiB
Go

package handler
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"regexp"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/multica-ai/multica/server/internal/logger"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// Per-issue metadata is a small JSONB KV map agents use to record pipeline
// state (PR number, pipeline_status, waiting_on, ...). Three rules govern
// the V1 surface — they're enforced both in the handler and at the DB:
//
// - keys match `^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$` (handler)
// - at most 50 keys per issue (handler)
// - values are primitive: string / number / bool (handler)
// - JSONB column is an object and ≤ 8KB (DB CHECK; defense in depth)
//
// All mutations are single-key atomic. UpdateIssue does NOT touch metadata —
// any whole-blob overwrite would race with concurrent agent writes (see the
// design discussion on MUL-2017).
const (
maxIssueMetadataKeys = 50
)
var issueMetadataKeyRE = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$`)
// SetIssueMetadataKeyRequest carries the JSON value to write under the key
// named in the URL. Value is a RawMessage so we can preserve numeric vs.
// string typing through to PostgreSQL — once decoded into `any`, JSON
// numbers all collapse to float64 and we'd lose integer fidelity.
type SetIssueMetadataKeyRequest struct {
Value json.RawMessage `json:"value"`
}
func validateIssueMetadataKey(key string) error {
if key == "" {
return errors.New("key is required")
}
if !issueMetadataKeyRE.MatchString(key) {
return errors.New("key must match ^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$")
}
return nil
}
// validateIssueMetadataValue rejects anything other than a primitive JSON
// scalar. Null, arrays, and objects are not allowed — the V1 surface is
// flat KV. Removing a key uses DELETE, not a null value.
func validateIssueMetadataValue(raw json.RawMessage) error {
if len(raw) == 0 {
return errors.New("value is required")
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return fmt.Errorf("value must be valid JSON: %w", err)
}
switch v.(type) {
case string, bool, float64:
return nil
case nil:
return errors.New("value cannot be null (use DELETE to remove a key)")
default:
return errors.New("value must be a primitive: string, number, or bool")
}
}
// parseIssueMetadata decodes the JSONB bytes from db.Issue.Metadata into a
// Go map suitable for response serialization. Empty or unparseable blobs
// degrade to an empty map — the DB CHECK guarantees object shape, so this
// path is only hit on rows somehow predating the migration.
func parseIssueMetadata(raw []byte) map[string]any {
if len(raw) == 0 {
return map[string]any{}
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil || out == nil {
return map[string]any{}
}
return out
}
// parseMetadataFilterParam reads the `metadata` query parameter (a JSON
// object) and returns it as the JSONB filter blob passed to ListIssues /
// CountIssues / ListOpenIssues. Empty input means "no filter" and returns
// a nil []byte, which the SQL layer interprets as "skip the @> check".
//
// Validates that the filter is itself a flat object of primitives, mirroring
// the constraints we apply at write time — querying for `{key: {nested}}`
// would never match since written values are primitive by construction.
func parseMetadataFilterParam(w http.ResponseWriter, raw string) ([]byte, bool) {
if raw == "" {
return nil, true
}
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
writeError(w, http.StatusBadRequest, "metadata filter must be a JSON object")
return nil, false
}
for k, v := range parsed {
if err := validateIssueMetadataKey(k); err != nil {
writeError(w, http.StatusBadRequest, "metadata filter "+err.Error())
return nil, false
}
switch v.(type) {
case string, bool, float64:
// ok
default:
writeError(w, http.StatusBadRequest, "metadata filter values must be primitives (string, number, bool)")
return nil, false
}
}
// Re-marshal so we send canonical JSON to PG (and not the raw, possibly
// whitespace-padded user input).
buf, err := json.Marshal(parsed)
if err != nil {
writeError(w, http.StatusBadRequest, "metadata filter is invalid")
return nil, false
}
return buf, true
}
func (h *Handler) ListIssueMetadata(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
writeJSON(w, http.StatusOK, map[string]any{"metadata": parseIssueMetadata(issue.Metadata)})
}
func (h *Handler) SetIssueMetadataKey(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
key := chi.URLParam(r, "key")
if err := validateIssueMetadataKey(key); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var req SetIssueMetadataKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := validateIssueMetadataValue(req.Value); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
// Enforce the key-count cap in the handler. The DB only guards size,
// and a clear 4xx for "too many keys" beats a CHECK violation that
// happens to fire on the size cap once enough keys accumulate.
existing := parseIssueMetadata(issue.Metadata)
if _, present := existing[key]; !present && len(existing) >= maxIssueMetadataKeys {
writeError(w, http.StatusBadRequest, fmt.Sprintf("metadata cannot exceed %d keys", maxIssueMetadataKeys))
return
}
updated, err := h.Queries.SetIssueMetadataKey(r.Context(), db.SetIssueMetadataKeyParams{
ID: issue.ID,
WorkspaceID: issue.WorkspaceID,
Key: key,
Value: []byte(req.Value),
})
if err != nil {
if isCheckViolation(err) {
writeError(w, http.StatusBadRequest, "metadata exceeds the 8KB size limit")
return
}
slog.Warn("SetIssueMetadataKey failed", append(logger.RequestAttrs(r), "error", err, "issue_id", issueID, "key", key)...)
writeError(w, http.StatusInternalServerError, "failed to set metadata key")
return
}
workspaceID := uuidToString(updated.WorkspaceID)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
metadata := parseIssueMetadata(updated.Metadata)
h.publish(protocol.EventIssueMetadataChanged, workspaceID, actorType, actorID, map[string]any{
"issue_id": uuidToString(updated.ID),
"metadata": metadata,
})
writeJSON(w, http.StatusOK, map[string]any{"metadata": metadata})
}
func (h *Handler) DeleteIssueMetadataKey(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
key := chi.URLParam(r, "key")
if err := validateIssueMetadataKey(key); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
updated, err := h.Queries.DeleteIssueMetadataKey(r.Context(), db.DeleteIssueMetadataKeyParams{
ID: issue.ID,
WorkspaceID: issue.WorkspaceID,
Key: key,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "issue not found")
return
}
slog.Warn("DeleteIssueMetadataKey failed", append(logger.RequestAttrs(r), "error", err, "issue_id", issueID, "key", key)...)
writeError(w, http.StatusInternalServerError, "failed to delete metadata key")
return
}
workspaceID := uuidToString(updated.WorkspaceID)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
metadata := parseIssueMetadata(updated.Metadata)
h.publish(protocol.EventIssueMetadataChanged, workspaceID, actorType, actorID, map[string]any{
"issue_id": uuidToString(updated.ID),
"metadata": metadata,
})
writeJSON(w, http.StatusOK, map[string]any{"metadata": metadata})
}