Monorepo for Tangled tangled.org

appview/db: add db handlers for label management

Signed-off-by: oppiliappan <me@oppi.li>

oppi.li df7a3879 b90e07de

verified
Changed files
+798 -3
appview
db
pages
repoinfo
-1
appview/db/db.go
··· 669 669 }) 670 670 conn.ExecContext(ctx, "pragma foreign_keys = on;") 671 671 672 - // run migrations 673 672 runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 674 673 tx.Exec(` 675 674 alter table repos add column spindle text;
+721
appview/db/label.go
··· 1 + package db 2 + 3 + import ( 4 + "crypto/sha1" 5 + "database/sql" 6 + "encoding/hex" 7 + "errors" 8 + "fmt" 9 + "log" 10 + "maps" 11 + "slices" 12 + "strings" 13 + "time" 14 + 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + "tangled.sh/tangled.sh/core/api/tangled" 17 + ) 18 + 19 + type ConcreteType string 20 + 21 + const ( 22 + ConcreteTypeNull ConcreteType = "null" 23 + ConcreteTypeString ConcreteType = "string" 24 + ConcreteTypeInt ConcreteType = "integer" 25 + ConcreteTypeBool ConcreteType = "boolean" 26 + ) 27 + 28 + type ValueTypeFormat string 29 + 30 + const ( 31 + ValueTypeFormatAny ValueTypeFormat = "any" 32 + ValueTypeFormatDid ValueTypeFormat = "did" 33 + ) 34 + 35 + // ValueType represents an atproto lexicon type definition with constraints 36 + type ValueType struct { 37 + Type ConcreteType `json:"type"` 38 + Format ValueTypeFormat `json:"format,omitempty"` 39 + Enum []string `json:"enum,omitempty"` 40 + } 41 + 42 + func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType { 43 + return tangled.LabelDefinition_ValueType{ 44 + Type: string(vt.Type), 45 + Format: string(vt.Format), 46 + Enum: vt.Enum, 47 + } 48 + } 49 + 50 + func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType { 51 + return ValueType{ 52 + Type: ConcreteType(record.Type), 53 + Format: ValueTypeFormat(record.Format), 54 + Enum: record.Enum, 55 + } 56 + } 57 + 58 + func (vt ValueType) IsConcreteType() bool { 59 + return vt.Type == ConcreteTypeNull || 60 + vt.Type == ConcreteTypeString || 61 + vt.Type == ConcreteTypeInt || 62 + vt.Type == ConcreteTypeBool 63 + } 64 + 65 + func (vt ValueType) IsNull() bool { 66 + return vt.Type == ConcreteTypeNull 67 + } 68 + 69 + func (vt ValueType) IsString() bool { 70 + return vt.Type == ConcreteTypeString 71 + } 72 + 73 + func (vt ValueType) IsInt() bool { 74 + return vt.Type == ConcreteTypeInt 75 + } 76 + 77 + func (vt ValueType) IsBool() bool { 78 + return vt.Type == ConcreteTypeBool 79 + } 80 + 81 + func (vt ValueType) IsEnumType() bool { 82 + return len(vt.Enum) > 0 83 + } 84 + 85 + type LabelDefinition struct { 86 + Id int64 87 + Did string 88 + Rkey string 89 + 90 + Name string 91 + ValueType ValueType 92 + Scope syntax.NSID 93 + Color *string 94 + Multiple bool 95 + Created time.Time 96 + } 97 + 98 + func (l *LabelDefinition) AtUri() syntax.ATURI { 99 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey)) 100 + } 101 + 102 + func (l *LabelDefinition) AsRecord() tangled.LabelDefinition { 103 + vt := l.ValueType.AsRecord() 104 + return tangled.LabelDefinition{ 105 + Name: l.Name, 106 + Color: l.Color, 107 + CreatedAt: l.Created.Format(time.RFC3339), 108 + Multiple: &l.Multiple, 109 + Scope: l.Scope.String(), 110 + ValueType: &vt, 111 + } 112 + } 113 + 114 + // random color for a given seed 115 + func randomColor(seed string) string { 116 + hash := sha1.Sum([]byte(seed)) 117 + hexStr := hex.EncodeToString(hash[:]) 118 + r := hexStr[0:2] 119 + g := hexStr[2:4] 120 + b := hexStr[4:6] 121 + 122 + return fmt.Sprintf("#%s%s%s", r, g, b) 123 + } 124 + 125 + func (ld LabelDefinition) GetColor() string { 126 + if ld.Color == nil { 127 + seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey) 128 + color := randomColor(seed) 129 + return color 130 + } 131 + 132 + return *ld.Color 133 + } 134 + 135 + func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) LabelDefinition { 136 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 137 + if err != nil { 138 + created = time.Now() 139 + } 140 + 141 + multiple := false 142 + if record.Multiple != nil { 143 + multiple = *record.Multiple 144 + } 145 + 146 + var vt ValueType 147 + if record.ValueType != nil { 148 + vt = ValueTypeFromRecord(*record.ValueType) 149 + } 150 + 151 + return LabelDefinition{ 152 + Did: did, 153 + Rkey: rkey, 154 + 155 + Name: record.Name, 156 + ValueType: vt, 157 + Scope: syntax.NSID(record.Scope), 158 + Color: record.Color, 159 + Multiple: multiple, 160 + Created: created, 161 + } 162 + } 163 + 164 + func DeleteLabelDefinition(e Execer, filters ...filter) error { 165 + var conditions []string 166 + var args []any 167 + for _, filter := range filters { 168 + conditions = append(conditions, filter.Condition()) 169 + args = append(args, filter.Arg()...) 170 + } 171 + whereClause := "" 172 + if conditions != nil { 173 + whereClause = " where " + strings.Join(conditions, " and ") 174 + } 175 + query := fmt.Sprintf(`delete from label_definitions %s`, whereClause) 176 + _, err := e.Exec(query, args...) 177 + return err 178 + } 179 + 180 + func AddLabelDefinition(e Execer, l *LabelDefinition) (int64, error) { 181 + result, err := e.Exec( 182 + `insert into label_definitions ( 183 + did, 184 + rkey, 185 + name, 186 + value_type, 187 + value_format, 188 + value_enum, 189 + scope, 190 + color, 191 + multiple, 192 + created 193 + ) 194 + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 195 + on conflict(did, rkey) do update set 196 + name = excluded.name, 197 + scope = excluded.scope, 198 + color = excluded.color, 199 + multiple = excluded.multiple`, 200 + l.Did, 201 + l.Rkey, 202 + l.Name, 203 + l.ValueType.Type, 204 + l.ValueType.Format, 205 + strings.Join(l.ValueType.Enum, ","), 206 + l.Scope.String(), 207 + l.Color, 208 + l.Multiple, 209 + l.Created.Format(time.RFC3339), 210 + time.Now().Format(time.RFC3339), 211 + ) 212 + if err != nil { 213 + return 0, err 214 + } 215 + 216 + id, err := result.LastInsertId() 217 + if err != nil { 218 + return 0, err 219 + } 220 + 221 + l.Id = id 222 + 223 + return id, nil 224 + } 225 + 226 + func GetLabelDefinitions(e Execer, filters ...filter) ([]LabelDefinition, error) { 227 + var labelDefinitions []LabelDefinition 228 + var conditions []string 229 + var args []any 230 + 231 + for _, filter := range filters { 232 + conditions = append(conditions, filter.Condition()) 233 + args = append(args, filter.Arg()...) 234 + } 235 + 236 + whereClause := "" 237 + if conditions != nil { 238 + whereClause = " where " + strings.Join(conditions, " and ") 239 + } 240 + 241 + query := fmt.Sprintf( 242 + ` 243 + select 244 + id, 245 + did, 246 + rkey, 247 + name, 248 + value_type, 249 + value_format, 250 + value_enum, 251 + scope, 252 + color, 253 + multiple, 254 + created 255 + from label_definitions 256 + %s 257 + order by created 258 + `, 259 + whereClause, 260 + ) 261 + 262 + rows, err := e.Query(query, args...) 263 + if err != nil { 264 + return nil, err 265 + } 266 + defer rows.Close() 267 + 268 + for rows.Next() { 269 + var labelDefinition LabelDefinition 270 + var createdAt, enumVariants string 271 + var color sql.Null[string] 272 + var multiple int 273 + 274 + if err := rows.Scan( 275 + &labelDefinition.Id, 276 + &labelDefinition.Did, 277 + &labelDefinition.Rkey, 278 + &labelDefinition.Name, 279 + &labelDefinition.ValueType.Type, 280 + &labelDefinition.ValueType.Format, 281 + &enumVariants, 282 + &labelDefinition.Scope, 283 + &color, 284 + &multiple, 285 + &createdAt, 286 + ); err != nil { 287 + return nil, err 288 + } 289 + 290 + labelDefinition.Created, err = time.Parse(time.RFC3339, createdAt) 291 + if err != nil { 292 + labelDefinition.Created = time.Now() 293 + } 294 + 295 + if color.Valid { 296 + labelDefinition.Color = &color.V 297 + } 298 + 299 + if multiple != 0 { 300 + labelDefinition.Multiple = true 301 + } 302 + 303 + if enumVariants != "" { 304 + labelDefinition.ValueType.Enum = strings.Split(enumVariants, ",") 305 + } 306 + 307 + labelDefinitions = append(labelDefinitions, labelDefinition) 308 + } 309 + 310 + return labelDefinitions, nil 311 + } 312 + 313 + // helper to get exactly one label def 314 + func GetLabelDefinition(e Execer, filters ...filter) (*LabelDefinition, error) { 315 + labels, err := GetLabelDefinitions(e, filters...) 316 + if err != nil { 317 + return nil, err 318 + } 319 + 320 + if labels == nil { 321 + return nil, sql.ErrNoRows 322 + } 323 + 324 + if len(labels) != 1 { 325 + return nil, fmt.Errorf("too many rows returned") 326 + } 327 + 328 + return &labels[0], nil 329 + } 330 + 331 + type LabelOp struct { 332 + Id int64 333 + Did string 334 + Rkey string 335 + Subject syntax.ATURI 336 + Operation LabelOperation 337 + OperandKey string 338 + OperandValue string 339 + PerformedAt time.Time 340 + IndexedAt time.Time 341 + } 342 + 343 + func (l LabelOp) SortAt() time.Time { 344 + createdAt := l.PerformedAt 345 + indexedAt := l.IndexedAt 346 + 347 + // if we don't have an indexedat, fall back to now 348 + if indexedAt.IsZero() { 349 + indexedAt = time.Now() 350 + } 351 + 352 + // if createdat is invalid (before epoch), treat as null -> return zero time 353 + if createdAt.Before(time.UnixMicro(0)) { 354 + return time.Time{} 355 + } 356 + 357 + // if createdat is <= indexedat, use createdat 358 + if createdAt.Before(indexedAt) || createdAt.Equal(indexedAt) { 359 + return createdAt 360 + } 361 + 362 + // otherwise, createdat is in the future relative to indexedat -> use indexedat 363 + return indexedAt 364 + } 365 + 366 + type LabelOperation string 367 + 368 + const ( 369 + LabelOperationAdd LabelOperation = "add" 370 + LabelOperationDel LabelOperation = "del" 371 + ) 372 + 373 + // a record can create multiple label ops 374 + func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp { 375 + performed, err := time.Parse(time.RFC3339, record.PerformedAt) 376 + if err != nil { 377 + performed = time.Now() 378 + } 379 + 380 + mkOp := func(operand *tangled.LabelOp_Operand) LabelOp { 381 + return LabelOp{ 382 + Did: did, 383 + Rkey: rkey, 384 + Subject: syntax.ATURI(record.Subject), 385 + OperandKey: operand.Key, 386 + OperandValue: operand.Value, 387 + PerformedAt: performed, 388 + } 389 + } 390 + 391 + var ops []LabelOp 392 + for _, o := range record.Add { 393 + if o != nil { 394 + op := mkOp(o) 395 + op.Operation = LabelOperationAdd 396 + ops = append(ops, op) 397 + } 398 + } 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 + 407 + return ops 408 + } 409 + 410 + func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp { 411 + if len(ops) == 0 { 412 + return tangled.LabelOp{} 413 + } 414 + 415 + // use the first operation to establish common fields 416 + first := ops[0] 417 + record := tangled.LabelOp{ 418 + Subject: string(first.Subject), 419 + PerformedAt: first.PerformedAt.Format(time.RFC3339), 420 + } 421 + 422 + var addOperands []*tangled.LabelOp_Operand 423 + var deleteOperands []*tangled.LabelOp_Operand 424 + 425 + for _, op := range ops { 426 + operand := &tangled.LabelOp_Operand{ 427 + Key: op.OperandKey, 428 + Value: op.OperandValue, 429 + } 430 + 431 + switch op.Operation { 432 + case LabelOperationAdd: 433 + addOperands = append(addOperands, operand) 434 + case LabelOperationDel: 435 + deleteOperands = append(deleteOperands, operand) 436 + default: 437 + return tangled.LabelOp{} 438 + } 439 + } 440 + 441 + record.Add = addOperands 442 + record.Delete = deleteOperands 443 + 444 + return record 445 + } 446 + 447 + func AddLabelOp(e Execer, l *LabelOp) (int64, error) { 448 + now := time.Now() 449 + result, err := e.Exec( 450 + `insert into label_ops ( 451 + did, 452 + rkey, 453 + subject, 454 + operation, 455 + operand_key, 456 + operand_value, 457 + performed, 458 + indexed 459 + ) 460 + values (?, ?, ?, ?, ?, ?, ?, ?) 461 + on conflict(did, rkey, subject, operand_key, operand_value) do update set 462 + operation = excluded.operation, 463 + operand_value = excluded.operand_value, 464 + performed = excluded.performed, 465 + indexed = excluded.indexed`, 466 + l.Did, 467 + l.Rkey, 468 + l.Subject.String(), 469 + string(l.Operation), 470 + l.OperandKey, 471 + l.OperandValue, 472 + l.PerformedAt.Format(time.RFC3339), 473 + now.Format(time.RFC3339), 474 + ) 475 + if err != nil { 476 + return 0, err 477 + } 478 + 479 + id, err := result.LastInsertId() 480 + if err != nil { 481 + return 0, err 482 + } 483 + 484 + l.Id = id 485 + l.IndexedAt = now 486 + 487 + return id, nil 488 + } 489 + 490 + func GetLabelOps(e Execer, filters ...filter) ([]LabelOp, error) { 491 + var labelOps []LabelOp 492 + var conditions []string 493 + var args []any 494 + 495 + for _, filter := range filters { 496 + conditions = append(conditions, filter.Condition()) 497 + args = append(args, filter.Arg()...) 498 + } 499 + 500 + whereClause := "" 501 + if conditions != nil { 502 + whereClause = " where " + strings.Join(conditions, " and ") 503 + } 504 + 505 + query := fmt.Sprintf( 506 + ` 507 + select 508 + id, 509 + did, 510 + rkey, 511 + subject, 512 + operation, 513 + operand_key, 514 + operand_value, 515 + performed, 516 + indexed 517 + from label_ops 518 + %s 519 + order by indexed 520 + `, 521 + whereClause, 522 + ) 523 + 524 + rows, err := e.Query(query, args...) 525 + if err != nil { 526 + return nil, err 527 + } 528 + defer rows.Close() 529 + 530 + for rows.Next() { 531 + var labelOp LabelOp 532 + var performedAt, indexedAt string 533 + 534 + if err := rows.Scan( 535 + &labelOp.Id, 536 + &labelOp.Did, 537 + &labelOp.Rkey, 538 + &labelOp.Subject, 539 + &labelOp.Operation, 540 + &labelOp.OperandKey, 541 + &labelOp.OperandValue, 542 + &performedAt, 543 + &indexedAt, 544 + ); err != nil { 545 + return nil, err 546 + } 547 + 548 + labelOp.PerformedAt, err = time.Parse(time.RFC3339, performedAt) 549 + if err != nil { 550 + labelOp.PerformedAt = time.Now() 551 + } 552 + 553 + labelOp.IndexedAt, err = time.Parse(time.RFC3339, indexedAt) 554 + if err != nil { 555 + labelOp.IndexedAt = time.Now() 556 + } 557 + 558 + labelOps = append(labelOps, labelOp) 559 + } 560 + 561 + return labelOps, nil 562 + } 563 + 564 + // get labels for a given list of subject URIs 565 + func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]LabelState, error) { 566 + ops, err := GetLabelOps(e, filters...) 567 + if err != nil { 568 + return nil, err 569 + } 570 + 571 + // group ops by subject 572 + opsBySubject := make(map[syntax.ATURI][]LabelOp) 573 + for _, op := range ops { 574 + subject := syntax.ATURI(op.Subject) 575 + opsBySubject[subject] = append(opsBySubject[subject], op) 576 + } 577 + 578 + // get all unique labelats for creating the context 579 + labelAtSet := make(map[string]bool) 580 + for _, op := range ops { 581 + labelAtSet[op.OperandKey] = true 582 + } 583 + labelAts := slices.Collect(maps.Keys(labelAtSet)) 584 + 585 + actx, err := NewLabelApplicationCtx(e, FilterIn("at_uri", labelAts)) 586 + if err != nil { 587 + return nil, err 588 + } 589 + 590 + // apply label ops for each subject and collect results 591 + results := make(map[syntax.ATURI]LabelState) 592 + for subject, subjectOps := range opsBySubject { 593 + state := NewLabelState() 594 + actx.ApplyLabelOps(state, subjectOps) 595 + results[subject] = state 596 + } 597 + 598 + log.Println("results for get labels", "s", results) 599 + 600 + return results, nil 601 + } 602 + 603 + type set = map[string]struct{} 604 + 605 + type LabelState struct { 606 + inner map[string]set 607 + } 608 + 609 + func NewLabelState() LabelState { 610 + return LabelState{ 611 + inner: make(map[string]set), 612 + } 613 + } 614 + 615 + func (s LabelState) Inner() map[string]set { 616 + return s.inner 617 + } 618 + 619 + func (s LabelState) ContainsLabel(l string) bool { 620 + if valset, exists := s.inner[l]; exists { 621 + if valset != nil { 622 + return true 623 + } 624 + } 625 + 626 + return false 627 + } 628 + 629 + func (s *LabelState) GetValSet(l string) set { 630 + return s.inner[l] 631 + } 632 + 633 + type LabelApplicationCtx struct { 634 + defs map[string]*LabelDefinition // labelAt -> labelDef 635 + } 636 + 637 + var ( 638 + LabelNoOpError = errors.New("no-op") 639 + ) 640 + 641 + func NewLabelApplicationCtx(e Execer, filters ...filter) (*LabelApplicationCtx, error) { 642 + labels, err := GetLabelDefinitions(e, filters...) 643 + if err != nil { 644 + return nil, err 645 + } 646 + 647 + defs := make(map[string]*LabelDefinition) 648 + for _, l := range labels { 649 + defs[l.AtUri().String()] = &l 650 + } 651 + 652 + return &LabelApplicationCtx{defs}, nil 653 + } 654 + 655 + func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error { 656 + def := c.defs[op.OperandKey] 657 + 658 + switch op.Operation { 659 + case LabelOperationAdd: 660 + // if valueset is empty, init it 661 + if state.inner[op.OperandKey] == nil { 662 + state.inner[op.OperandKey] = make(set) 663 + } 664 + 665 + // if valueset is populated & this val alr exists, this labelop is a noop 666 + if valueSet, exists := state.inner[op.OperandKey]; exists { 667 + if _, exists = valueSet[op.OperandValue]; exists { 668 + return LabelNoOpError 669 + } 670 + } 671 + 672 + if def.Multiple { 673 + // append to set 674 + state.inner[op.OperandKey][op.OperandValue] = struct{}{} 675 + } else { 676 + // reset to just this value 677 + state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}} 678 + } 679 + 680 + case LabelOperationDel: 681 + // if label DNE, then deletion is a no-op 682 + if valueSet, exists := state.inner[op.OperandKey]; !exists { 683 + return LabelNoOpError 684 + } else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op 685 + return LabelNoOpError 686 + } 687 + 688 + if def.Multiple { 689 + // remove from set 690 + delete(state.inner[op.OperandKey], op.OperandValue) 691 + } else { 692 + // reset the entire label 693 + delete(state.inner, op.OperandKey) 694 + } 695 + 696 + // if the map becomes empty, then set it to nil, this is just the inverse of add 697 + if len(state.inner[op.OperandKey]) == 0 { 698 + state.inner[op.OperandKey] = nil 699 + } 700 + 701 + } 702 + 703 + return nil 704 + } 705 + 706 + func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) { 707 + // sort label ops in sort order first 708 + slices.SortFunc(ops, func(a, b LabelOp) int { 709 + return a.SortAt().Compare(b.SortAt()) 710 + }) 711 + 712 + // apply ops in sequence 713 + for _, o := range ops { 714 + _ = c.ApplyLabelOp(state, o) 715 + } 716 + } 717 + 718 + type Label struct { 719 + def *LabelDefinition 720 + val set 721 + }
+75
appview/db/repos.go
··· 30 30 Source string 31 31 } 32 32 33 + func (r *Repo) AsRecord() tangled.Repo { 34 + return tangled.Repo{} 35 + } 36 + 33 37 func (r Repo) RepoAt() syntax.ATURI { 34 38 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 35 39 } ··· 494 498 IssueCount IssueCount 495 499 PullCount PullCount 496 500 } 501 + 502 + type RepoLabel struct { 503 + Id int64 504 + RepoAt syntax.ATURI 505 + LabelAt syntax.ATURI 506 + } 507 + 508 + func SubscribeLabel(e Execer, rl *RepoLabel) error { 509 + query := `insert or ignore into repo_labels (repo_at, label_at) values (?, ?)` 510 + 511 + _, err := e.Exec(query, rl.RepoAt.String(), rl.LabelAt.String()) 512 + return err 513 + } 514 + 515 + func UnsubscribeLabel(e Execer, filters ...filter) error { 516 + var conditions []string 517 + var args []any 518 + for _, filter := range filters { 519 + conditions = append(conditions, filter.Condition()) 520 + args = append(args, filter.Arg()...) 521 + } 522 + 523 + whereClause := "" 524 + if conditions != nil { 525 + whereClause = " where " + strings.Join(conditions, " and ") 526 + } 527 + 528 + query := fmt.Sprintf(`delete from repo_labels %s`, whereClause) 529 + _, err := e.Exec(query, args...) 530 + return err 531 + } 532 + 533 + func GetRepoLabels(e Execer, filters ...filter) ([]RepoLabel, error) { 534 + var conditions []string 535 + var args []any 536 + for _, filter := range filters { 537 + conditions = append(conditions, filter.Condition()) 538 + args = append(args, filter.Arg()...) 539 + } 540 + 541 + whereClause := "" 542 + if conditions != nil { 543 + whereClause = " where " + strings.Join(conditions, " and ") 544 + } 545 + 546 + query := fmt.Sprintf(`select id, repo_at, label_at from repo_labels %s`, whereClause) 547 + 548 + rows, err := e.Query(query, args...) 549 + if err != nil { 550 + return nil, err 551 + } 552 + defer rows.Close() 553 + 554 + var labels []RepoLabel 555 + for rows.Next() { 556 + var label RepoLabel 557 + 558 + err := rows.Scan(&label.Id, &label.RepoAt, &label.LabelAt) 559 + if err != nil { 560 + return nil, err 561 + } 562 + 563 + labels = append(labels, label) 564 + } 565 + 566 + if err = rows.Err(); err != nil { 567 + return nil, err 568 + } 569 + 570 + return labels, nil 571 + }
+2 -2
appview/pages/repoinfo/repoinfo.go
··· 24 24 } 25 25 26 26 func (r RepoInfo) OwnerWithoutAt() string { 27 - if strings.HasPrefix(r.OwnerWithAt(), "@") { 28 - return strings.TrimPrefix(r.OwnerWithAt(), "@") 27 + if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok { 28 + return after 29 29 } else { 30 30 return userutil.FlattenDid(r.OwnerDid) 31 31 }