Files
multica/server/internal/handler/issue_metadata.go
Xichang(Seacen) Zhao cb04226fd4 MUL-5632: fix(engine): broadcast the full issue payload for chat-created issues (#6278)
* fix(engine): broadcast the full issue payload for chat-created issues

An issue opened with /issue from a chat channel broadcast
{"issue_id": <uuid>} — the minimal fallback IssueService.Create emits
whenever a caller leaves IssueCreateOpts.BroadcastPayload nil, which the
engine did. Every issue:created consumer reads payload["issue"], so the
missing key meant extractIssueFields (cmd/server/subscriber_listeners.go)
failed its map assertion and returned false before it ever reached the
id / creator_id check. The listener returned early and no auto-subscribe
rule ran, so the person who typed /issue was never subscribed to the
issue they had just filed and got no notifications for it. Both channels
that register with the engine — Feishu/Lark and Slack — were affected.

The HTTP handler already supplies a BroadcastPayload and autopilot
publishes its own event through issueToMap; only the engine path was
short. Export issueToMap as IssueToMap and have the engine use it, so a
single builder is the one source of truth for that shape instead of
three descriptions drifting apart. The workspace issue prefix comes from
the GetWorkspace call the /issue path already made for the chat reply's
identifier, hoisted so it is read once and used for both.

No regression — the payload only gains keys, and consumers read named
fields. The activity and notification listeners type-assert
payload["issue"] to handler.IssueResponse and still skip a map, exactly
as they already do for autopilot-created issues. The subscriber listener
accepts either shape. The workspace WS fanout marshals the payload
as-is, so open clients now render a chat-created issue live instead of
waiting for a refetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(service): complete the issue:created payload contract

IssueToMap omitted project_id, stage, metadata and properties, all of
which handler.IssueResponse — the other rendering of the same event —
always emits. Clients type both as a complete Issue and insert the
object straight into the list cache without runtime validation, so an
issue created by autopilot, quick-create or the chat /issue command
appeared in open clients missing its project and custom properties
until the next refetch. Broadcasting the full issue from the chat path
would otherwise have spread that existing defect to a third entry
point.

Fill in the missing keys and pin the contract with a test that fails if
the two renderings ever drift apart again, so adding a field to
IssueResponse without adding it here is caught in CI rather than in the
UI. metadata and properties render as {} when unset, matching the
"always present" part of the contract.

Also route both identifier renderings through service.IssueIdentifier,
so a workspace lookup that degrades the prefix cannot show "#42" in the
chat reply while the realtime list shows "-42".

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* docs(service): name the actual IssueToMap call sites

The doc comment and the shape test listed quick-create among the
non-HTTP publishers of the issue payload. It is not one: the three call
sites are autopilot and the channel engine on issue:created, and the
background stuck-issue status reset on issue:updated.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 14:29:47 +08:00

238 lines
8.0 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"
"github.com/multica-ai/multica/server/internal/util"
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. Shared with the
// service-layer broadcast rendering (service.IssueToMap) so both ways of
// describing an issue agree on what an unset bag looks like on the wire.
func parseIssueMetadata(raw []byte) map[string]any {
return util.JSONObjectOrEmpty(raw)
}
// 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})
}