A vibe coded tangled fork which supports pijul.
at sl/spindle-adapters 703 lines 18 kB view raw
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.Now() 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 IndexedAt time.Time 346} 347 348func (l LabelOp) SortAt() time.Time { 349 createdAt := l.PerformedAt 350 indexedAt := l.IndexedAt 351 352 // if we don't have an indexedat, fall back to now 353 if indexedAt.IsZero() { 354 indexedAt = time.Now() 355 } 356 357 // if createdat is invalid (before epoch), treat as null -> return zero time 358 if createdAt.Before(time.UnixMicro(0)) { 359 return time.Time{} 360 } 361 362 // if createdat is <= indexedat, use createdat 363 if createdAt.Before(indexedAt) || createdAt.Equal(indexedAt) { 364 return createdAt 365 } 366 367 // otherwise, createdat is in the future relative to indexedat -> use indexedat 368 return indexedAt 369} 370 371var _ Validator = new(LabelOp) 372 373func (l *LabelOp) Validate() error { 374 if _, err := syntax.ParseATURI(string(l.Subject)); err != nil { 375 return fmt.Errorf("invalid subject URI: %w", err) 376 } 377 if l.Operation != LabelOperationAdd && l.Operation != LabelOperationDel { 378 return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", l.Operation) 379 } 380 // Validate performed time is not zero/invalid 381 if l.PerformedAt.IsZero() { 382 return fmt.Errorf("performed_at timestamp is required") 383 } 384 return nil 385} 386 387type LabelOperation string 388 389const ( 390 LabelOperationAdd LabelOperation = "add" 391 LabelOperationDel LabelOperation = "del" 392) 393 394// a record can create multiple label ops 395func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp { 396 performed, err := time.Parse(time.RFC3339, record.PerformedAt) 397 if err != nil { 398 performed = time.Now() 399 } 400 401 mkOp := func(operand *tangled.LabelOp_Operand) LabelOp { 402 return LabelOp{ 403 Did: did, 404 Rkey: rkey, 405 Subject: syntax.ATURI(record.Subject), 406 OperandKey: operand.Key, 407 OperandValue: operand.Value, 408 PerformedAt: performed, 409 } 410 } 411 412 var ops []LabelOp 413 // deletes first, then additions 414 for _, o := range record.Delete { 415 if o != nil { 416 op := mkOp(o) 417 op.Operation = LabelOperationDel 418 ops = append(ops, op) 419 } 420 } 421 for _, o := range record.Add { 422 if o != nil { 423 op := mkOp(o) 424 op.Operation = LabelOperationAdd 425 ops = append(ops, op) 426 } 427 } 428 429 return ops 430} 431 432func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp { 433 if len(ops) == 0 { 434 return tangled.LabelOp{} 435 } 436 437 // use the first operation to establish common fields 438 first := ops[0] 439 record := tangled.LabelOp{ 440 Subject: string(first.Subject), 441 PerformedAt: first.PerformedAt.Format(time.RFC3339), 442 } 443 444 var addOperands []*tangled.LabelOp_Operand 445 var deleteOperands []*tangled.LabelOp_Operand 446 447 for _, op := range ops { 448 operand := &tangled.LabelOp_Operand{ 449 Key: op.OperandKey, 450 Value: op.OperandValue, 451 } 452 453 switch op.Operation { 454 case LabelOperationAdd: 455 addOperands = append(addOperands, operand) 456 case LabelOperationDel: 457 deleteOperands = append(deleteOperands, operand) 458 default: 459 return tangled.LabelOp{} 460 } 461 } 462 463 record.Add = addOperands 464 record.Delete = deleteOperands 465 466 return record 467} 468 469type set = map[string]struct{} 470 471type LabelState struct { 472 inner map[string]set 473} 474 475func NewLabelState() LabelState { 476 return LabelState{ 477 inner: make(map[string]set), 478 } 479} 480 481func (s LabelState) Inner() map[string]set { 482 return s.inner 483} 484 485func (s LabelState) ContainsLabel(l string) bool { 486 if valset, exists := s.inner[l]; exists { 487 if valset != nil { 488 return true 489 } 490 } 491 492 return false 493} 494 495// go maps behavior in templates make this necessary, 496// indexing a map and getting `set` in return is apparently truthy 497func (s LabelState) ContainsLabelAndVal(l, v string) bool { 498 if valset, exists := s.inner[l]; exists { 499 if _, exists := valset[v]; exists { 500 return true 501 } 502 } 503 504 return false 505} 506 507func (s LabelState) GetValSet(l string) set { 508 if valset, exists := s.inner[l]; exists { 509 return valset 510 } else { 511 return make(set) 512 } 513} 514 515type LabelApplicationCtx struct { 516 Defs map[string]*LabelDefinition // labelAt -> labelDef 517} 518 519var ( 520 LabelNoOpError = errors.New("no-op") 521) 522 523func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error { 524 def, ok := c.Defs[op.OperandKey] 525 if !ok { 526 // this def was deleted, but an op exists, so we just skip over the op 527 return nil 528 } 529 530 switch op.Operation { 531 case LabelOperationAdd: 532 // if valueset is empty, init it 533 if state.inner[op.OperandKey] == nil { 534 state.inner[op.OperandKey] = make(set) 535 } 536 537 // if valueset is populated & this val alr exists, this labelop is a noop 538 if valueSet, exists := state.inner[op.OperandKey]; exists { 539 if _, exists = valueSet[op.OperandValue]; exists { 540 return LabelNoOpError 541 } 542 } 543 544 if def.Multiple { 545 // append to set 546 state.inner[op.OperandKey][op.OperandValue] = struct{}{} 547 } else { 548 // reset to just this value 549 state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}} 550 } 551 552 case LabelOperationDel: 553 // if label DNE, then deletion is a no-op 554 if valueSet, exists := state.inner[op.OperandKey]; !exists { 555 return LabelNoOpError 556 } else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op 557 return LabelNoOpError 558 } 559 560 if def.Multiple { 561 // remove from set 562 delete(state.inner[op.OperandKey], op.OperandValue) 563 } else { 564 // reset the entire label 565 delete(state.inner, op.OperandKey) 566 } 567 568 // if the map becomes empty, then set it to nil, this is just the inverse of add 569 if len(state.inner[op.OperandKey]) == 0 { 570 state.inner[op.OperandKey] = nil 571 } 572 573 } 574 575 return nil 576} 577 578func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) { 579 // sort label ops in sort order first 580 slices.SortFunc(ops, func(a, b LabelOp) int { 581 return a.SortAt().Compare(b.SortAt()) 582 }) 583 584 // apply ops in sequence 585 for _, o := range ops { 586 _ = c.ApplyLabelOp(state, o) 587 } 588} 589 590// IsInverse checks if one label operation is the inverse of another 591// returns true if one is an add and the other is a delete with the same key and value 592func (op1 LabelOp) IsInverse(op2 LabelOp) bool { 593 if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue { 594 return false 595 } 596 597 return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) || 598 (op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd) 599} 600 601// removes pairs of label operations that are inverses of each other 602// from the given slice. the function preserves the order of remaining operations. 603func ReduceLabelOps(ops []LabelOp) []LabelOp { 604 if len(ops) <= 1 { 605 return ops 606 } 607 608 keep := make([]bool, len(ops)) 609 for i := range keep { 610 keep[i] = true 611 } 612 613 for i := range ops { 614 if !keep[i] { 615 continue 616 } 617 618 for j := i + 1; j < len(ops); j++ { 619 if !keep[j] { 620 continue 621 } 622 623 if ops[i].IsInverse(ops[j]) { 624 keep[i] = false 625 keep[j] = false 626 break // move to next i since this one is now eliminated 627 } 628 } 629 } 630 631 // build result slice with only kept operations 632 var result []LabelOp 633 for i, op := range ops { 634 if keep[i] { 635 result = append(result, op) 636 } 637 } 638 639 return result 640} 641 642func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) { 643 var labelDefs []LabelDefinition 644 ctx := context.Background() 645 646 for _, dl := range aturis { 647 atUri, err := syntax.ParseATURI(dl) 648 if err != nil { 649 return nil, fmt.Errorf("failed to parse AT-URI %s: %v", dl, err) 650 } 651 if atUri.Collection() != tangled.LabelDefinitionNSID { 652 return nil, fmt.Errorf("expected AT-URI pointing %s collection: %s", tangled.LabelDefinitionNSID, atUri) 653 } 654 655 owner, err := r.ResolveIdent(ctx, atUri.Authority().String()) 656 if err != nil { 657 return nil, fmt.Errorf("failed to resolve default label owner DID %s: %v", atUri.Authority(), err) 658 } 659 660 xrpcc := xrpc.Client{ 661 Host: owner.PDSEndpoint(), 662 } 663 664 record, err := atproto.RepoGetRecord( 665 ctx, 666 &xrpcc, 667 "", 668 atUri.Collection().String(), 669 atUri.Authority().String(), 670 atUri.RecordKey().String(), 671 ) 672 if err != nil { 673 return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) 674 } 675 676 if record != nil { 677 bytes, err := record.Value.MarshalJSON() 678 if err != nil { 679 return nil, fmt.Errorf("failed to marshal record value for %s: %v", atUri, err) 680 } 681 682 raw := json.RawMessage(bytes) 683 labelRecord := tangled.LabelDefinition{} 684 err = json.Unmarshal(raw, &labelRecord) 685 if err != nil { 686 return nil, fmt.Errorf("invalid record for %s: %w", atUri, err) 687 } 688 689 labelDef, err := LabelDefinitionFromRecord( 690 atUri.Authority().String(), 691 atUri.RecordKey().String(), 692 labelRecord, 693 ) 694 if err != nil { 695 return nil, fmt.Errorf("failed to create label definition from record %s: %v", atUri, err) 696 } 697 698 labelDefs = append(labelDefs, *labelDef) 699 } 700 } 701 702 return labelDefs, nil 703}