Files
bb14e8763c feat(issues): add multi_text and multi_url custom property types (MUL-7496) (#8559)
* feat(issues): add multi_text and multi_url custom property types

Workspaces could only hold list-shaped values as preset option lists
(multi_select) or member references (multi_actor). There was no way to
keep a list of free-form strings or links on an issue, so teams with
"related docs / spec links / aliases" needs had to either cram them into
one text field (no structure, nothing clickable) or burn one select
option per link.

This adds two property types:

- multi_text: an ordered list of free-form text entries
- multi_url: an ordered list of http(s) URLs

Each entry is validated exactly like a single text / url value
(length caps, http(s)-only for URLs), duplicates are dropped, insertion
order is preserved, and values are capped at 20 entries to respect the
16KB properties-bag budget. Values ride the existing jsonb bag, so the
jsonb_path_ops GIN index, the @> containment filter, and the client
value schema all keep working unchanged.

Filtering matches any single list element exactly (equality) plus "No
value" — the containment form the filter compiler already emits for
array elements, so no new server predicate is needed. List types
deliberately get no sort order and cannot group, like the other
multi-value types.

On the web, list values render as chips; multi_url chips are clickable
and open in a new tab, and the editor popover offers a per-entry open
button, so several related documents or links stay directly reachable
from the issue. The filter menu reuses the scalar input (equality-only
plus "No value"). CLI: comma-separated --value form for both types,
element-equality --property filtering, and display_values rows.

Server-side allowlist widened by migration 500 (check constraint
re-added NOT VALID then validated, same pattern as migration 341).

* fix(properties): address list property review nits

Co-authored-by: multica-agent <[email protected]>

* fix(properties): scope list drafts to the edited issue

Co-authored-by: multica-agent <[email protected]>

* test(agent): avoid fork races when writing zeroclaw fixtures

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-07 13:33:02 +08:00

344 lines
10 KiB
Go

// Package issueproperty owns the transport-independent validation contract for
// values stored in an issue's custom-property bag. HTTP PUT and atomic issue
// creation both call this package so the accepted types and canonical form
// cannot drift between write paths.
package issueproperty
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
const (
maxTextValueLen = 2000
maxURLValueLen = 2048
// MaxActorValues is exported so the handler's existing focused tests can
// continue to pin the public multi-actor limit after validation moved here.
MaxActorValues = 20
// Free-form list values (multi_text / multi_url) are capped the same way:
// the whole properties bag shares one 16KB row budget, and URL entries can
// individually reach 2048 bytes.
MaxListValues = 20
)
var actorKinds = []string{"member"}
type propertyOption struct {
ID string `json:"id"`
Name string `json:"name"`
}
type propertyConfig struct {
Options []propertyOption `json:"options,omitempty"`
}
// ActorRef is the canonical parsed form of a "<kind>:<uuid>" property value.
type ActorRef struct {
Kind string
ID string
}
func (a ActorRef) String() string { return a.Kind + ":" + a.ID }
// IsActor reports whether a definition needs workspace-member resolution in
// addition to pure JSON shape validation.
func IsActor(propertyType string) bool {
return propertyType == "actor" || propertyType == "multi_actor"
}
func ActorKindsHint() string { return strings.Join(actorKinds, " / ") }
// ParseActorRef validates and canonicalizes an actor reference. Members use
// user_id, matching the assignee contract and client-side member directory.
func ParseActorRef(value string) (ActorRef, error) {
kind, id, found := strings.Cut(value, ":")
if !found {
return ActorRef{}, fmt.Errorf("value must look like \"<kind>:<uuid>\" where kind is one of: %s", ActorKindsHint())
}
valid := false
for _, candidate := range actorKinds {
if kind == candidate {
valid = true
break
}
}
if !valid {
return ActorRef{}, fmt.Errorf("unknown actor kind %q; valid kinds: %s", kind, ActorKindsHint())
}
parsed, err := uuid.Parse(id)
if err != nil {
return ActorRef{}, fmt.Errorf("actor id in %q must be a UUID", value)
}
return ActorRef{Kind: kind, ID: parsed.String()}, nil
}
// ParseActorRefList validates a multi_actor array, removes duplicates, and
// preserves first-occurrence order.
func ParseActorRefList(items []any) ([]ActorRef, error) {
if len(items) == 0 {
return nil, errors.New("value must be a non-empty array of actor references")
}
if len(items) > MaxActorValues {
return nil, fmt.Errorf("value cannot list more than %d actors", MaxActorValues)
}
seen := make(map[string]struct{}, len(items))
refs := make([]ActorRef, 0, len(items))
for _, item := range items {
value, ok := item.(string)
if !ok {
return nil, errors.New("value must be an array of actor reference strings")
}
ref, err := ParseActorRef(value)
if err != nil {
return nil, err
}
if _, duplicate := seen[ref.String()]; duplicate {
continue
}
seen[ref.String()] = struct{}{}
refs = append(refs, ref)
}
return refs, nil
}
// ActorRefsInValue decodes canonical actor JSON for tenant-scoped reference
// resolution by the caller.
func ActorRefsInValue(propertyType string, stored []byte) ([]ActorRef, error) {
if propertyType == "actor" {
var value string
if err := json.Unmarshal(stored, &value); err != nil {
return nil, err
}
ref, err := ParseActorRef(value)
if err != nil {
return nil, err
}
return []ActorRef{ref}, nil
}
var values []string
if err := json.Unmarshal(stored, &values); err != nil {
return nil, err
}
refs := make([]ActorRef, 0, len(values))
for _, value := range values {
ref, err := ParseActorRef(value)
if err != nil {
return nil, err
}
refs = append(refs, ref)
}
return refs, nil
}
func parseConfig(raw []byte) propertyConfig {
var config propertyConfig
if len(raw) != 0 {
_ = json.Unmarshal(raw, &config)
}
return config
}
func optionOrder(config propertyConfig) map[string]int {
order := make(map[string]int, len(config.Options))
for index, option := range config.Options {
order[option.ID] = index
}
return order
}
func optionsHint(config propertyConfig) string {
parts := make([]string, len(config.Options))
for index, option := range config.Options {
parts[index] = fmt.Sprintf("%s (%s)", option.ID, option.Name)
}
return strings.Join(parts, ", ")
}
// textItem validates one text value - a single `text` value or one
// `multi_text` element. Text keeps interior spacing as written.
func textItem(s string) (string, error) {
if strings.TrimSpace(s) == "" {
return "", errors.New("value cannot be empty (use DELETE to unset a property)")
}
if utf8.RuneCountInString(s) > maxTextValueLen {
return "", fmt.Errorf("value must be %d characters or fewer", maxTextValueLen)
}
return util.SanitizeTextForPostgres(s), nil
}
// urlItem validates and canonicalizes one http(s) URL value - a single `url`
// value or one `multi_url` element.
func urlItem(s string) (string, error) {
s = strings.TrimSpace(s)
if len(s) > maxURLValueLen {
return "", fmt.Errorf("value must be %d characters or fewer", maxURLValueLen)
}
parsed, err := url.Parse(s)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return "", errors.New("value must be an http(s) URL")
}
return s, nil
}
// validateStringList validates a multi_text / multi_url value: a non-empty
// array whose every element passes the item validator. Duplicates are dropped
// and the caller's order preserved, mirroring multi_actor.
func validateStringList(value any, itemValidator func(string) (string, error)) ([]byte, error) {
items, ok := value.([]any)
if !ok || len(items) == 0 {
return nil, errors.New("value must be a non-empty array of strings")
}
if len(items) > MaxListValues {
return nil, fmt.Errorf("value cannot list more than %d entries", MaxListValues)
}
seen := make(map[string]struct{}, len(items))
out := make([]string, 0, len(items))
for _, item := range items {
text, ok := item.(string)
if !ok {
return nil, errors.New("value must be a non-empty array of strings")
}
canonical, err := itemValidator(text)
if err != nil {
return nil, err
}
if _, duplicate := seen[canonical]; duplicate {
continue
}
seen[canonical] = struct{}{}
out = append(out, canonical)
}
return json.Marshal(out)
}
// ValidateValue checks a raw JSON value against the definition's type and
// returns the canonical JSON stored by every issue-property write path.
func ValidateValue(def db.IssueProperty, raw json.RawMessage) ([]byte, error) {
if len(raw) == 0 {
return nil, errors.New("value is required")
}
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return nil, fmt.Errorf("value must be valid JSON: %w", err)
}
if value == nil {
return nil, errors.New("value cannot be null (use DELETE to unset a property)")
}
config := parseConfig(def.Config)
switch def.Type {
case "text":
text, ok := value.(string)
if !ok {
return nil, errors.New("value must be a string")
}
item, err := textItem(text)
if err != nil {
return nil, err
}
return json.Marshal(item)
case "multi_text":
return validateStringList(value, textItem)
case "url":
text, ok := value.(string)
if !ok {
return nil, errors.New("value must be a URL string")
}
item, err := urlItem(text)
if err != nil {
return nil, err
}
return json.Marshal(item)
case "multi_url":
return validateStringList(value, urlItem)
case "number":
if _, ok := value.(float64); !ok {
return nil, errors.New("value must be a number")
}
return json.Marshal(value)
case "checkbox":
if _, ok := value.(bool); !ok {
return nil, errors.New("value must be true or false")
}
return json.Marshal(value)
case "date":
text, ok := value.(string)
if !ok {
return nil, errors.New("value must be a date string in YYYY-MM-DD format")
}
if _, err := time.Parse("2006-01-02", text); err != nil {
return nil, errors.New("value must be a date string in YYYY-MM-DD format")
}
return json.Marshal(text)
case "select":
id, ok := value.(string)
if !ok {
return nil, fmt.Errorf("value must be one of the option ids: %s", optionsHint(config))
}
if _, exists := optionOrder(config)[id]; !exists {
return nil, fmt.Errorf("value must be one of the option ids: %s", optionsHint(config))
}
return json.Marshal(id)
case "multi_select":
items, ok := value.([]any)
if !ok || len(items) == 0 {
return nil, fmt.Errorf("value must be a non-empty array of option ids: %s", optionsHint(config))
}
order := optionOrder(config)
seen := make(map[string]struct{}, len(items))
ids := make([]string, 0, len(items))
for _, item := range items {
id, ok := item.(string)
if !ok {
return nil, fmt.Errorf("value must be a non-empty array of option ids: %s", optionsHint(config))
}
if _, exists := order[id]; !exists {
return nil, fmt.Errorf("unknown option id %q; valid option ids: %s", id, optionsHint(config))
}
if _, duplicate := seen[id]; duplicate {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
sort.SliceStable(ids, func(left, right int) bool { return order[ids[left]] < order[ids[right]] })
return json.Marshal(ids)
case "actor":
text, ok := value.(string)
if !ok {
return nil, fmt.Errorf("value must be an actor reference string like \"member:<uuid>\" (kinds: %s)", ActorKindsHint())
}
ref, err := ParseActorRef(text)
if err != nil {
return nil, err
}
return json.Marshal(ref.String())
case "multi_actor":
items, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("value must be an array of actor reference strings like \"member:<uuid>\" (kinds: %s)", ActorKindsHint())
}
refs, err := ParseActorRefList(items)
if err != nil {
return nil, err
}
out := make([]string, len(refs))
for index, ref := range refs {
out[index] = ref.String()
}
return json.Marshal(out)
default:
return nil, fmt.Errorf("unsupported property type %q", def.Type)
}
}