package doctor import ( "database/sql" "fmt" "os" "path/filepath" "strings" "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/configfile" ) // CheckMultiRepoTypes discovers and reports custom types used by child repos in multi-repo setups. // This is informational - the federation trust model means we don't require parent config to // list child types, but it's useful to know what types each child uses. func CheckMultiRepoTypes(repoPath string) DoctorCheck { multiRepo := config.GetMultiRepoConfig() if multiRepo == nil || len(multiRepo.Additional) == 0 { return DoctorCheck{ Name: "Multi-Repo Types", Status: StatusOK, Message: "N/A (single-repo mode)", Category: CategoryData, } } var details []string var warnings []string // Discover types from each child repo for _, repoPathStr := range multiRepo.Additional { childTypes := discoverChildTypes(repoPathStr) if len(childTypes) > 0 { details = append(details, fmt.Sprintf(" %s: %s", repoPathStr, strings.Join(childTypes, ", "))) } else { details = append(details, fmt.Sprintf(" %s: (no custom types)", repoPathStr)) } } // Check for hydrated issues using types not found anywhere unknownTypes := findUnknownTypesInHydratedIssues(repoPath, multiRepo) if len(unknownTypes) > 0 { warnings = append(warnings, fmt.Sprintf("Issues with unknown types: %s", strings.Join(unknownTypes, ", "))) } status := StatusOK message := fmt.Sprintf("Discovered types from %d child repo(s)", len(multiRepo.Additional)) if len(warnings) > 0 { status = StatusWarning message = fmt.Sprintf("Found %d type warning(s)", len(warnings)) details = append(details, "") details = append(details, "Warnings:") details = append(details, warnings...) } return DoctorCheck{ Name: "Multi-Repo Types", Status: status, Message: message, Detail: strings.Join(details, "\n"), Category: CategoryData, } } // discoverChildTypes reads custom types from a child repo's config or database. // Returns nil if no custom types are found (not an error - child may not have any). func discoverChildTypes(repoPath string) []string { // Expand tilde if strings.HasPrefix(repoPath, "~") { if home, err := os.UserHomeDir(); err == nil { repoPath = filepath.Join(home, repoPath[1:]) } } beadsDir := filepath.Join(repoPath, ".beads") // First try reading from database config table types, err := readTypesFromDB(beadsDir) if err == nil && len(types) > 0 { return types } // Fall back to reading from config.yaml types, err = readTypesFromYAML(beadsDir) if err == nil { return types } // No custom types found return nil } // readTypesFromDB reads types.custom from the database config table func readTypesFromDB(beadsDir string) ([]string, error) { // Get database path var dbPath string if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { dbPath = cfg.DatabasePath(beadsDir) } else { dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName) } if _, err := os.Stat(dbPath); os.IsNotExist(err) { return nil, err } db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true)) if err != nil { return nil, err } defer db.Close() var typesStr string err = db.QueryRow("SELECT value FROM config WHERE key = 'types.custom'").Scan(&typesStr) if err != nil { return nil, err } if typesStr == "" { return nil, nil } // Parse comma-separated list var types []string for _, t := range strings.Split(typesStr, ",") { t = strings.TrimSpace(t) if t != "" { types = append(types, t) } } return types, nil } // readTypesFromYAML reads types.custom from config.yaml func readTypesFromYAML(beadsDir string) ([]string, error) { configPath := filepath.Join(beadsDir, "config.yaml") if _, err := os.Stat(configPath); os.IsNotExist(err) { return nil, err } // Use viper to read the config // For simplicity, we'll parse it manually to avoid viper state issues content, err := os.ReadFile(configPath) // #nosec G304 - path is controlled if err != nil { return nil, err } // Simple YAML parsing for types.custom // Looking for "types:" section with "custom:" key lines := strings.Split(string(content), "\n") inTypes := false for _, line := range lines { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "types:") { inTypes = true continue } if inTypes && strings.HasPrefix(trimmed, "custom:") { // Parse the value value := strings.TrimPrefix(trimmed, "custom:") value = strings.TrimSpace(value) // Handle array format [a, b, c] or string format "a,b,c" value = strings.Trim(value, "[]\"'") if value == "" { return nil, nil } var types []string for _, t := range strings.Split(value, ",") { t = strings.TrimSpace(t) t = strings.Trim(t, "\"'") if t != "" { types = append(types, t) } } return types, nil } // Exit types section if we hit another top-level key if inTypes && len(line) > 0 && line[0] != ' ' && line[0] != '\t' { break } } return nil, nil } // findUnknownTypesInHydratedIssues checks if any hydrated issues use types not found in any config func findUnknownTypesInHydratedIssues(repoPath string, multiRepo *config.MultiRepoConfig) []string { beadsDir := filepath.Join(repoPath, ".beads") // Get database path var dbPath string if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { dbPath = cfg.DatabasePath(beadsDir) } else { dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName) } if _, err := os.Stat(dbPath); os.IsNotExist(err) { return nil } db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true)) if err != nil { return nil } defer db.Close() // Collect all known types (core work types + parent custom + all child custom) // Only core work types are built-in; Gas Town types require types.custom config. knownTypes := map[string]bool{ "bug": true, "feature": true, "task": true, "epic": true, "chore": true, } // Add parent's custom types var parentTypes string if err := db.QueryRow("SELECT value FROM config WHERE key = 'types.custom'").Scan(&parentTypes); err == nil { for _, t := range strings.Split(parentTypes, ",") { t = strings.TrimSpace(t) if t != "" { knownTypes[t] = true } } } // Add child types for _, repoPathStr := range multiRepo.Additional { childTypes := discoverChildTypes(repoPathStr) for _, t := range childTypes { knownTypes[t] = true } } // Find issues with types not in knownTypes rows, err := db.Query(` SELECT DISTINCT issue_type FROM issues WHERE status != 'tombstone' AND source_repo != '' AND source_repo != '.' `) if err != nil { return nil } defer rows.Close() var unknownTypes []string seen := make(map[string]bool) for rows.Next() { var issueType string if err := rows.Scan(&issueType); err != nil { continue } if !knownTypes[issueType] && !seen[issueType] { unknownTypes = append(unknownTypes, issueType) seen[issueType] = true } } return unknownTypes }