mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 10:26:46 +08:00
* 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]>
344 lines
10 KiB
Go
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)
|
|
}
|
|
}
|