This repository has no description
1package models
2
3import (
4 "cmp"
5 "context"
6 "crypto/sha1"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "regexp"
12 "slices"
13 "strings"
14 "time"
15
16 "github.com/bluesky-social/indigo/api/atproto"
17 "github.com/bluesky-social/indigo/atproto/syntax"
18 "github.com/bluesky-social/indigo/xrpc"
19 "tangled.org/core/api/tangled"
20 "tangled.org/core/idresolver"
21)
22
23type ConcreteType string
24
25const (
26 ConcreteTypeNull ConcreteType = "null"
27 ConcreteTypeString ConcreteType = "string"
28 ConcreteTypeInt ConcreteType = "integer"
29 ConcreteTypeBool ConcreteType = "boolean"
30)
31
32type ValueTypeFormat string
33
34const (
35 ValueTypeFormatAny ValueTypeFormat = "any"
36 ValueTypeFormatDid ValueTypeFormat = "did"
37)
38
39// ValueType represents an atproto lexicon type definition with constraints
40type ValueType struct {
41 Type ConcreteType `json:"type"`
42 Format ValueTypeFormat `json:"format,omitempty"`
43 Enum []string `json:"enum,omitempty"`
44}
45
46func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType {
47 return tangled.LabelDefinition_ValueType{
48 Type: string(vt.Type),
49 Format: string(vt.Format),
50 Enum: vt.Enum,
51 }
52}
53
54func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType {
55 return ValueType{
56 Type: ConcreteType(record.Type),
57 Format: ValueTypeFormat(record.Format),
58 Enum: record.Enum,
59 }
60}
61
62func (vt ValueType) IsConcreteType() bool {
63 return vt.Type == ConcreteTypeNull ||
64 vt.Type == ConcreteTypeString ||
65 vt.Type == ConcreteTypeInt ||
66 vt.Type == ConcreteTypeBool
67}
68
69func (vt ValueType) IsNull() bool {
70 return vt.Type == ConcreteTypeNull
71}
72
73func (vt ValueType) IsString() bool {
74 return vt.Type == ConcreteTypeString
75}
76
77func (vt ValueType) IsInt() bool {
78 return vt.Type == ConcreteTypeInt
79}
80
81func (vt ValueType) IsBool() bool {
82 return vt.Type == ConcreteTypeBool
83}
84
85func (vt ValueType) IsEnum() bool {
86 return len(vt.Enum) > 0
87}
88
89func (vt ValueType) IsDidFormat() bool {
90 return vt.Format == ValueTypeFormatDid
91}
92
93func (vt ValueType) IsAnyFormat() bool {
94 return vt.Format == ValueTypeFormatAny
95}
96
97type LabelDefinition struct {
98 Id int64
99 Did string
100 Rkey string
101
102 Name string
103 ValueType ValueType
104 Scope []string
105 Color *string
106 Multiple bool
107 Created time.Time
108}
109
110func (l *LabelDefinition) AtUri() syntax.ATURI {
111 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey))
112}
113
114func (l *LabelDefinition) AsRecord() tangled.LabelDefinition {
115 vt := l.ValueType.AsRecord()
116 return tangled.LabelDefinition{
117 Name: l.Name,
118 Color: l.Color,
119 CreatedAt: l.Created.Format(time.RFC3339),
120 Multiple: &l.Multiple,
121 Scope: l.Scope,
122 ValueType: &vt,
123 }
124}
125
126var (
127 // Label name should be alphanumeric with hyphens/underscores, but not start/end with them
128 labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`)
129 // Color should be a valid hex color
130 colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`)
131 // You can only label issues and pulls presently
132 validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID}
133)
134
135var _ Validator = new(LabelDefinition)
136
137func (l *LabelDefinition) Validate() error {
138 if l.Name == "" {
139 return fmt.Errorf("label name is empty")
140 }
141 if len(l.Name) > 40 {
142 return fmt.Errorf("label name too long (max 40 graphemes)")
143 }
144 if len(l.Name) < 1 {
145 return fmt.Errorf("label name too short (min 1 grapheme)")
146 }
147 if !labelNameRegex.MatchString(l.Name) {
148 return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)")
149 }
150
151 if !l.ValueType.IsConcreteType() {
152 return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", l.ValueType.Type)
153 }
154
155 // null type checks: cannot be enums, multiple or explicit format
156 if l.ValueType.IsNull() && l.ValueType.IsEnum() {
157 return fmt.Errorf("null type cannot be used in conjunction with enum type")
158 }
159 if l.ValueType.IsNull() && l.Multiple {
160 return fmt.Errorf("null type labels cannot be multiple")
161 }
162 if l.ValueType.IsNull() && !l.ValueType.IsAnyFormat() {
163 return fmt.Errorf("format cannot be used in conjunction with null type")
164 }
165
166 // format checks: cannot be used with enum, or integers
167 if !l.ValueType.IsAnyFormat() && l.ValueType.IsEnum() {
168 return fmt.Errorf("enum types cannot be used in conjunction with format specification")
169 }
170
171 if !l.ValueType.IsAnyFormat() && !l.ValueType.IsString() {
172 return fmt.Errorf("format specifications are only permitted on string types")
173 }
174
175 // validate scope (nsid format)
176 if l.Scope == nil {
177 return fmt.Errorf("scope is required")
178 }
179 for _, s := range l.Scope {
180 if _, err := syntax.ParseNSID(s); err != nil {
181 return fmt.Errorf("failed to parse scope: %w", err)
182 }
183 if !slices.Contains(validScopes, s) {
184 return fmt.Errorf("invalid scope: scope must be present in %q", validScopes)
185 }
186 }
187
188 // validate color if provided
189 if l.Color != nil {
190 color := strings.TrimSpace(*l.Color)
191 if color == "" {
192 // empty color is fine, set to nil
193 l.Color = nil
194 } else {
195 if !colorRegex.MatchString(color) {
196 return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)")
197 }
198 // expand 3-digit hex to 6-digit hex
199 if len(color) == 4 { // #ABC
200 color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3])
201 }
202 // convert to uppercase for consistency
203 color = strings.ToUpper(color)
204 l.Color = &color
205 }
206 }
207
208 return nil
209}
210
211// ValidateOperandValue validates the label operation operand value based on
212// label definition.
213//
214// NOTE: This can modify the [LabelOp]
215func (def *LabelDefinition) ValidateOperandValue(op *LabelOp) error {
216 expectedKey := def.AtUri().String()
217 if op.OperandKey != def.AtUri().String() {
218 return fmt.Errorf("operand key %q does not match label definition URI %q", op.OperandKey, expectedKey)
219 }
220
221 valueType := def.ValueType
222
223 // this is permitted, it "unsets" a label
224 if op.OperandValue == "" {
225 op.Operation = LabelOperationDel
226 return nil
227 }
228
229 switch valueType.Type {
230 case ConcreteTypeNull:
231 // For null type, value should be empty
232 if op.OperandValue != "null" {
233 return fmt.Errorf("null type requires empty value, got %q", op.OperandValue)
234 }
235
236 case ConcreteTypeString:
237 // For string type, validate enum constraints if present
238 if valueType.IsEnum() {
239 if !slices.Contains(valueType.Enum, op.OperandValue) {
240 return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum)
241 }
242 }
243
244 switch valueType.Format {
245 case ValueTypeFormatDid:
246 if _, err := syntax.ParseDID(op.OperandValue); err != nil {
247 return fmt.Errorf("failed to resolve did/handle: %w", err)
248 }
249 case ValueTypeFormatAny, "":
250 default:
251 return fmt.Errorf("unsupported format constraint: %q", valueType.Format)
252 }
253
254 case ConcreteTypeInt:
255 if op.OperandValue == "" {
256 return fmt.Errorf("integer type requires non-empty value")
257 }
258 if _, err := fmt.Sscanf(op.OperandValue, "%d", new(int)); err != nil {
259 return fmt.Errorf("value %q is not a valid integer", op.OperandValue)
260 }
261
262 if valueType.IsEnum() {
263 if !slices.Contains(valueType.Enum, op.OperandValue) {
264 return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum)
265 }
266 }
267
268 case ConcreteTypeBool:
269 if op.OperandValue != "true" && op.OperandValue != "false" {
270 return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", op.OperandValue)
271 }
272
273 // validate enum constraints if present (though uncommon for booleans)
274 if valueType.IsEnum() {
275 if !slices.Contains(valueType.Enum, op.OperandValue) {
276 return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum)
277 }
278 }
279
280 default:
281 return fmt.Errorf("unsupported value type: %q", valueType.Type)
282 }
283
284 return nil
285}
286
287// random color for a given seed
288func randomColor(seed string) string {
289 hash := sha1.Sum([]byte(seed))
290 hexStr := hex.EncodeToString(hash[:])
291 r := hexStr[0:2]
292 g := hexStr[2:4]
293 b := hexStr[4:6]
294
295 return fmt.Sprintf("#%s%s%s", r, g, b)
296}
297
298func (l LabelDefinition) GetColor() string {
299 if l.Color == nil {
300 seed := fmt.Sprintf("%d:%s:%s", l.Id, l.Did, l.Rkey)
301 color := randomColor(seed)
302 return color
303 }
304
305 return *l.Color
306}
307
308func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) {
309 created, err := time.Parse(time.RFC3339, record.CreatedAt)
310 if err != nil {
311 created = time.Time{}
312 }
313
314 multiple := false
315 if record.Multiple != nil {
316 multiple = *record.Multiple
317 }
318
319 var vt ValueType
320 if record.ValueType != nil {
321 vt = ValueTypeFromRecord(*record.ValueType)
322 }
323
324 return &LabelDefinition{
325 Did: did,
326 Rkey: rkey,
327
328 Name: record.Name,
329 ValueType: vt,
330 Scope: record.Scope,
331 Color: record.Color,
332 Multiple: multiple,
333 Created: created,
334 }, nil
335}
336
337type LabelOp struct {
338 Id int64
339 Did string
340 Rkey string
341 Subject syntax.ATURI
342 Operation LabelOperation
343 OperandKey string
344 OperandValue string
345 PerformedAt time.Time
346}
347
348func (l LabelOp) SortAt() time.Time {
349 // if createdat is invalid (before epoch), treat as null -> return zero time
350 if l.PerformedAt.Before(time.UnixMicro(0)) {
351 return time.Time{}
352 }
353 return l.PerformedAt
354}
355
356var _ Validator = new(LabelOp)
357
358func (l *LabelOp) Validate() error {
359 if _, err := syntax.ParseATURI(string(l.Subject)); err != nil {
360 return fmt.Errorf("invalid subject URI: %w", err)
361 }
362 if l.Operation != LabelOperationAdd && l.Operation != LabelOperationDel {
363 return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", l.Operation)
364 }
365 // Validate performed time is not zero/invalid
366 if l.PerformedAt.IsZero() {
367 return fmt.Errorf("performed_at timestamp is required")
368 }
369 return nil
370}
371
372type LabelOperation string
373
374const (
375 LabelOperationAdd LabelOperation = "add"
376 LabelOperationDel LabelOperation = "del"
377)
378
379// a record can create multiple label ops
380func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp {
381 performed, err := time.Parse(time.RFC3339, record.PerformedAt)
382 if err != nil {
383 performed = time.Time{}
384 }
385
386 mkOp := func(operand *tangled.LabelOp_Operand) LabelOp {
387 return LabelOp{
388 Did: did,
389 Rkey: rkey,
390 Subject: syntax.ATURI(record.Subject),
391 OperandKey: operand.Key,
392 OperandValue: operand.Value,
393 PerformedAt: performed,
394 }
395 }
396
397 var ops []LabelOp
398 // deletes first, then additions
399 for _, o := range record.Delete {
400 if o != nil {
401 op := mkOp(o)
402 op.Operation = LabelOperationDel
403 ops = append(ops, op)
404 }
405 }
406 for _, o := range record.Add {
407 if o != nil {
408 op := mkOp(o)
409 op.Operation = LabelOperationAdd
410 ops = append(ops, op)
411 }
412 }
413
414 return ops
415}
416
417func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp {
418 if len(ops) == 0 {
419 return tangled.LabelOp{}
420 }
421
422 // use the first operation to establish common fields
423 first := ops[0]
424 record := tangled.LabelOp{
425 Subject: string(first.Subject),
426 PerformedAt: first.PerformedAt.Format(time.RFC3339),
427 }
428
429 var addOperands []*tangled.LabelOp_Operand
430 var deleteOperands []*tangled.LabelOp_Operand
431
432 for _, op := range ops {
433 operand := &tangled.LabelOp_Operand{
434 Key: op.OperandKey,
435 Value: op.OperandValue,
436 }
437
438 switch op.Operation {
439 case LabelOperationAdd:
440 addOperands = append(addOperands, operand)
441 case LabelOperationDel:
442 deleteOperands = append(deleteOperands, operand)
443 default:
444 return tangled.LabelOp{}
445 }
446 }
447
448 record.Add = addOperands
449 record.Delete = deleteOperands
450
451 return record
452}
453
454type set = map[string]struct{}
455
456type LabelState struct {
457 inner map[string]set
458 names map[string]string
459}
460
461func NewLabelState() LabelState {
462 return LabelState{
463 inner: make(map[string]set),
464 names: make(map[string]string),
465 }
466}
467
468func (s LabelState) LabelNames() []string {
469 var result []string
470 for key, valset := range s.inner {
471 if valset == nil {
472 continue
473 }
474 if name, ok := s.names[key]; ok {
475 result = append(result, name)
476 }
477 }
478 return result
479}
480
481// LabelNameValues returns composite "name:value" strings for all labels
482// that have non-empty values.
483func (s LabelState) LabelNameValues() []string {
484 var result []string
485 for key, valset := range s.inner {
486 if valset == nil {
487 continue
488 }
489 name, ok := s.names[key]
490 if !ok {
491 continue
492 }
493 for val := range valset {
494 if val != "" {
495 result = append(result, name+":"+val)
496 }
497 }
498 }
499 return result
500}
501
502func (s LabelState) Inner() map[string]set {
503 return s.inner
504}
505
506func (s LabelState) SetName(key, name string) {
507 s.names[key] = name
508}
509
510func (s LabelState) ContainsLabel(l string) bool {
511 if valset, exists := s.inner[l]; exists {
512 if valset != nil {
513 return true
514 }
515 }
516
517 return false
518}
519
520// go maps behavior in templates make this necessary,
521// indexing a map and getting `set` in return is apparently truthy
522func (s LabelState) ContainsLabelAndVal(l, v string) bool {
523 if valset, exists := s.inner[l]; exists {
524 if _, exists := valset[v]; exists {
525 return true
526 }
527 }
528
529 return false
530}
531
532func (s LabelState) GetValSet(l string) set {
533 if valset, exists := s.inner[l]; exists {
534 return valset
535 } else {
536 return make(set)
537 }
538}
539
540type LabelApplicationCtx struct {
541 Defs map[string]*LabelDefinition // labelAt -> labelDef
542}
543
544var (
545 LabelNoOpError = errors.New("no-op")
546)
547
548func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error {
549 def, ok := c.Defs[op.OperandKey]
550 if !ok {
551 // this def was deleted, but an op exists, so we just skip over the op
552 return nil
553 }
554
555 if op.Subject != "" && len(def.Scope) > 0 && !slices.Contains(def.Scope, op.Subject.Collection().String()) {
556 return nil
557 }
558
559 state.names[op.OperandKey] = def.Name
560
561 switch op.Operation {
562 case LabelOperationAdd:
563 // if valueset is empty, init it
564 if state.inner[op.OperandKey] == nil {
565 state.inner[op.OperandKey] = make(set)
566 }
567
568 // if valueset is populated & this val alr exists, this labelop is a noop
569 if valueSet, exists := state.inner[op.OperandKey]; exists {
570 if _, exists = valueSet[op.OperandValue]; exists {
571 return LabelNoOpError
572 }
573 }
574
575 if def.Multiple {
576 // append to set
577 state.inner[op.OperandKey][op.OperandValue] = struct{}{}
578 } else {
579 // reset to just this value
580 state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}}
581 }
582
583 case LabelOperationDel:
584 // if label DNE, then deletion is a no-op
585 if valueSet, exists := state.inner[op.OperandKey]; !exists {
586 return LabelNoOpError
587 } else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op
588 return LabelNoOpError
589 }
590
591 if def.Multiple {
592 // remove from set
593 delete(state.inner[op.OperandKey], op.OperandValue)
594 } else {
595 // reset the entire label
596 delete(state.inner, op.OperandKey)
597 }
598
599 // if the map becomes empty, then set it to nil, this is just the inverse of add
600 if len(state.inner[op.OperandKey]) == 0 {
601 state.inner[op.OperandKey] = nil
602 }
603
604 }
605
606 return nil
607}
608
609func labelOpRank(op LabelOperation) int {
610 if op == LabelOperationDel {
611 return 0
612 }
613 return 1
614}
615
616func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) {
617 // sort label ops in sort order first
618 slices.SortFunc(ops, func(a, b LabelOp) int {
619 return cmp.Or(
620 a.SortAt().Compare(b.SortAt()),
621 cmp.Compare(a.Did, b.Did),
622 cmp.Compare(a.Rkey, b.Rkey),
623 cmp.Compare(labelOpRank(a.Operation), labelOpRank(b.Operation)),
624 cmp.Compare(a.OperandKey, b.OperandKey),
625 cmp.Compare(a.OperandValue, b.OperandValue),
626 )
627 })
628
629 // apply ops in sequence
630 for _, o := range ops {
631 _ = c.ApplyLabelOp(state, o)
632 }
633}
634
635// IsInverse checks if one label operation is the inverse of another
636// returns true if one is an add and the other is a delete with the same key and value
637func (op1 LabelOp) IsInverse(op2 LabelOp) bool {
638 if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue {
639 return false
640 }
641
642 return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) ||
643 (op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd)
644}
645
646// removes pairs of label operations that are inverses of each other
647// from the given slice. the function preserves the order of remaining operations.
648func ReduceLabelOps(ops []LabelOp) []LabelOp {
649 if len(ops) <= 1 {
650 return ops
651 }
652
653 keep := make([]bool, len(ops))
654 for i := range keep {
655 keep[i] = true
656 }
657
658 for i := range ops {
659 if !keep[i] {
660 continue
661 }
662
663 for j := i + 1; j < len(ops); j++ {
664 if !keep[j] {
665 continue
666 }
667
668 if ops[i].IsInverse(ops[j]) {
669 keep[i] = false
670 keep[j] = false
671 break // move to next i since this one is now eliminated
672 }
673 }
674 }
675
676 // build result slice with only kept operations
677 var result []LabelOp
678 for i, op := range ops {
679 if keep[i] {
680 result = append(result, op)
681 }
682 }
683
684 return result
685}
686
687func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) {
688 var labelDefs []LabelDefinition
689 ctx := context.Background()
690
691 for _, dl := range aturis {
692 atUri, err := syntax.ParseATURI(dl)
693 if err != nil {
694 return nil, fmt.Errorf("failed to parse AT-URI %s: %v", dl, err)
695 }
696 if atUri.Collection() != tangled.LabelDefinitionNSID {
697 return nil, fmt.Errorf("expected AT-URI pointing %s collection: %s", tangled.LabelDefinitionNSID, atUri)
698 }
699
700 owner, err := r.ResolveIdent(ctx, atUri.Authority().String())
701 if err != nil {
702 return nil, fmt.Errorf("failed to resolve default label owner DID %s: %v", atUri.Authority(), err)
703 }
704
705 xrpcc := xrpc.Client{
706 Host: owner.PDSEndpoint(),
707 }
708
709 record, err := atproto.RepoGetRecord(
710 ctx,
711 &xrpcc,
712 "",
713 atUri.Collection().String(),
714 atUri.Authority().String(),
715 atUri.RecordKey().String(),
716 )
717 if err != nil {
718 return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err)
719 }
720
721 if record != nil {
722 bytes, err := record.Value.MarshalJSON()
723 if err != nil {
724 return nil, fmt.Errorf("failed to marshal record value for %s: %v", atUri, err)
725 }
726
727 raw := json.RawMessage(bytes)
728 labelRecord := tangled.LabelDefinition{}
729 err = json.Unmarshal(raw, &labelRecord)
730 if err != nil {
731 return nil, fmt.Errorf("invalid record for %s: %w", atUri, err)
732 }
733
734 labelDef, err := LabelDefinitionFromRecord(
735 atUri.Authority().String(),
736 atUri.RecordKey().String(),
737 labelRecord,
738 )
739 if err != nil {
740 return nil, fmt.Errorf("failed to create label definition from record %s: %v", atUri, err)
741 }
742
743 labelDefs = append(labelDefs, *labelDef)
744 }
745 }
746
747 return labelDefs, nil
748}