···11+package validator
22+33+import (
44+ "fmt"
55+ "regexp"
66+ "strings"
77+88+ "github.com/bluesky-social/indigo/atproto/syntax"
99+ "golang.org/x/exp/slices"
1010+ "tangled.sh/tangled.sh/core/api/tangled"
1111+ "tangled.sh/tangled.sh/core/appview/db"
1212+)
1313+1414+var (
1515+ // Label name should be alphanumeric with hyphens/underscores, but not start/end with them
1616+ labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`)
1717+ // Color should be a valid hex color
1818+ colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`)
1919+ // You can only label issues and pulls presently
2020+ validScopes = []syntax.NSID{tangled.RepoIssueNSID, tangled.RepoPullNSID}
2121+)
2222+2323+func (v *Validator) ValidateLabelDefinition(label *db.LabelDefinition) error {
2424+ if label.Name == "" {
2525+ return fmt.Errorf("label name is empty")
2626+ }
2727+ if len(label.Name) > 40 {
2828+ return fmt.Errorf("label name too long (max 40 graphemes)")
2929+ }
3030+ if len(label.Name) < 1 {
3131+ return fmt.Errorf("label name too short (min 1 grapheme)")
3232+ }
3333+ if !labelNameRegex.MatchString(label.Name) {
3434+ return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)")
3535+ }
3636+3737+ if !label.ValueType.IsConcreteType() {
3838+ return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType)
3939+ }
4040+4141+ if label.ValueType.IsNull() && label.ValueType.IsEnumType() {
4242+ return fmt.Errorf("null type cannot be used in conjunction with enum type")
4343+ }
4444+4545+ // validate scope (nsid format)
4646+ if label.Scope == "" {
4747+ return fmt.Errorf("scope is required")
4848+ }
4949+ if _, err := syntax.ParseNSID(string(label.Scope)); err != nil {
5050+ return fmt.Errorf("failed to parse scope: %w", err)
5151+ }
5252+ if !slices.Contains(validScopes, label.Scope) {
5353+ return fmt.Errorf("invalid scope: scope must be one of %q", validScopes)
5454+ }
5555+5656+ // validate color if provided
5757+ if label.Color != nil {
5858+ color := strings.TrimSpace(*label.Color)
5959+ if color == "" {
6060+ // empty color is fine, set to nil
6161+ label.Color = nil
6262+ } else {
6363+ if !colorRegex.MatchString(color) {
6464+ return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)")
6565+ }
6666+ // expand 3-digit hex to 6-digit hex
6767+ if len(color) == 4 { // #ABC
6868+ color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3])
6969+ }
7070+ // convert to uppercase for consistency
7171+ color = strings.ToUpper(color)
7272+ label.Color = &color
7373+ }
7474+ }
7575+7676+ return nil
7777+}