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