feat(multirepo): trust non-built-in types during hydration (bd-9ji4z)
Implements federation trust model for multi-repo type validation: - Built-in types are validated (catch typos) - Non-built-in types trusted from source repos Changes: - Add IssueType.IsBuiltIn() method - Add Issue.ValidateForImport() for trust-based validation - Update upsertIssueInTx to use ValidateForImport Closes: bd-dqwuf, bd-alpw2 | Epic: bd-9ji4z Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
fdabb9d256
commit
576a517c59
@@ -307,6 +307,20 @@ func (i *Issue) ValidateWithCustomStatuses(customStatuses []string) error {
|
||||
// ValidateWithCustom checks if the issue has valid field values,
|
||||
// allowing custom statuses and types in addition to built-in ones.
|
||||
func (i *Issue) ValidateWithCustom(customStatuses, customTypes []string) error {
|
||||
return i.validateInternal(customStatuses, customTypes, false)
|
||||
}
|
||||
|
||||
// ValidateForImport validates the issue for multi-repo import (federation trust model).
|
||||
// Built-in types are validated (to catch typos). Non-built-in types are trusted
|
||||
// since the source repo already validated them when the issue was created.
|
||||
// This implements "trust the chain below you" from the HOP federation model.
|
||||
func (i *Issue) ValidateForImport(customStatuses []string) error {
|
||||
return i.validateInternal(customStatuses, nil, true)
|
||||
}
|
||||
|
||||
// validateInternal is the shared validation logic.
|
||||
// If trustCustomTypes is true, non-built-in issue types are trusted (not validated).
|
||||
func (i *Issue) validateInternal(customStatuses, customTypes []string, trustCustomTypes bool) error {
|
||||
if len(i.Title) == 0 {
|
||||
return fmt.Errorf("title is required")
|
||||
}
|
||||
@@ -319,9 +333,25 @@ func (i *Issue) ValidateWithCustom(customStatuses, customTypes []string) error {
|
||||
if !i.Status.IsValidWithCustom(customStatuses) {
|
||||
return fmt.Errorf("invalid status: %s", i.Status)
|
||||
}
|
||||
if !i.IssueType.IsValidWithCustom(customTypes) {
|
||||
return fmt.Errorf("invalid issue type: %s", i.IssueType)
|
||||
|
||||
// Issue type validation: federation trust model (bd-9ji4z)
|
||||
if trustCustomTypes {
|
||||
// Multi-repo import: trust non-built-in types from source repo
|
||||
// Only validate built-in types (catch typos like "tsak" vs "task")
|
||||
if i.IssueType != "" && !i.IssueType.IsBuiltIn() {
|
||||
// Non-built-in type - trust it (child repo already validated)
|
||||
} else if i.IssueType != "" && !i.IssueType.IsValid() {
|
||||
// This shouldn't happen: IsBuiltIn() == IsValid() for non-empty types
|
||||
// But guard against edge cases
|
||||
return fmt.Errorf("invalid issue type: %s", i.IssueType)
|
||||
}
|
||||
} else {
|
||||
// Normal validation: check against built-in + custom types
|
||||
if !i.IssueType.IsValidWithCustom(customTypes) {
|
||||
return fmt.Errorf("invalid issue type: %s", i.IssueType)
|
||||
}
|
||||
}
|
||||
|
||||
if i.EstimatedMinutes != nil && *i.EstimatedMinutes < 0 {
|
||||
return fmt.Errorf("estimated_minutes cannot be negative")
|
||||
}
|
||||
@@ -437,6 +467,14 @@ func (t IssueType) IsValid() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsBuiltIn returns true if this is a built-in type (not a custom type).
|
||||
// Used during multi-repo hydration to determine whether to validate or trust:
|
||||
// - Built-in types: validate (catch typos like "tsak" vs "task")
|
||||
// - Custom types: trust (child repo already validated)
|
||||
func (t IssueType) IsBuiltIn() bool {
|
||||
return t.IsValid()
|
||||
}
|
||||
|
||||
// IsValidWithCustom checks if the issue type is valid, including custom types.
|
||||
// Custom types are user-defined via bd config set types.custom "type1,type2,..."
|
||||
func (t IssueType) IsValidWithCustom(customTypes []string) bool {
|
||||
|
||||
@@ -391,6 +391,149 @@ func TestValidateWithCustomStatuses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateForImport tests the federation trust model (bd-9ji4z):
|
||||
// - Built-in types are validated (catch typos)
|
||||
// - Non-built-in types are trusted (child repo already validated)
|
||||
func TestValidateForImport(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
issue Issue
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "built-in type task passes",
|
||||
issue: Issue{
|
||||
Title: "Test Issue",
|
||||
Status: StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: TypeTask,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "built-in type bug passes",
|
||||
issue: Issue{
|
||||
Title: "Test Issue",
|
||||
Status: StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: TypeBug,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "custom type pm is trusted (not in parent config)",
|
||||
issue: Issue{
|
||||
Title: "Test Issue",
|
||||
Status: StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: IssueType("pm"), // Custom type from child repo
|
||||
},
|
||||
wantErr: false, // Should pass - federation trust model
|
||||
},
|
||||
{
|
||||
name: "custom type llm is trusted",
|
||||
issue: Issue{
|
||||
Title: "Test Issue",
|
||||
Status: StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: IssueType("llm"), // Custom type from child repo
|
||||
},
|
||||
wantErr: false, // Should pass - federation trust model
|
||||
},
|
||||
{
|
||||
name: "custom type agent is trusted",
|
||||
issue: Issue{
|
||||
Title: "Test Issue",
|
||||
Status: StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: IssueType("agent"), // Gas Town custom type
|
||||
},
|
||||
wantErr: false, // Should pass - federation trust model
|
||||
},
|
||||
{
|
||||
name: "empty type defaults to task (handled by SetDefaults)",
|
||||
issue: Issue{
|
||||
Title: "Test Issue",
|
||||
Status: StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: IssueType(""), // Empty is allowed
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "other validations still run - missing title",
|
||||
issue: Issue{
|
||||
Title: "", // Missing required field
|
||||
Status: StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: IssueType("pm"),
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "title is required",
|
||||
},
|
||||
{
|
||||
name: "other validations still run - invalid priority",
|
||||
issue: Issue{
|
||||
Title: "Test Issue",
|
||||
Status: StatusOpen,
|
||||
Priority: 10, // Invalid
|
||||
IssueType: IssueType("pm"),
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "priority must be between 0 and 4",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.issue.ValidateForImport(nil) // No custom statuses needed for these tests
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("ValidateForImport() expected error, got nil")
|
||||
return
|
||||
}
|
||||
if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("ValidateForImport() error = %v, want error containing %q", err, tt.errMsg)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("ValidateForImport() unexpected error = %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateForImportVsValidateWithCustom contrasts the two validation modes
|
||||
func TestValidateForImportVsValidateWithCustom(t *testing.T) {
|
||||
// Issue with custom type that's NOT in customTypes list
|
||||
issue := Issue{
|
||||
Title: "Test Issue",
|
||||
Status: StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: IssueType("pm"), // Custom type not configured in parent
|
||||
}
|
||||
|
||||
// ValidateWithCustom (normal mode): should fail without pm in customTypes
|
||||
err := issue.ValidateWithCustom(nil, nil)
|
||||
if err == nil {
|
||||
t.Error("ValidateWithCustom() should fail for custom type without config")
|
||||
}
|
||||
|
||||
// ValidateWithCustom: should pass with pm in customTypes
|
||||
err = issue.ValidateWithCustom(nil, []string{"pm"})
|
||||
if err != nil {
|
||||
t.Errorf("ValidateWithCustom() with pm config should pass, got: %v", err)
|
||||
}
|
||||
|
||||
// ValidateForImport (federation trust mode): should pass without any config
|
||||
err = issue.ValidateForImport(nil)
|
||||
if err != nil {
|
||||
t.Errorf("ValidateForImport() should trust custom type, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueTypeIsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
issueType IssueType
|
||||
@@ -423,6 +566,43 @@ func TestIssueTypeIsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueTypeIsBuiltIn(t *testing.T) {
|
||||
// IsBuiltIn should match IsValid - both identify built-in types
|
||||
// This is used during multi-repo hydration to determine trust:
|
||||
// - Built-in types: validate (catch typos)
|
||||
// - Custom types (!IsBuiltIn): trust from source repo
|
||||
tests := []struct {
|
||||
issueType IssueType
|
||||
builtIn bool
|
||||
}{
|
||||
// All built-in types
|
||||
{TypeBug, true},
|
||||
{TypeFeature, true},
|
||||
{TypeTask, true},
|
||||
{TypeEpic, true},
|
||||
{TypeChore, true},
|
||||
{TypeMessage, true},
|
||||
{TypeMergeRequest, true},
|
||||
{TypeMolecule, true},
|
||||
{TypeGate, true},
|
||||
{TypeEvent, true},
|
||||
// Custom types (not built-in)
|
||||
{IssueType("pm"), false}, // Custom type from child repo
|
||||
{IssueType("llm"), false}, // Custom type from child repo
|
||||
{IssueType("agent"), false}, // Gas Town custom type
|
||||
{IssueType("convoy"), false}, // Gas Town custom type
|
||||
{IssueType(""), false}, // Empty is not built-in
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.issueType), func(t *testing.T) {
|
||||
if got := tt.issueType.IsBuiltIn(); got != tt.builtIn {
|
||||
t.Errorf("IssueType(%q).IsBuiltIn() = %v, want %v", tt.issueType, got, tt.builtIn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueTypeRequiredSections(t *testing.T) {
|
||||
tests := []struct {
|
||||
issueType IssueType
|
||||
|
||||
Reference in New Issue
Block a user