feat(config): add validation.on-create and validation.on-sync config options (bd-t7jq)

Add .beads/config.yaml support for template validation settings:
- validation.on-create: warn|error|none (default: none)
- validation.on-sync: warn|error|none (default: none)

When set to "warn", issues missing required sections (based on type) show
warnings but operations proceed. When set to "error", operations fail.

Implementation:
- Add validation keys to YamlOnlyKeys in yaml_config.go
- Add defaults in config.go
- Wire up bd create to check validation.on-create config
- Wire up bd sync to run validation before export
- Add tests for config loading
- Update CONFIG.md documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
topaz
2026-01-01 19:31:12 -08:00
committed by Steve Yegge
parent b73085962c
commit 65fb0c6d77
7 changed files with 164 additions and 2 deletions

View File

@@ -166,12 +166,26 @@ var createCmd = &cobra.Command{
estimatedMinutes = &est estimatedMinutes = &est
} }
// Validate template if --validate flag is set // Validate template based on --validate flag or config
validateTemplate, _ := cmd.Flags().GetBool("validate") validateTemplate, _ := cmd.Flags().GetBool("validate")
if validateTemplate { if validateTemplate {
// Explicit --validate flag: fail on error
if err := validation.ValidateTemplate(types.IssueType(issueType), description); err != nil { if err := validation.ValidateTemplate(types.IssueType(issueType), description); err != nil {
FatalError("%v", err) FatalError("%v", err)
} }
} else {
// Check validation.on-create config (bd-t7jq)
validationMode := config.GetString("validation.on-create")
if validationMode == "error" || validationMode == "warn" {
if err := validation.ValidateTemplate(types.IssueType(issueType), description); err != nil {
if validationMode == "error" {
FatalError("%v", err)
} else {
// warn mode: print warning but proceed
fmt.Fprintf(os.Stderr, "%s %v\n", ui.RenderWarn("⚠"), err)
}
}
}
} }
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun

View File

