From 68bee501d8252521cb6110c134e33863235ff2d1 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Wed, 10 Sep 2025 16:09:54 +0100 Subject: [PATCH] appview/validator: add model validation for label defs Signed-off-by: oppiliappan --- appview/validator/label.go | 77 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 appview/validator/label.go diff --git a/appview/validator/label.go b/appview/validator/label.go new file mode 100644 index 00000000..d839c2b3 --- /dev/null +++ b/appview/validator/label.go @@ -0,0 +1,77 @@ +package validator + +import ( + "fmt" + "regexp" + "strings" + + "github.com/bluesky-social/indigo/atproto/syntax" + "golang.org/x/exp/slices" + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/appview/db" +) + +var ( + // Label name should be alphanumeric with hyphens/underscores, but not start/end with them + labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`) + // Color should be a valid hex color + colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`) + // You can only label issues and pulls presently + validScopes = []syntax.NSID{tangled.RepoIssueNSID, tangled.RepoPullNSID} +) + +func (v *Validator) ValidateLabelDefinition(label *db.LabelDefinition) error { + if label.Name == "" { + return fmt.Errorf("label name is empty") + } + if len(label.Name) > 40 { + return fmt.Errorf("label name too long (max 40 graphemes)") + } + if len(label.Name) < 1 { + return fmt.Errorf("label name too short (min 1 grapheme)") + } + if !labelNameRegex.MatchString(label.Name) { + return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)") + } + + if !label.ValueType.IsConcreteType() { + return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType) + } + + if label.ValueType.IsNull() && label.ValueType.IsEnumType() { + return fmt.Errorf("null type cannot be used in conjunction with enum type") + } + + // validate scope (nsid format) + if label.Scope == "" { + return fmt.Errorf("scope is required") + } + if _, err := syntax.ParseNSID(string(label.Scope)); err != nil { + return fmt.Errorf("failed to parse scope: %w", err) + } + if !slices.Contains(validScopes, label.Scope) { + return fmt.Errorf("invalid scope: scope must be one of %q", validScopes) + } + + // validate color if provided + if label.Color != nil { + color := strings.TrimSpace(*label.Color) + if color == "" { + // empty color is fine, set to nil + label.Color = nil + } else { + if !colorRegex.MatchString(color) { + return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)") + } + // expand 3-digit hex to 6-digit hex + if len(color) == 4 { // #ABC + color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3]) + } + // convert to uppercase for consistency + color = strings.ToUpper(color) + label.Color = &color + } + } + + return nil +} -- 2.51.2