- multirepo.go: discoverChildTypes now returns []string instead of ([]string, error) since error was always nil - socket_path.go: tmpDir changed from function to const since it always returned "/tmp" regardless of platform Fixes CI lint failures caused by unparam linter detecting unused error returns and constant function results. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Executed-By: beads/crew/dave Rig: beads Role: crew
262 lines
7.0 KiB
Go
262 lines
7.0 KiB
Go
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 (built-in + parent custom + all child custom)
|
|
knownTypes := map[string]bool{
|
|
"bug": true, "feature": true, "task": true, "epic": true, "chore": true,
|
|
"message": true, "merge-request": true, "molecule": true, "gate": true, "event": 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
|
|
}
|