refactor: simplify service interface to use tags/rels for all node properties

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 23:10:56 +02:00
parent 4404518f50
commit cb16bda200
5 changed files with 151 additions and 147 deletions

View File

@@ -1,6 +1,7 @@
package cmd package cmd
import ( import (
"axolotl/models"
"axolotl/output" "axolotl/output"
"axolotl/service" "axolotl/service"
"fmt" "fmt"
@@ -9,7 +10,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var cDue, cContent, cStatus, cPrio, cType, cNamespace, cAssignee string var cDue, cContent, cType, cStatus, cPrio, cNamespace, cAssignee string
var cTags, cRels []string var cTags, cRels []string
var addCmd = &cobra.Command{ var addCmd = &cobra.Command{
@@ -22,15 +23,27 @@ var addCmd = &cobra.Command{
} }
input := service.AddInput{ input := service.AddInput{
Title: args[0], Title: args[0],
Content: cContent, Content: cContent,
DueDate: cDue, DueDate: cDue,
Type: cType, Tags: append([]string{}, cTags...),
Status: cStatus, }
Priority: cPrio,
Namespace: cNamespace, // Shorthand flags expand to tags or rels.
Assignee: cAssignee, if cType != "" {
Tags: cTags, input.Tags = append(input.Tags, "_type::"+cType)
}
if cStatus != "" {
input.Tags = append(input.Tags, "_status::"+cStatus)
}
if cPrio != "" {
input.Tags = append(input.Tags, "_prio::"+cPrio)
}
if cNamespace != "" {
input.Rels = append(input.Rels, service.RelInput{Type: models.RelInNamespace, Target: cNamespace})
}
if cAssignee != "" {
input.Rels = append(input.Rels, service.RelInput{Type: models.RelAssignee, Target: cAssignee})
} }
for _, r := range cRels { for _, r := range cRels {
@@ -55,7 +68,7 @@ var addCmd = &cobra.Command{
func init() { func init() {
rootCmd.AddCommand(addCmd) rootCmd.AddCommand(addCmd)
f := addCmd.Flags() f := addCmd.Flags()
f.StringVar(&cType, "type", "issue", "node type (issue, note, …)") f.StringVar(&cType, "type", "", "node type (issue, note, …)")
f.StringVar(&cStatus, "status", "", "initial status (open, done)") f.StringVar(&cStatus, "status", "", "initial status (open, done)")
f.StringVar(&cPrio, "prio", "", "priority (high, medium, low)") f.StringVar(&cPrio, "prio", "", "priority (high, medium, low)")
f.StringVar(&cNamespace, "namespace", "", "namespace name or ID") f.StringVar(&cNamespace, "namespace", "", "namespace name or ID")

View File

@@ -1,6 +1,7 @@
package cmd package cmd
import ( import (
"axolotl/models"
"axolotl/output" "axolotl/output"
"axolotl/service" "axolotl/service"
"fmt" "fmt"
@@ -22,13 +23,27 @@ var listCmd = &cobra.Command{
} }
filter := service.ListFilter{ filter := service.ListFilter{
Tags: lTags, Tags: append([]string{}, lTags...),
Status: lStatus, }
Priority: lPrio,
Type: lType, // Shorthand flags expand to tag prefixes or rel filters.
Namespace: lNamespace, if lStatus != "" {
Assignee: lAssignee, filter.Tags = append(filter.Tags, "_status::"+lStatus)
Mention: lMention, }
if lPrio != "" {
filter.Tags = append(filter.Tags, "_prio::"+lPrio)
}
if lType != "" {
filter.Tags = append(filter.Tags, "_type::"+lType)
}
if lNamespace != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelInNamespace, Target: lNamespace})
}
if lAssignee != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: lAssignee})
}
if lMention != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelMentions, Target: lMention})
} }
for _, r := range lRels { for _, r := range lRels {

View File

@@ -1,6 +1,7 @@
package cmd package cmd
import ( import (
"axolotl/models"
"axolotl/output" "axolotl/output"
"axolotl/service" "axolotl/service"
"fmt" "fmt"
@@ -10,10 +11,11 @@ import (
) )
var ( var (
uTitle, uContent, uDue string uTitle, uContent, uDue string
uClearDue bool uClearDue bool
uStatus, uPrio, uType, uNamespace, uAssignee string uStatus, uPrio, uType string
uAddTags, uRmTags, uAddRels, uRmRels []string uNamespace, uAssignee string
uAddTags, uRmTags, uAddRels, uRmRels []string
) )
var updateCmd = &cobra.Command{ var updateCmd = &cobra.Command{
@@ -26,7 +28,7 @@ var updateCmd = &cobra.Command{
} }
input := service.UpdateInput{ input := service.UpdateInput{
AddTags: uAddTags, AddTags: append([]string{}, uAddTags...),
RemoveTags: uRmTags, RemoveTags: uRmTags,
} }
@@ -43,20 +45,22 @@ var updateCmd = &cobra.Command{
empty := "" empty := ""
input.DueDate = &empty input.DueDate = &empty
} }
// Shorthand flags expand to tags or rels.
if cmd.Flags().Changed("type") {
input.AddTags = append(input.AddTags, "_type::"+uType)
}
if cmd.Flags().Changed("status") { if cmd.Flags().Changed("status") {
input.Status = &uStatus input.AddTags = append(input.AddTags, "_status::"+uStatus)
} }
if cmd.Flags().Changed("prio") { if cmd.Flags().Changed("prio") {
input.Priority = &uPrio input.AddTags = append(input.AddTags, "_prio::"+uPrio)
}
if cmd.Flags().Changed("type") {
input.Type = &uType
} }
if cmd.Flags().Changed("namespace") { if cmd.Flags().Changed("namespace") {
input.Namespace = &uNamespace input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelInNamespace, Target: uNamespace})
} }
if cmd.Flags().Changed("assignee") { if cmd.Flags().Changed("assignee") {
input.Assignee = &uAssignee input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelAssignee, Target: uAssignee})
} }
for _, r := range uAddRels { for _, r := range uAddRels {

View File

@@ -24,56 +24,37 @@ type NodeService interface {
} }
// AddInput describes a new node to create. // AddInput describes a new node to create.
// Tags may include special property tags (_type::, _status::, _prio::); the
// service applies defaults (type=issue, status=open for issues) and validates.
// Rels may include assignee, in_namespace, blocks, subtask, related, etc.;
// user and namespace targets are auto-created as needed.
type AddInput struct { type AddInput struct {
Title string Title string
Content string Content string
DueDate string DueDate string
Type string // default: "issue" Tags []string
Status string // default: "open" when Type is "issue" Rels []RelInput
Priority string
// Namespace is a namespace name or node ID. Defaults to the current user.
Namespace string
// Assignee is a username or node ID.
Assignee string
// Tags are arbitrary user-defined labels (not system properties).
Tags []string
// Rels are additional typed edges (e.g. blocks, subtask, related).
Rels []RelInput
} }
// UpdateInput describes changes to apply to an existing node. // UpdateInput describes changes to apply to an existing node.
// Nil pointer fields mean "no change". // Nil pointer fields mean "no change".
// Setting _status::done in AddTags is rejected when the node has open blockers.
// Adding assignee or in_namespace rels replaces the previous single target.
type UpdateInput struct { type UpdateInput struct {
Title *string Title *string
Content *string Content *string
DueDate *string // nil = no change; pointer to "" = clear due date DueDate *string // nil = no change; pointer to "" = clear due date
// Status "done" is rejected when the node has open blockers.
Status *string
Priority *string
Type *string
// Namespace replaces the current namespace.
Namespace *string
// Assignee replaces the current assignee.
Assignee *string
AddTags []string AddTags []string
RemoveTags []string RemoveTags []string
AddRels []RelInput AddRels []RelInput
RemoveRels []RelInput RemoveRels []RelInput
} }
// ListFilter specifies which nodes to return. Empty fields are ignored. // ListFilter specifies which nodes to return. Empty slices are ignored.
// Tags are matched as exact tag values or prefixes (e.g. "_status::open").
// Rels are resolved to node IDs; a missing target returns no results.
type ListFilter struct { type ListFilter struct {
Tags []string Tags []string
Status string
Priority string
Type string
// Namespace filters by namespace name or node ID.
Namespace string
// Assignee filters by username or node ID.
Assignee string
// Mention filters to nodes that mention the given username or node ID.
Mention string
// Rels are additional relation filters (e.g. blocks:someID).
Rels []RelInput Rels []RelInput
} }

View File

@@ -7,6 +7,7 @@ import (
"maps" "maps"
"regexp" "regexp"
"slices" "slices"
"strings"
"time" "time"
) )
@@ -25,6 +26,43 @@ func mentions(t string) []string {
return slices.Collect(maps.Keys(seen)) return slices.Collect(maps.Keys(seen))
} }
// --- Validation ---
var (
validTypes = map[string]bool{"issue": true, "note": true, "user": true, "namespace": true}
validStatuses = map[string]bool{"open": true, "done": true}
validPrios = map[string]bool{"high": true, "medium": true, "low": true}
)
func validateTags(tags []string) error {
for _, t := range tags {
if v, ok := strings.CutPrefix(t, "_type::"); ok {
if !validTypes[v] {
return fmt.Errorf("invalid type %q: must be one of issue, note, user, namespace", v)
}
} else if v, ok := strings.CutPrefix(t, "_status::"); ok {
if !validStatuses[v] {
return fmt.Errorf("invalid status %q: must be one of open, done", v)
}
} else if v, ok := strings.CutPrefix(t, "_prio::"); ok {
if !validPrios[v] {
return fmt.Errorf("invalid priority %q: must be one of high, medium, low", v)
}
}
}
return nil
}
// tagValue returns the value of the first tag with the given prefix, or "".
func tagValue(tags []string, prefix string) string {
for _, t := range tags {
if v, ok := strings.CutPrefix(t, prefix); ok {
return v
}
}
return ""
}
// --- Query --- // --- Query ---
func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) { func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) {
@@ -32,86 +70,51 @@ func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) {
} }
func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) { func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) {
// Build tag prefixes from both semantic fields and raw tags.
tagPrefixes := append([]string{}, filter.Tags...)
if filter.Status != "" {
tagPrefixes = append(tagPrefixes, "_status::"+filter.Status)
}
if filter.Priority != "" {
tagPrefixes = append(tagPrefixes, "_prio::"+filter.Priority)
}
if filter.Type != "" {
tagPrefixes = append(tagPrefixes, "_type::"+filter.Type)
}
// Build rel filters, resolving names to node IDs (read-only, no auto-creation).
type relEntry struct {
relType models.RelType
name string
}
namedRels := []relEntry{
{models.RelAssignee, filter.Assignee},
{models.RelInNamespace, filter.Namespace},
{models.RelMentions, filter.Mention},
}
var relFilters []*models.Rel var relFilters []*models.Rel
for _, e := range namedRels {
if e.name == "" {
continue
}
id, ok := s.lookupRelTarget(e.relType, e.name)
if !ok {
return nil, nil // named target doesn't exist; no nodes can match
}
relFilters = append(relFilters, &models.Rel{Type: e.relType, Target: id})
}
for _, ri := range filter.Rels { for _, ri := range filter.Rels {
id, ok := s.lookupRelTarget(ri.Type, ri.Target) id, ok := s.lookupRelTarget(ri.Type, ri.Target)
if !ok { if !ok {
return nil, nil return nil, nil // named target doesn't exist; no nodes can match
} }
relFilters = append(relFilters, &models.Rel{Type: ri.Type, Target: id}) relFilters = append(relFilters, &models.Rel{Type: ri.Type, Target: id})
} }
return s.store.FindNodes(filter.Tags, relFilters)
return s.store.FindNodes(tagPrefixes, relFilters)
} }
// --- Lifecycle --- // --- Lifecycle ---
func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) { func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
// Copy tags so we can extend without mutating the input.
tags := make([]string, len(input.Tags))
copy(tags, input.Tags)
// Apply defaults. // Apply defaults.
nodeType := input.Type nodeType := tagValue(tags, "_type::")
if nodeType == "" { if nodeType == "" {
nodeType = "issue" nodeType = "issue"
tags = append(tags, "_type::issue")
} }
status := input.Status if nodeType == "issue" && tagValue(tags, "_status::") == "" {
if status == "" && nodeType == "issue" { tags = append(tags, "_status::open")
status = "open"
} }
// Build initial tag set from semantic fields. // Validate special tags.
tags := []string{"_type::" + nodeType} if err := validateTags(tags); err != nil {
if status != "" { return nil, err
tags = append(tags, "_status::"+status)
} }
if input.Priority != "" {
tags = append(tags, "_prio::"+input.Priority)
}
tags = append(tags, input.Tags...)
// Build initial relation map from semantic fields. // Build initial relation map from rels input.
rels := make(map[models.RelType][]string) rels := make(map[models.RelType][]string)
if input.Namespace != "" { hasNamespace := false
rels[models.RelInNamespace] = []string{input.Namespace}
} else {
rels[models.RelInNamespace] = []string{s.userID} // default: creator's namespace
}
if input.Assignee != "" {
rels[models.RelAssignee] = []string{input.Assignee}
}
for _, ri := range input.Rels { for _, ri := range input.Rels {
if ri.Type == models.RelInNamespace {
hasNamespace = true
}
rels[ri.Type] = append(rels[ri.Type], ri.Target) rels[ri.Type] = append(rels[ri.Type], ri.Target)
} }
if !hasNamespace {
rels[models.RelInNamespace] = []string{s.userID}
}
id, err := s.store.GenerateID() id, err := s.store.GenerateID()
if err != nil { if err != nil {
@@ -176,12 +179,20 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, error) { func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, error) {
// Enforce blocking constraint before allowing status=done. // Enforce blocking constraint before allowing status=done.
if input.Status != nil && *input.Status == "done" { for _, t := range input.AddTags {
if err := s.checkBlockers(id); err != nil { if t == "_status::done" {
return nil, err if err := s.checkBlockers(id); err != nil {
return nil, err
}
break
} }
} }
// Validate tags being added.
if err := validateTags(input.AddTags); err != nil {
return nil, err
}
err := s.store.Transaction(func(st store.Store) error { err := s.store.Transaction(func(st store.Store) error {
current, err := st.GetNode(id) current, err := st.GetNode(id)
if err != nil { if err != nil {
@@ -211,15 +222,6 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
for _, t := range current.Tags() { for _, t := range current.Tags() {
tmp.AddTag(t) tmp.AddTag(t)
} }
if input.Type != nil {
tmp.AddTag("_type::" + *input.Type)
}
if input.Status != nil {
tmp.AddTag("_status::" + *input.Status)
}
if input.Priority != nil {
tmp.AddTag("_prio::" + *input.Priority)
}
for _, t := range input.AddTags { for _, t := range input.AddTags {
tmp.AddTag(t) tmp.AddTag(t)
} }
@@ -251,18 +253,8 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
} }
} }
// Build relation additions, including structured fields.
var addRels []RelInput
if input.Namespace != nil {
addRels = append(addRels, RelInput{Type: models.RelInNamespace, Target: *input.Namespace})
}
if input.Assignee != nil {
addRels = append(addRels, RelInput{Type: models.RelAssignee, Target: *input.Assignee})
}
addRels = append(addRels, input.AddRels...)
currentRels := current.Relations() currentRels := current.Relations()
for _, ri := range addRels { for _, ri := range input.AddRels {
resolved, err := s.resolveRelTarget(st, ri) resolved, err := s.resolveRelTarget(st, ri)
if err != nil { if err != nil {
return err return err
@@ -485,4 +477,3 @@ func (s *nodeServiceImpl) ensureNamespace(st store.Store, name string) (string,
} }
return id, nil return id, nil
} }