feat: add agent ID pattern validation for bd create --type=agent (gt-hlaaf)

- Update ParseIssueType to use canonical types.IsValid() (includes agent, role, gate, message)
- Add ValidateAgentID function to validate gt-<role>[-<rig>[-<name>]] pattern
- Wire up validation in create.go when --type=agent with explicit ID
- Update --type flag help text to include agent and role types
- Add comprehensive test coverage for agent ID validation
This commit is contained in:
Steve Yegge
2025-12-28 00:07:04 -08:00
parent b5c695075a
commit 46cdf075d4
3 changed files with 184 additions and 16 deletions

View File

@@ -215,6 +215,13 @@ var createCmd = &cobra.Command{
if err := validation.ValidatePrefix(requestedPrefix, dbPrefix, forceCreate); err != nil { if err := validation.ValidatePrefix(requestedPrefix, dbPrefix, forceCreate); err != nil {
FatalError("%v", err) FatalError("%v", err)
} }
// Validate agent ID pattern if type is agent (gt-hlaaf)
if issueType == "agent" {
if err := validation.ValidateAgentID(explicitID); err != nil {
FatalError("invalid agent ID: %v", err)
}
}
} }
var externalRefPtr *string var externalRefPtr *string
@@ -453,7 +460,7 @@ func init() {
createCmd.Flags().String("title", "", "Issue title (alternative to positional argument)") createCmd.Flags().String("title", "", "Issue title (alternative to positional argument)")
createCmd.Flags().Bool("silent", false, "Output only the issue ID (for scripting)") createCmd.Flags().Bool("silent", false, "Output only the issue ID (for scripting)")
registerPriorityFlag(createCmd, "2") registerPriorityFlag(createCmd, "2")
createCmd.Flags().StringP("type", "t", "task", "Issue type (bug|feature|task|epic|chore|merge-request|molecule|gate)") createCmd.Flags().StringP("type", "t", "task", "Issue type (bug|feature|task|epic|chore|merge-request|molecule|gate|agent|role)")
registerCommonIssueFlags(createCmd) registerCommonIssueFlags(createCmd)
createCmd.Flags().StringSliceP("labels", "l", []string{}, "Labels (comma-separated)") createCmd.Flags().StringSliceP("labels", "l", []string{}, "Labels (comma-separated)")
createCmd.Flags().StringSlice("label", []string{}, "Alias for --labels") createCmd.Flags().StringSlice("label", []string{}, "Alias for --labels")

View File

@@ -30,18 +30,8 @@ func ParsePriority(content string) int {
func ParseIssueType(content string) (types.IssueType, error) { func ParseIssueType(content string) (types.IssueType, error) {
issueType := types.IssueType(strings.TrimSpace(content)) issueType := types.IssueType(strings.TrimSpace(content))
// Validate issue type // Use the canonical IsValid() from types package
validTypes := map[types.IssueType]bool{ if !issueType.IsValid() {
types.TypeBug: true,
types.TypeFeature: true,
types.TypeTask: true,
types.TypeEpic: true,
types.TypeChore: true,
types.TypeMergeRequest: true,
types.TypeMolecule: true,
}
if !validTypes[issueType] {
return types.TypeTask, fmt.Errorf("invalid issue type: %s", content) return types.TypeTask, fmt.Errorf("invalid issue type: %s", content)
} }
@@ -88,3 +78,100 @@ func ValidatePrefix(requestedPrefix, dbPrefix string, force bool) error {
return fmt.Errorf("prefix mismatch: database uses '%s' but you specified '%s' (use --force to override)", dbPrefix, requestedPrefix) return fmt.Errorf("prefix mismatch: database uses '%s' but you specified '%s' (use --force to override)", dbPrefix, requestedPrefix)
} }
// ValidAgentRoles are the known agent role types for ID pattern validation
var ValidAgentRoles = []string{
"mayor", // Town-level: gt-mayor
"deacon", // Town-level: gt-deacon
"witness", // Per-rig: gt-witness-<rig>
"refinery", // Per-rig: gt-refinery-<rig>
"crew", // Per-rig with name: gt-crew-<rig>-<name>
"polecat", // Per-rig with name: gt-polecat-<rig>-<name>
}
// TownLevelRoles are agent roles that don't have a rig
var TownLevelRoles = []string{"mayor", "deacon"}
// NamedRoles are agent roles that include a worker name
var NamedRoles = []string{"crew", "polecat"}
// ValidateAgentID validates that an agent ID follows the expected pattern.
// Patterns:
// - Town-level: gt-<role> (e.g., gt-mayor, gt-deacon)
// - Per-rig: gt-<role>-<rig> (e.g., gt-witness-gastown)
// - Named: gt-<role>-<rig>-<name> (e.g., gt-polecat-gastown-nux)
//
// Returns nil if the ID is valid, or an error describing the issue.
func ValidateAgentID(id string) error {
if id == "" {
return fmt.Errorf("agent ID is required")
}
// Must start with gt-
if !strings.HasPrefix(id, "gt-") {
return fmt.Errorf("agent ID must start with 'gt-' (got %q)", id)
}
// Split into parts after the prefix
rest := id[3:] // Skip "gt-"
parts := strings.Split(rest, "-")
if len(parts) < 1 || parts[0] == "" {
return fmt.Errorf("agent ID must include role type: gt-<role>[-<rig>[-<name>]] (got %q)", id)
}
role := parts[0]
// Check if role is valid
validRole := false
for _, r := range ValidAgentRoles {
if role == r {
validRole = true
break
}
}
if !validRole {
return fmt.Errorf("invalid agent role %q (valid: %s)", role, strings.Join(ValidAgentRoles, ", "))
}
// Check town-level roles (no rig allowed)
for _, r := range TownLevelRoles {
if role == r {
if len(parts) > 1 {
return fmt.Errorf("town-level agent %q cannot have rig suffix (expected gt-%s, got %q)", role, role, id)
}
return nil // Valid town-level agent
}
}
// Per-rig agents require at least a rig
if len(parts) < 2 {
return fmt.Errorf("per-rig agent %q requires rig: gt-%s-<rig> (got %q)", role, role, id)
}
rig := parts[1]
if rig == "" {
return fmt.Errorf("rig name cannot be empty in %q", id)
}
// Check named roles (require name)
for _, r := range NamedRoles {
if role == r {
if len(parts) < 3 {
return fmt.Errorf("agent %q requires name: gt-%s-<rig>-<name> (got %q)", role, role, id)
}
name := parts[2]
if name == "" {
return fmt.Errorf("agent name cannot be empty in %q", id)
}
// Extra parts after name are allowed (e.g., for complex identifiers)
return nil // Valid named agent
}
}
// Regular per-rig agents (witness, refinery) - should have exactly 2 parts
if len(parts) > 2 {
return fmt.Errorf("agent %q takes only rig: gt-%s-<rig> (got %q)", role, role, id)
}
return nil // Valid per-rig agent
}

View File

@@ -125,15 +125,19 @@ func TestParseIssueType(t *testing.T) {
{"chore type", "chore", types.TypeChore, false, ""}, {"chore type", "chore", types.TypeChore, false, ""},
{"merge-request type", "merge-request", types.TypeMergeRequest, false, ""}, {"merge-request type", "merge-request", types.TypeMergeRequest, false, ""},
{"molecule type", "molecule", types.TypeMolecule, false, ""}, {"molecule type", "molecule", types.TypeMolecule, false, ""},
{"gate type", "gate", types.TypeGate, false, ""},
{"agent type", "agent", types.TypeAgent, false, ""},
{"role type", "role", types.TypeRole, false, ""},
{"message type", "message", types.TypeMessage, false, ""},
// Case sensitivity (function is case-sensitive) // Case sensitivity (function is case-sensitive)
{"uppercase bug", "BUG", types.TypeTask, true, "invalid issue type"}, {"uppercase bug", "BUG", types.TypeTask, true, "invalid issue type"},
{"mixed case feature", "FeAtUrE", types.TypeTask, true, "invalid issue type"}, {"mixed case feature", "FeAtUrE", types.TypeTask, true, "invalid issue type"},
// With whitespace // With whitespace
{"bug with spaces", " bug ", types.TypeBug, false, ""}, {"bug with spaces", " bug ", types.TypeBug, false, ""},
{"feature with tabs", "\tfeature\t", types.TypeFeature, false, ""}, {"feature with tabs", "\tfeature\t", types.TypeFeature, false, ""},
// Invalid issue types // Invalid issue types
{"invalid type", "invalid", types.TypeTask, true, "invalid issue type"}, {"invalid type", "invalid", types.TypeTask, true, "invalid issue type"},
{"empty string", "", types.TypeTask, true, "invalid issue type"}, {"empty string", "", types.TypeTask, true, "invalid issue type"},
@@ -190,3 +194,73 @@ func TestValidatePrefix(t *testing.T) {
}) })
} }
} }
func TestValidateAgentID(t *testing.T) {
tests := []struct {
name string
id string
wantError bool
errorContains string
}{
// Town-level agents (no rig)
{"valid mayor", "gt-mayor", false, ""},
{"valid deacon", "gt-deacon", false, ""},
// Per-rig agents (witness, refinery)
{"valid witness gastown", "gt-witness-gastown", false, ""},
{"valid refinery beads", "gt-refinery-beads", false, ""},
// Note: hyphenated rig names like "my-rig" are ambiguous and not supported
// for witness/refinery. Use simple rig names.
// Named agents (crew, polecat)
{"valid polecat", "gt-polecat-gastown-nux", false, ""},
{"valid crew", "gt-crew-beads-dave", false, ""},
{"valid polecat with complex name", "gt-polecat-gastown-war-boy-1", false, ""},
// Invalid: wrong prefix
{"wrong prefix bd", "bd-mayor", true, "must start with 'gt-'"},
{"wrong prefix empty", "mayor", true, "must start with 'gt-'"},
// Invalid: empty
{"empty id", "", true, "agent ID is required"},
// Invalid: unknown role
{"unknown role", "gt-admin-gastown", true, "invalid agent role"},
{"unknown role foo", "gt-foo", true, "invalid agent role"},
// Invalid: town-level with rig
{"mayor with rig", "gt-mayor-gastown", true, "cannot have rig suffix"},
{"deacon with rig", "gt-deacon-beads", true, "cannot have rig suffix"},
// Invalid: per-rig without rig
{"witness no rig", "gt-witness", true, "requires rig"},
{"refinery no rig", "gt-refinery", true, "requires rig"},
// Invalid: named agent without name
{"polecat no name", "gt-polecat-gastown", true, "requires name"},
{"crew no name", "gt-crew-beads", true, "requires name"},
// Invalid: witness/refinery with extra parts
{"witness with name", "gt-witness-gastown-extra", true, "takes only rig"},
{"refinery with name", "gt-refinery-beads-extra", true, "takes only rig"},
// Invalid: empty components
{"empty rig", "gt-witness-", true, "rig name cannot be empty"},
{"just gt", "gt-", true, "must include role type"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateAgentID(tt.id)
if (err != nil) != tt.wantError {
t.Errorf("ValidateAgentID(%q) error = %v, wantError %v", tt.id, err, tt.wantError)
return
}
if err != nil && tt.errorContains != "" {
if !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("ValidateAgentID(%q) error = %q, should contain %q", tt.id, err.Error(), tt.errorContains)
}
}
})
}
}