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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user