diff --git a/appview/db/issues.go b/appview/db/issues.go index 937c6079..9221863a 100644 --- a/appview/db/issues.go +++ b/appview/db/issues.go @@ -11,6 +11,7 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/api/tangled" + "tangled.org/core/appview/models" "tangled.org/core/appview/pagination" ) @@ -30,7 +31,7 @@ type Issue struct { // optionally, populate this when querying for reverse mappings // like comment counts, parent repo etc. Comments []IssueComment - Labels LabelState + Labels models.LabelState Repo *Repo } diff --git a/appview/db/label.go b/appview/db/label.go index fa1f5590..7ab92465 100644 --- a/appview/db/label.go +++ b/appview/db/label.go @@ -1,10 +1,7 @@ package db import ( - "crypto/sha1" "database/sql" - "encoding/hex" - "errors" "fmt" "maps" "slices" @@ -12,181 +9,11 @@ import ( "time" "github.com/bluesky-social/indigo/atproto/syntax" - "tangled.sh/tangled.sh/core/api/tangled" - "tangled.sh/tangled.sh/core/consts" + "tangled.org/core/appview/models" ) -type ConcreteType string - -const ( - ConcreteTypeNull ConcreteType = "null" - ConcreteTypeString ConcreteType = "string" - ConcreteTypeInt ConcreteType = "integer" - ConcreteTypeBool ConcreteType = "boolean" -) - -type ValueTypeFormat string - -const ( - ValueTypeFormatAny ValueTypeFormat = "any" - ValueTypeFormatDid ValueTypeFormat = "did" -) - -// ValueType represents an atproto lexicon type definition with constraints -type ValueType struct { - Type ConcreteType `json:"type"` - Format ValueTypeFormat `json:"format,omitempty"` - Enum []string `json:"enum,omitempty"` -} - -func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType { - return tangled.LabelDefinition_ValueType{ - Type: string(vt.Type), - Format: string(vt.Format), - Enum: vt.Enum, - } -} - -func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType { - return ValueType{ - Type: ConcreteType(record.Type), - Format: ValueTypeFormat(record.Format), - Enum: record.Enum, - } -} - -func (vt ValueType) IsConcreteType() bool { - return vt.Type == ConcreteTypeNull || - vt.Type == ConcreteTypeString || - vt.Type == ConcreteTypeInt || - vt.Type == ConcreteTypeBool -} - -func (vt ValueType) IsNull() bool { - return vt.Type == ConcreteTypeNull -} - -func (vt ValueType) IsString() bool { - return vt.Type == ConcreteTypeString -} - -func (vt ValueType) IsInt() bool { - return vt.Type == ConcreteTypeInt -} - -func (vt ValueType) IsBool() bool { - return vt.Type == ConcreteTypeBool -} - -func (vt ValueType) IsEnum() bool { - return len(vt.Enum) > 0 -} - -func (vt ValueType) IsDidFormat() bool { - return vt.Format == ValueTypeFormatDid -} - -func (vt ValueType) IsAnyFormat() bool { - return vt.Format == ValueTypeFormatAny -} - -type LabelDefinition struct { - Id int64 - Did string - Rkey string - - Name string - ValueType ValueType - Scope []string - Color *string - Multiple bool - Created time.Time -} - -func (l *LabelDefinition) AtUri() syntax.ATURI { - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey)) -} - -func (l *LabelDefinition) AsRecord() tangled.LabelDefinition { - vt := l.ValueType.AsRecord() - return tangled.LabelDefinition{ - Name: l.Name, - Color: l.Color, - CreatedAt: l.Created.Format(time.RFC3339), - Multiple: &l.Multiple, - Scope: l.Scope, - ValueType: &vt, - } -} - -// random color for a given seed -func randomColor(seed string) string { - hash := sha1.Sum([]byte(seed)) - hexStr := hex.EncodeToString(hash[:]) - r := hexStr[0:2] - g := hexStr[2:4] - b := hexStr[4:6] - - return fmt.Sprintf("#%s%s%s", r, g, b) -} - -func (ld LabelDefinition) GetColor() string { - if ld.Color == nil { - seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey) - color := randomColor(seed) - return color - } - - return *ld.Color -} - -func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) { - created, err := time.Parse(time.RFC3339, record.CreatedAt) - if err != nil { - created = time.Now() - } - - multiple := false - if record.Multiple != nil { - multiple = *record.Multiple - } - - var vt ValueType - if record.ValueType != nil { - vt = ValueTypeFromRecord(*record.ValueType) - } - - return &LabelDefinition{ - Did: did, - Rkey: rkey, - - Name: record.Name, - ValueType: vt, - Scope: record.Scope, - Color: record.Color, - Multiple: multiple, - Created: created, - }, nil -} - -func DeleteLabelDefinition(e Execer, filters ...filter) error { - var conditions []string - var args []any - for _, filter := range filters { - conditions = append(conditions, filter.Condition()) - args = append(args, filter.Arg()...) - } - whereClause := "" - if conditions != nil { - whereClause = " where " + strings.Join(conditions, " and ") - } - query := fmt.Sprintf(`delete from label_definitions %s`, whereClause) - _, err := e.Exec(query, args...) - return err -} - // no updating type for now -func AddLabelDefinition(e Execer, l *LabelDefinition) (int64, error) { +func AddLabelDefinition(e Execer, l *models.LabelDefinition) (int64, error) { result, err := e.Exec( `insert into label_definitions ( did, @@ -232,8 +59,24 @@ func AddLabelDefinition(e Execer, l *LabelDefinition) (int64, error) { return id, nil } -func GetLabelDefinitions(e Execer, filters ...filter) ([]LabelDefinition, error) { - var labelDefinitions []LabelDefinition +func DeleteLabelDefinition(e Execer, filters ...filter) error { + var conditions []string + var args []any + for _, filter := range filters { + conditions = append(conditions, filter.Condition()) + args = append(args, filter.Arg()...) + } + whereClause := "" + if conditions != nil { + whereClause = " where " + strings.Join(conditions, " and ") + } + query := fmt.Sprintf(`delete from label_definitions %s`, whereClause) + _, err := e.Exec(query, args...) + return err +} + +func GetLabelDefinitions(e Execer, filters ...filter) ([]models.LabelDefinition, error) { + var labelDefinitions []models.LabelDefinition var conditions []string var args []any @@ -275,7 +118,7 @@ func GetLabelDefinitions(e Execer, filters ...filter) ([]LabelDefinition, error) defer rows.Close() for rows.Next() { - var labelDefinition LabelDefinition + var labelDefinition models.LabelDefinition var createdAt, enumVariants, scopes string var color sql.Null[string] var multiple int @@ -324,7 +167,7 @@ func GetLabelDefinitions(e Execer, filters ...filter) ([]LabelDefinition, error) } // helper to get exactly one label def -func GetLabelDefinition(e Execer, filters ...filter) (*LabelDefinition, error) { +func GetLabelDefinition(e Execer, filters ...filter) (*models.LabelDefinition, error) { labels, err := GetLabelDefinitions(e, filters...) if err != nil { return nil, err @@ -341,123 +184,7 @@ func GetLabelDefinition(e Execer, filters ...filter) (*LabelDefinition, error) { return &labels[0], nil } -type LabelOp struct { - Id int64 - Did string - Rkey string - Subject syntax.ATURI - Operation LabelOperation - OperandKey string - OperandValue string - PerformedAt time.Time - IndexedAt time.Time -} - -func (l LabelOp) SortAt() time.Time { - createdAt := l.PerformedAt - indexedAt := l.IndexedAt - - // if we don't have an indexedat, fall back to now - if indexedAt.IsZero() { - indexedAt = time.Now() - } - - // if createdat is invalid (before epoch), treat as null -> return zero time - if createdAt.Before(time.UnixMicro(0)) { - return time.Time{} - } - - // if createdat is <= indexedat, use createdat - if createdAt.Before(indexedAt) || createdAt.Equal(indexedAt) { - return createdAt - } - - // otherwise, createdat is in the future relative to indexedat -> use indexedat - return indexedAt -} - -type LabelOperation string - -const ( - LabelOperationAdd LabelOperation = "add" - LabelOperationDel LabelOperation = "del" -) - -// a record can create multiple label ops -func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp { - performed, err := time.Parse(time.RFC3339, record.PerformedAt) - if err != nil { - performed = time.Now() - } - - mkOp := func(operand *tangled.LabelOp_Operand) LabelOp { - return LabelOp{ - Did: did, - Rkey: rkey, - Subject: syntax.ATURI(record.Subject), - OperandKey: operand.Key, - OperandValue: operand.Value, - PerformedAt: performed, - } - } - - var ops []LabelOp - for _, o := range record.Add { - if o != nil { - op := mkOp(o) - op.Operation = LabelOperationAdd - ops = append(ops, op) - } - } - for _, o := range record.Delete { - if o != nil { - op := mkOp(o) - op.Operation = LabelOperationDel - ops = append(ops, op) - } - } - - return ops -} - -func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp { - if len(ops) == 0 { - return tangled.LabelOp{} - } - - // use the first operation to establish common fields - first := ops[0] - record := tangled.LabelOp{ - Subject: string(first.Subject), - PerformedAt: first.PerformedAt.Format(time.RFC3339), - } - - var addOperands []*tangled.LabelOp_Operand - var deleteOperands []*tangled.LabelOp_Operand - - for _, op := range ops { - operand := &tangled.LabelOp_Operand{ - Key: op.OperandKey, - Value: op.OperandValue, - } - - switch op.Operation { - case LabelOperationAdd: - addOperands = append(addOperands, operand) - case LabelOperationDel: - deleteOperands = append(deleteOperands, operand) - default: - return tangled.LabelOp{} - } - } - - record.Add = addOperands - record.Delete = deleteOperands - - return record -} - -func AddLabelOp(e Execer, l *LabelOp) (int64, error) { +func AddLabelOp(e Execer, l *models.LabelOp) (int64, error) { now := time.Now() result, err := e.Exec( `insert into label_ops ( @@ -500,8 +227,8 @@ func AddLabelOp(e Execer, l *LabelOp) (int64, error) { return id, nil } -func GetLabelOps(e Execer, filters ...filter) ([]LabelOp, error) { - var labelOps []LabelOp +func GetLabelOps(e Execer, filters ...filter) ([]models.LabelOp, error) { + var labelOps []models.LabelOp var conditions []string var args []any @@ -541,7 +268,7 @@ func GetLabelOps(e Execer, filters ...filter) ([]LabelOp, error) { defer rows.Close() for rows.Next() { - var labelOp LabelOp + var labelOp models.LabelOp var performedAt, indexedAt string if err := rows.Scan( @@ -575,14 +302,14 @@ func GetLabelOps(e Execer, filters ...filter) ([]LabelOp, error) { } // get labels for a given list of subject URIs -func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]LabelState, error) { +func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]models.LabelState, error) { ops, err := GetLabelOps(e, filters...) if err != nil { return nil, err } // group ops by subject - opsBySubject := make(map[syntax.ATURI][]LabelOp) + opsBySubject := make(map[syntax.ATURI][]models.LabelOp) for _, op := range ops { subject := syntax.ATURI(op.Subject) opsBySubject[subject] = append(opsBySubject[subject], op) @@ -601,9 +328,9 @@ func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]LabelState, error) } // apply label ops for each subject and collect results - results := make(map[syntax.ATURI]LabelState) + results := make(map[syntax.ATURI]models.LabelState) for subject, subjectOps := range opsBySubject { - state := NewLabelState() + state := models.NewLabelState() actx.ApplyLabelOps(state, subjectOps) results[subject] = state } @@ -611,206 +338,16 @@ func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]LabelState, error) return results, nil } -type set = map[string]struct{} - -type LabelState struct { - inner map[string]set -} - -func NewLabelState() LabelState { - return LabelState{ - inner: make(map[string]set), - } -} - -func (s LabelState) Inner() map[string]set { - return s.inner -} - -func (s LabelState) ContainsLabel(l string) bool { - if valset, exists := s.inner[l]; exists { - if valset != nil { - return true - } - } - - return false -} - -// go maps behavior in templates make this necessary, -// indexing a map and getting `set` in return is apparently truthy -func (s LabelState) ContainsLabelAndVal(l, v string) bool { - if valset, exists := s.inner[l]; exists { - if _, exists := valset[v]; exists { - return true - } - } - - return false -} - -func (s LabelState) GetValSet(l string) set { - if valset, exists := s.inner[l]; exists { - return valset - } else { - return make(set) - } -} - -type LabelApplicationCtx struct { - Defs map[string]*LabelDefinition // labelAt -> labelDef -} - -var ( - LabelNoOpError = errors.New("no-op") -) - -func NewLabelApplicationCtx(e Execer, filters ...filter) (*LabelApplicationCtx, error) { +func NewLabelApplicationCtx(e Execer, filters ...filter) (*models.LabelApplicationCtx, error) { labels, err := GetLabelDefinitions(e, filters...) if err != nil { return nil, err } - defs := make(map[string]*LabelDefinition) + defs := make(map[string]*models.LabelDefinition) for _, l := range labels { defs[l.AtUri().String()] = &l } - return &LabelApplicationCtx{defs}, nil -} - -func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error { - def, ok := c.Defs[op.OperandKey] - if !ok { - // this def was deleted, but an op exists, so we just skip over the op - return nil - } - - switch op.Operation { - case LabelOperationAdd: - // if valueset is empty, init it - if state.inner[op.OperandKey] == nil { - state.inner[op.OperandKey] = make(set) - } - - // if valueset is populated & this val alr exists, this labelop is a noop - if valueSet, exists := state.inner[op.OperandKey]; exists { - if _, exists = valueSet[op.OperandValue]; exists { - return LabelNoOpError - } - } - - if def.Multiple { - // append to set - state.inner[op.OperandKey][op.OperandValue] = struct{}{} - } else { - // reset to just this value - state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}} - } - - case LabelOperationDel: - // if label DNE, then deletion is a no-op - if valueSet, exists := state.inner[op.OperandKey]; !exists { - return LabelNoOpError - } else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op - return LabelNoOpError - } - - if def.Multiple { - // remove from set - delete(state.inner[op.OperandKey], op.OperandValue) - } else { - // reset the entire label - delete(state.inner, op.OperandKey) - } - - // if the map becomes empty, then set it to nil, this is just the inverse of add - if len(state.inner[op.OperandKey]) == 0 { - state.inner[op.OperandKey] = nil - } - - } - - return nil -} - -func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) { - // sort label ops in sort order first - slices.SortFunc(ops, func(a, b LabelOp) int { - return a.SortAt().Compare(b.SortAt()) - }) - - // apply ops in sequence - for _, o := range ops { - _ = c.ApplyLabelOp(state, o) - } -} - -// IsInverse checks if one label operation is the inverse of another -// returns true if one is an add and the other is a delete with the same key and value -func (op1 LabelOp) IsInverse(op2 LabelOp) bool { - if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue { - return false - } - - return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) || - (op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd) -} - -// removes pairs of label operations that are inverses of each other -// from the given slice. the function preserves the order of remaining operations. -func ReduceLabelOps(ops []LabelOp) []LabelOp { - if len(ops) <= 1 { - return ops - } - - keep := make([]bool, len(ops)) - for i := range keep { - keep[i] = true - } - - for i := range ops { - if !keep[i] { - continue - } - - for j := i + 1; j < len(ops); j++ { - if !keep[j] { - continue - } - - if ops[i].IsInverse(ops[j]) { - keep[i] = false - keep[j] = false - break // move to next i since this one is now eliminated - } - } - } - - // build result slice with only kept operations - var result []LabelOp - for i, op := range ops { - if keep[i] { - result = append(result, op) - } - } - - return result -} - -func DefaultLabelDefs() []string { - rkeys := []string{ - "wontfix", - "duplicate", - "assignee", - "good-first-issue", - "documentation", - } - - defs := make([]string, len(rkeys)) - for i, r := range rkeys { - defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r) - } - - return defs + return &models.LabelApplicationCtx{defs}, nil } diff --git a/appview/ingester.go b/appview/ingester.go index b26e1218..b1fb40e2 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -923,7 +923,7 @@ func (i *Ingester) ingestLabelDefinition(e *jmodels.Event) error { return fmt.Errorf("invalid record: %w", err) } - def, err := db.LabelDefinitionFromRecord(did, rkey, record) + def, err := models.LabelDefinitionFromRecord(did, rkey, record) if err != nil { return fmt.Errorf("failed to parse labeldef from record: %w", err) } diff --git a/appview/issues/issues.go b/appview/issues/issues.go index bbb38d35..267e8e47 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -19,6 +19,7 @@ import ( "tangled.org/core/api/tangled" "tangled.org/core/appview/config" "tangled.org/core/appview/db" + "tangled.org/core/appview/models" "tangled.org/core/appview/notify" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" @@ -103,7 +104,7 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { return } - defs := make(map[string]*db.LabelDefinition) + defs := make(map[string]*models.LabelDefinition) for _, l := range labelDefs { defs[l.AtUri().String()] = &l } @@ -796,7 +797,7 @@ func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { return } - defs := make(map[string]*db.LabelDefinition) + defs := make(map[string]*models.LabelDefinition) for _, l := range labelDefs { defs[l.AtUri().String()] = &l } diff --git a/appview/labels/labels.go b/appview/labels/labels.go index 31beac41..5a911f3f 100644 --- a/appview/labels/labels.go +++ b/appview/labels/labels.go @@ -17,6 +17,7 @@ import ( "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/appview/db" "tangled.sh/tangled.sh/core/appview/middleware" + "tangled.sh/tangled.sh/core/appview/models" "tangled.sh/tangled.sh/core/appview/oauth" "tangled.sh/tangled.sh/core/appview/pages" "tangled.sh/tangled.sh/core/appview/validator" @@ -113,19 +114,19 @@ func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { return } - labelState := db.NewLabelState() + labelState := models.NewLabelState() actx.ApplyLabelOps(labelState, existingOps) - var labelOps []db.LabelOp + var labelOps []models.LabelOp // first delete all existing state for key, vals := range labelState.Inner() { for val := range vals { - labelOps = append(labelOps, db.LabelOp{ + labelOps = append(labelOps, models.LabelOp{ Did: did, Rkey: rkey, Subject: syntax.ATURI(subjectUri), - Operation: db.LabelOperationDel, + Operation: models.LabelOperationDel, OperandKey: key, OperandValue: val, PerformedAt: performedAt, @@ -141,11 +142,11 @@ func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { } for _, val := range vals { - labelOps = append(labelOps, db.LabelOp{ + labelOps = append(labelOps, models.LabelOp{ Did: did, Rkey: rkey, Subject: syntax.ATURI(subjectUri), - Operation: db.LabelOperationAdd, + Operation: models.LabelOperationAdd, OperandKey: key, OperandValue: val, PerformedAt: performedAt, @@ -155,7 +156,7 @@ func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { } // reduce the opset - labelOps = db.ReduceLabelOps(labelOps) + labelOps = models.ReduceLabelOps(labelOps) for i := range labelOps { def := actx.Defs[labelOps[i].OperandKey] @@ -168,7 +169,7 @@ func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { // next, apply all ops introduced in this request and filter out ones that are no-ops validLabelOps := labelOps[:0] for _, op := range labelOps { - if err = actx.ApplyLabelOp(labelState, op); err != db.LabelNoOpError { + if err = actx.ApplyLabelOp(labelState, op); err != models.LabelNoOpError { validLabelOps = append(validLabelOps, op) } } @@ -180,7 +181,7 @@ func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { } // create an atproto record of valid ops - record := db.LabelOpsAsRecord(validLabelOps) + record := models.LabelOpsAsRecord(validLabelOps) client, err := l.oauth.AuthorizedClient(r) if err != nil { diff --git a/appview/models/label.go b/appview/models/label.go new file mode 100644 index 00000000..60c99aaa --- /dev/null +++ b/appview/models/label.go @@ -0,0 +1,473 @@ +package models + +import ( + "crypto/sha1" + "encoding/hex" + "errors" + "fmt" + "slices" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/tangled" + "tangled.org/core/consts" +) + +type ConcreteType string + +const ( + ConcreteTypeNull ConcreteType = "null" + ConcreteTypeString ConcreteType = "string" + ConcreteTypeInt ConcreteType = "integer" + ConcreteTypeBool ConcreteType = "boolean" +) + +type ValueTypeFormat string + +const ( + ValueTypeFormatAny ValueTypeFormat = "any" + ValueTypeFormatDid ValueTypeFormat = "did" +) + +// ValueType represents an atproto lexicon type definition with constraints +type ValueType struct { + Type ConcreteType `json:"type"` + Format ValueTypeFormat `json:"format,omitempty"` + Enum []string `json:"enum,omitempty"` +} + +func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType { + return tangled.LabelDefinition_ValueType{ + Type: string(vt.Type), + Format: string(vt.Format), + Enum: vt.Enum, + } +} + +func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType { + return ValueType{ + Type: ConcreteType(record.Type), + Format: ValueTypeFormat(record.Format), + Enum: record.Enum, + } +} + +func (vt ValueType) IsConcreteType() bool { + return vt.Type == ConcreteTypeNull || + vt.Type == ConcreteTypeString || + vt.Type == ConcreteTypeInt || + vt.Type == ConcreteTypeBool +} + +func (vt ValueType) IsNull() bool { + return vt.Type == ConcreteTypeNull +} + +func (vt ValueType) IsString() bool { + return vt.Type == ConcreteTypeString +} + +func (vt ValueType) IsInt() bool { + return vt.Type == ConcreteTypeInt +} + +func (vt ValueType) IsBool() bool { + return vt.Type == ConcreteTypeBool +} + +func (vt ValueType) IsEnum() bool { + return len(vt.Enum) > 0 +} + +func (vt ValueType) IsDidFormat() bool { + return vt.Format == ValueTypeFormatDid +} + +func (vt ValueType) IsAnyFormat() bool { + return vt.Format == ValueTypeFormatAny +} + +type LabelDefinition struct { + Id int64 + Did string + Rkey string + + Name string + ValueType ValueType + Scope []string + Color *string + Multiple bool + Created time.Time +} + +func (l *LabelDefinition) AtUri() syntax.ATURI { + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey)) +} + +func (l *LabelDefinition) AsRecord() tangled.LabelDefinition { + vt := l.ValueType.AsRecord() + return tangled.LabelDefinition{ + Name: l.Name, + Color: l.Color, + CreatedAt: l.Created.Format(time.RFC3339), + Multiple: &l.Multiple, + Scope: l.Scope, + ValueType: &vt, + } +} + +// random color for a given seed +func randomColor(seed string) string { + hash := sha1.Sum([]byte(seed)) + hexStr := hex.EncodeToString(hash[:]) + r := hexStr[0:2] + g := hexStr[2:4] + b := hexStr[4:6] + + return fmt.Sprintf("#%s%s%s", r, g, b) +} + +func (ld LabelDefinition) GetColor() string { + if ld.Color == nil { + seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey) + color := randomColor(seed) + return color + } + + return *ld.Color +} + +func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) { + created, err := time.Parse(time.RFC3339, record.CreatedAt) + if err != nil { + created = time.Now() + } + + multiple := false + if record.Multiple != nil { + multiple = *record.Multiple + } + + var vt ValueType + if record.ValueType != nil { + vt = ValueTypeFromRecord(*record.ValueType) + } + + return &LabelDefinition{ + Did: did, + Rkey: rkey, + + Name: record.Name, + ValueType: vt, + Scope: record.Scope, + Color: record.Color, + Multiple: multiple, + Created: created, + }, nil +} + +type LabelOp struct { + Id int64 + Did string + Rkey string + Subject syntax.ATURI + Operation LabelOperation + OperandKey string + OperandValue string + PerformedAt time.Time + IndexedAt time.Time +} + +func (l LabelOp) SortAt() time.Time { + createdAt := l.PerformedAt + indexedAt := l.IndexedAt + + // if we don't have an indexedat, fall back to now + if indexedAt.IsZero() { + indexedAt = time.Now() + } + + // if createdat is invalid (before epoch), treat as null -> return zero time + if createdAt.Before(time.UnixMicro(0)) { + return time.Time{} + } + + // if createdat is <= indexedat, use createdat + if createdAt.Before(indexedAt) || createdAt.Equal(indexedAt) { + return createdAt + } + + // otherwise, createdat is in the future relative to indexedat -> use indexedat + return indexedAt +} + +type LabelOperation string + +const ( + LabelOperationAdd LabelOperation = "add" + LabelOperationDel LabelOperation = "del" +) + +// a record can create multiple label ops +func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp { + performed, err := time.Parse(time.RFC3339, record.PerformedAt) + if err != nil { + performed = time.Now() + } + + mkOp := func(operand *tangled.LabelOp_Operand) LabelOp { + return LabelOp{ + Did: did, + Rkey: rkey, + Subject: syntax.ATURI(record.Subject), + OperandKey: operand.Key, + OperandValue: operand.Value, + PerformedAt: performed, + } + } + + var ops []LabelOp + for _, o := range record.Add { + if o != nil { + op := mkOp(o) + op.Operation = LabelOperationAdd + ops = append(ops, op) + } + } + for _, o := range record.Delete { + if o != nil { + op := mkOp(o) + op.Operation = LabelOperationDel + ops = append(ops, op) + } + } + + return ops +} + +func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp { + if len(ops) == 0 { + return tangled.LabelOp{} + } + + // use the first operation to establish common fields + first := ops[0] + record := tangled.LabelOp{ + Subject: string(first.Subject), + PerformedAt: first.PerformedAt.Format(time.RFC3339), + } + + var addOperands []*tangled.LabelOp_Operand + var deleteOperands []*tangled.LabelOp_Operand + + for _, op := range ops { + operand := &tangled.LabelOp_Operand{ + Key: op.OperandKey, + Value: op.OperandValue, + } + + switch op.Operation { + case LabelOperationAdd: + addOperands = append(addOperands, operand) + case LabelOperationDel: + deleteOperands = append(deleteOperands, operand) + default: + return tangled.LabelOp{} + } + } + + record.Add = addOperands + record.Delete = deleteOperands + + return record +} + +type set = map[string]struct{} + +type LabelState struct { + inner map[string]set +} + +func NewLabelState() LabelState { + return LabelState{ + inner: make(map[string]set), + } +} + +func (s LabelState) Inner() map[string]set { + return s.inner +} + +func (s LabelState) ContainsLabel(l string) bool { + if valset, exists := s.inner[l]; exists { + if valset != nil { + return true + } + } + + return false +} + +// go maps behavior in templates make this necessary, +// indexing a map and getting `set` in return is apparently truthy +func (s LabelState) ContainsLabelAndVal(l, v string) bool { + if valset, exists := s.inner[l]; exists { + if _, exists := valset[v]; exists { + return true + } + } + + return false +} + +func (s LabelState) GetValSet(l string) set { + if valset, exists := s.inner[l]; exists { + return valset + } else { + return make(set) + } +} + +type LabelApplicationCtx struct { + Defs map[string]*LabelDefinition // labelAt -> labelDef +} + +var ( + LabelNoOpError = errors.New("no-op") +) + +func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error { + def, ok := c.Defs[op.OperandKey] + if !ok { + // this def was deleted, but an op exists, so we just skip over the op + return nil + } + + switch op.Operation { + case LabelOperationAdd: + // if valueset is empty, init it + if state.inner[op.OperandKey] == nil { + state.inner[op.OperandKey] = make(set) + } + + // if valueset is populated & this val alr exists, this labelop is a noop + if valueSet, exists := state.inner[op.OperandKey]; exists { + if _, exists = valueSet[op.OperandValue]; exists { + return LabelNoOpError + } + } + + if def.Multiple { + // append to set + state.inner[op.OperandKey][op.OperandValue] = struct{}{} + } else { + // reset to just this value + state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}} + } + + case LabelOperationDel: + // if label DNE, then deletion is a no-op + if valueSet, exists := state.inner[op.OperandKey]; !exists { + return LabelNoOpError + } else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op + return LabelNoOpError + } + + if def.Multiple { + // remove from set + delete(state.inner[op.OperandKey], op.OperandValue) + } else { + // reset the entire label + delete(state.inner, op.OperandKey) + } + + // if the map becomes empty, then set it to nil, this is just the inverse of add + if len(state.inner[op.OperandKey]) == 0 { + state.inner[op.OperandKey] = nil + } + + } + + return nil +} + +func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) { + // sort label ops in sort order first + slices.SortFunc(ops, func(a, b LabelOp) int { + return a.SortAt().Compare(b.SortAt()) + }) + + // apply ops in sequence + for _, o := range ops { + _ = c.ApplyLabelOp(state, o) + } +} + +// IsInverse checks if one label operation is the inverse of another +// returns true if one is an add and the other is a delete with the same key and value +func (op1 LabelOp) IsInverse(op2 LabelOp) bool { + if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue { + return false + } + + return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) || + (op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd) +} + +// removes pairs of label operations that are inverses of each other +// from the given slice. the function preserves the order of remaining operations. +func ReduceLabelOps(ops []LabelOp) []LabelOp { + if len(ops) <= 1 { + return ops + } + + keep := make([]bool, len(ops)) + for i := range keep { + keep[i] = true + } + + for i := range ops { + if !keep[i] { + continue + } + + for j := i + 1; j < len(ops); j++ { + if !keep[j] { + continue + } + + if ops[i].IsInverse(ops[j]) { + keep[i] = false + keep[j] = false + break // move to next i since this one is now eliminated + } + } + } + + // build result slice with only kept operations + var result []LabelOp + for i, op := range ops { + if keep[i] { + result = append(result, op) + } + } + + return result +} + +func DefaultLabelDefs() []string { + rkeys := []string{ + "wontfix", + "duplicate", + "assignee", + "good-first-issue", + "documentation", + } + + defs := make([]string, len(rkeys)) + for i, r := range rkeys { + defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r) + } + + return defs +} diff --git a/appview/pages/pages.go b/appview/pages/pages.go index f0c54c7d..78113dd6 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -841,8 +841,8 @@ func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { type RepoGeneralSettingsParams struct { LoggedInUser *oauth.User RepoInfo repoinfo.RepoInfo - Labels []db.LabelDefinition - DefaultLabels []db.LabelDefinition + Labels []models.LabelDefinition + DefaultLabels []models.LabelDefinition SubscribedLabels map[string]struct{} Active string Tabs []map[string]any @@ -890,7 +890,7 @@ type RepoIssuesParams struct { RepoInfo repoinfo.RepoInfo Active string Issues []db.Issue - LabelDefs map[string]*db.LabelDefinition + LabelDefs map[string]*models.LabelDefinition Page pagination.Page FilteringByOpen bool } @@ -906,7 +906,7 @@ type RepoSingleIssueParams struct { Active string Issue *db.Issue CommentList []db.CommentListItem - LabelDefs map[string]*db.LabelDefinition + LabelDefs map[string]*models.LabelDefinition OrderedReactionKinds []db.ReactionKind Reactions map[db.ReactionKind]int @@ -1236,9 +1236,9 @@ func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error type LabelPanelParams struct { LoggedInUser *oauth.User RepoInfo repoinfo.RepoInfo - Defs map[string]*db.LabelDefinition + Defs map[string]*models.LabelDefinition Subject string - State db.LabelState + State models.LabelState } func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error { @@ -1248,9 +1248,9 @@ func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error { type EditLabelPanelParams struct { LoggedInUser *oauth.User RepoInfo repoinfo.RepoInfo - Defs map[string]*db.LabelDefinition + Defs map[string]*models.LabelDefinition Subject string - State db.LabelState + State models.LabelState } func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error { diff --git a/appview/repo/repo.go b/appview/repo/repo.go index 85bc6c0e..3ea5dfe2 100644 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -1005,18 +1005,18 @@ func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) { concreteType = "null" } - format := db.ValueTypeFormatAny + format := models.ValueTypeFormatAny if valueFormat == "did" { - format = db.ValueTypeFormatDid + format = models.ValueTypeFormatDid } - valueType := db.ValueType{ - Type: db.ConcreteType(concreteType), + valueType := models.ValueType{ + Type: models.ConcreteType(concreteType), Format: format, Enum: variants, } - label := db.LabelDefinition{ + label := models.LabelDefinition{ Did: user.Did, Rkey: tid.TID(), Name: name, @@ -1396,7 +1396,7 @@ func (rp *Repo) LabelPanel(w http.ResponseWriter, r *http.Request) { return } - defs := make(map[string]*db.LabelDefinition) + defs := make(map[string]*models.LabelDefinition) for _, l := range labelDefs { defs[l.AtUri().String()] = &l } @@ -1444,7 +1444,7 @@ func (rp *Repo) EditLabelPanel(w http.ResponseWriter, r *http.Request) { return } - defs := make(map[string]*db.LabelDefinition) + defs := make(map[string]*models.LabelDefinition) for _, l := range labelDefs { defs[l.AtUri().String()] = &l } @@ -1895,7 +1895,7 @@ func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { return } - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", db.DefaultLabelDefs())) + defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) if err != nil { log.Println("failed to fetch labels", err) rp.pages.Error503(w) diff --git a/appview/validator/label.go b/appview/validator/label.go index 86426538..8b449b6e 100644 --- a/appview/validator/label.go +++ b/appview/validator/label.go @@ -9,7 +9,7 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" "golang.org/x/exp/slices" "tangled.sh/tangled.sh/core/api/tangled" - "tangled.sh/tangled.sh/core/appview/db" + "tangled.sh/tangled.sh/core/appview/models" ) var ( @@ -21,7 +21,7 @@ var ( validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} ) -func (v *Validator) ValidateLabelDefinition(label *db.LabelDefinition) error { +func (v *Validator) ValidateLabelDefinition(label *models.LabelDefinition) error { if label.Name == "" { return fmt.Errorf("label name is empty") } @@ -95,7 +95,7 @@ func (v *Validator) ValidateLabelDefinition(label *db.LabelDefinition) error { return nil } -func (v *Validator) ValidateLabelOp(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error { +func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { if labelDef == nil { return fmt.Errorf("label definition is required") } @@ -108,7 +108,7 @@ func (v *Validator) ValidateLabelOp(labelDef *db.LabelDefinition, labelOp *db.La return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey) } - if labelOp.Operation != db.LabelOperationAdd && labelOp.Operation != db.LabelOperationDel { + if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel { return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation) } @@ -131,23 +131,23 @@ func (v *Validator) ValidateLabelOp(labelDef *db.LabelDefinition, labelOp *db.La return nil } -func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error { +func (v *Validator) validateOperandValue(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { valueType := labelDef.ValueType // this is permitted, it "unsets" a label if labelOp.OperandValue == "" { - labelOp.Operation = db.LabelOperationDel + labelOp.Operation = models.LabelOperationDel return nil } switch valueType.Type { - case db.ConcreteTypeNull: + case models.ConcreteTypeNull: // For null type, value should be empty if labelOp.OperandValue != "null" { return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue) } - case db.ConcreteTypeString: + case models.ConcreteTypeString: // For string type, validate enum constraints if present if valueType.IsEnum() { if !slices.Contains(valueType.Enum, labelOp.OperandValue) { @@ -156,7 +156,7 @@ func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp * } switch valueType.Format { - case db.ValueTypeFormatDid: + case models.ValueTypeFormatDid: id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue) if err != nil { return fmt.Errorf("failed to resolve did/handle: %w", err) @@ -164,12 +164,12 @@ func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp * labelOp.OperandValue = id.DID.String() - case db.ValueTypeFormatAny, "": + case models.ValueTypeFormatAny, "": default: return fmt.Errorf("unsupported format constraint: %q", valueType.Format) } - case db.ConcreteTypeInt: + case models.ConcreteTypeInt: if labelOp.OperandValue == "" { return fmt.Errorf("integer type requires non-empty value") } @@ -183,7 +183,7 @@ func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp * } } - case db.ConcreteTypeBool: + case models.ConcreteTypeBool: if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" { return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue) }