@@ -287,6 +287,11 @@ Use --merge to merge the sync branch back to main branch.`,
} }
} }
// Template validation before export (bd-t7jq)
if err := validateOpenIssuesForSync(ctx); err != nil {
FatalError("%v", err)
}
fmt.Println("→ Exporting pending changes to JSONL...") fmt.Println("→ Exporting pending changes to JSONL...")
if err := exportToJSONL(ctx, jsonlPath); err != nil { if err := exportToJSONL(ctx, jsonlPath); err != nil {
FatalError("exporting: %v", err) FatalError("exporting: %v", err)

View File

@@ -10,8 +10,11 @@ import (
"slices" "slices"
"time" "time"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/validation"
) )
// exportToJSONL exports the database to JSONL format // exportToJSONL exports the database to JSONL format
@@ -179,3 +182,63 @@ func exportToJSONL(ctx context.Context, jsonlPath string) error {
return nil return nil
} }
// validateOpenIssuesForSync validates all open issues against their templates
// before export, based on the validation.on-sync config setting.
// Returns an error if validation.on-sync is "error" and issues fail validation.
// Prints warnings if validation.on-sync is "warn".
// Does nothing if validation.on-sync is "none" (default).
func validateOpenIssuesForSync(ctx context.Context) error {
validationMode := config.GetString("validation.on-sync")
if validationMode == "none" || validationMode == "" {
return nil
}
// Ensure store is active
if err := ensureStoreActive(); err != nil {
return fmt.Errorf("failed to initialize store for validation: %w", err)
}
// Get all issues (excluding tombstones) and filter to open ones
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
return fmt.Errorf("failed to get issues for validation: %w", err)
}
// Filter to only open issues (not closed, not tombstones)
var issues []*types.Issue
for _, issue := range allIssues {
if issue.Status != types.StatusClosed && issue.Status != types.StatusTombstone {
issues = append(issues, issue)
}
}
// Validate each issue
var warnings []string
for _, issue := range issues {
if err := validation.LintIssue(issue); err != nil {
warnings = append(warnings, fmt.Sprintf("%s: %v", issue.ID, err))
}
}
if len(warnings) == 0 {
return nil
}
// Report based on mode
if validationMode == "error" {
fmt.Fprintf(os.Stderr, "%s Validation failed for %d issue(s):\n", ui.RenderFail("✗"), len(warnings))
for _, w := range warnings {
fmt.Fprintf(os.Stderr, " - %s\n", w)
}
return fmt.Errorf("template validation failed: %d issues missing required sections (set validation.on-sync: none or warn to proceed)", len(warnings))
}
// warn mode: print warnings but proceed
fmt.Fprintf(os.Stderr, "%s Validation warnings for %d issue(s):\n", ui.RenderWarn("⚠"), len(warnings))
for _, w := range warnings {
fmt.Fprintf(os.Stderr, " - %s\n", w)
}
return nil
}

View File

@@ -36,6 +36,8 @@ Tool-level settings you can configure:
| `no-auto-import` | `--no-auto-import` | `BD_NO_AUTO_IMPORT` | `false` | Disable auto JSONL import | | `no-auto-import` | `--no-auto-import` | `BD_NO_AUTO_IMPORT` | `false` | Disable auto JSONL import |
| `no-push` | `--no-push` | `BD_NO_PUSH` | `false` | Skip pushing to remote in bd sync | | `no-push` | `--no-push` | `BD_NO_PUSH` | `false` | Skip pushing to remote in bd sync |
| `create.require-description` | - | `BD_CREATE_REQUIRE_DESCRIPTION` | `false` | Require description when creating issues | | `create.require-description` | - | `BD_CREATE_REQUIRE_DESCRIPTION` | `false` | Require description when creating issues |
| `validation.on-create` | - | `BD_VALIDATION_ON_CREATE` | `none` | Template validation on create: `none`, `warn`, `error` |
| `validation.on-sync` | - | `BD_VALIDATION_ON_SYNC` | `none` | Template validation before sync: `none`, `warn`, `error` |
| `git.author` | - | `BD_GIT_AUTHOR` | (none) | Override commit author for beads commits | | `git.author` | - | `BD_GIT_AUTHOR` | (none) | Override commit author for beads commits |
| `git.no-gpg-sign` | - | `BD_GIT_NO_GPG_SIGN` | `false` | Disable GPG signing for beads commits | | `git.no-gpg-sign` | - | `BD_GIT_NO_GPG_SIGN` | `false` | Disable GPG signing for beads commits |
| `directory.labels` | - | - | (none) | Map directories to labels for automatic filtering | | `directory.labels` | - | - | (none) | Map directories to labels for automatic filtering |
@@ -81,6 +83,13 @@ flush-debounce: 15s
create: create:
require-description: true require-description: true
# Template validation settings (bd-t7jq)
# Validates that issues include required sections based on issue type
# Values: none (default), warn (print warning), error (block operation)
validation:
on-create: warn # Warn when creating issues missing sections
on-sync: none # No validation on sync (backwards compatible)
# Git commit signing options (GH#600) # Git commit signing options (GH#600)
# Useful when you have Touch ID commit signing that prompts for each commit # Useful when you have Touch ID commit signing that prompts for each commit
git: git:

View File

@@ -114,6 +114,14 @@ func Initialize() error {
// Create command defaults // Create command defaults
v.SetDefault("create.require-description", false) v.SetDefault("create.require-description", false)
// Validation configuration defaults (bd-t7jq)
// Values: "warn" | "error" | "none"
// - "none": no validation (default, backwards compatible)
// - "warn": validate and print warnings but proceed
// - "error": validate and fail on missing sections
v.SetDefault("validation.on-create", "none")
v.SetDefault("validation.on-sync", "none")
// Git configuration defaults (GH#600) // Git configuration defaults (GH#600)
v.SetDefault("git.author", "") // Override commit author (e.g., "beads-bot <beads@example.com>") v.SetDefault("git.author", "") // Override commit author (e.g., "beads-bot <beads@example.com>")
v.SetDefault("git.no-gpg-sign", false) // Disable GPG signing for beads commits v.SetDefault("git.no-gpg-sign", false) // Disable GPG signing for beads commits

View File

@@ -786,3 +786,61 @@ func TestConfigSourceConstants(t *testing.T) {
t.Errorf("SourceFlag = %q, want \"flag\"", SourceFlag) t.Errorf("SourceFlag = %q, want \"flag\"", SourceFlag)
} }
} }
func TestValidationConfigDefaults(t *testing.T) {
// Isolate from environment variables
restore := envSnapshot(t)
defer restore()
// Initialize config
if err := Initialize(); err != nil {
t.Fatalf("Initialize() returned error: %v", err)
}
// Test validation.on-create default is "none"
if got := GetString("validation.on-create"); got != "none" {
t.Errorf("GetString(validation.on-create) = %q, want \"none\"", got)
}
// Test validation.on-sync default is "none"
if got := GetString("validation.on-sync"); got != "none" {
t.Errorf("GetString(validation.on-sync) = %q, want \"none\"", got)
}
}
func TestValidationConfigFromFile(t *testing.T) {
// Create a temporary directory for config file
tmpDir := t.TempDir()
// Create a config file with validation settings
configContent := `
validation:
on-create: error
on-sync: warn
`
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("failed to create .beads directory: %v", err)
}
configPath := filepath.Join(beadsDir, "config.yaml")
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
// Change to tmp directory
t.Chdir(tmpDir)
// Initialize viper
if err := Initialize(); err != nil {
t.Fatalf("Initialize() returned error: %v", err)
}
// Test that validation settings are loaded correctly
if got := GetString("validation.on-create"); got != "error" {
t.Errorf("GetString(validation.on-create) = %q, want \"error\"", got)
}
if got := GetString("validation.on-sync"); got != "warn" {
t.Errorf("GetString(validation.on-sync) = %q, want \"warn\"", got)
}
}

View File

@@ -54,6 +54,11 @@ var YamlOnlyKeys = map[string]bool{
// Create command settings // Create command settings
"create.require-description": true, "create.require-description": true,
// Validation settings (bd-t7jq)
// Values: "warn" | "error" | "none"
"validation.on-create": true,
"validation.on-sync": true,
} }
// IsYamlOnlyKey returns true if the given key should be stored in config.yaml // IsYamlOnlyKey returns true if the given key should be stored in config.yaml
@@ -65,7 +70,7 @@ func IsYamlOnlyKey(key string) bool {
} }
// Check prefix matches for nested keys // Check prefix matches for nested keys
prefixes := []string{"routing.", "sync.", "git.", "directory.", "repos.", "external_projects."} prefixes := []string{"routing.", "sync.", "git.", "directory.", "repos.", "external_projects.", "validation."}
for _, prefix := range prefixes { for _, prefix := range prefixes {
if strings.HasPrefix(key, prefix) { if strings.HasPrefix(key, prefix) {
return true return true