* /{cmd,internal}: get dolt backend init working and allow issue creation
* /{website,internal,docs,cmd}: integration tests and more split backend fixes
* /{cmd,internal}: fix lint issues
* /cmd/bd/doctor/integrity.go: fix unable to query issues bug with dolt backend
* /cmd/bd/daemon.go: remove debug logging
745 lines
21 KiB
Go
745 lines
21 KiB
Go
package doctor
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "github.com/ncruces/go-sqlite3/driver"
|
|
_ "github.com/ncruces/go-sqlite3/embed"
|
|
"github.com/steveyegge/beads/cmd/bd/doctor/fix"
|
|
"github.com/steveyegge/beads/internal/beads"
|
|
"github.com/steveyegge/beads/internal/configfile"
|
|
"github.com/steveyegge/beads/internal/git"
|
|
storagefactory "github.com/steveyegge/beads/internal/storage/factory"
|
|
)
|
|
|
|
// CheckIDFormat checks whether issues use hash-based or sequential IDs
|
|
func CheckIDFormat(path string) DoctorCheck {
|
|
backend, beadsDir := getBackendAndBeadsDir(path)
|
|
|
|
// Determine the on-disk location (file for SQLite, directory for Dolt).
|
|
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
|
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil {
|
|
dbPath = cfg.DatabasePath(beadsDir)
|
|
}
|
|
|
|
// Check if using JSONL-only mode (or uninitialized DB).
|
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
|
// Check if JSONL exists (--no-db mode)
|
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
if _, err := os.Stat(jsonlPath); err == nil {
|
|
return DoctorCheck{
|
|
Name: "Issue IDs",
|
|
Status: StatusOK,
|
|
Message: "N/A (JSONL-only mode)",
|
|
}
|
|
}
|
|
// No database and no JSONL
|
|
return DoctorCheck{
|
|
Name: "Issue IDs",
|
|
Status: StatusOK,
|
|
Message: "No issues yet (will use hash-based IDs)",
|
|
}
|
|
}
|
|
|
|
// Open the configured backend in read-only mode.
|
|
// This must work for both SQLite and Dolt.
|
|
ctx := context.Background()
|
|
store, err := storagefactory.NewFromConfigWithOptions(ctx, beadsDir, storagefactory.Options{ReadOnly: true})
|
|
if err != nil {
|
|
return DoctorCheck{
|
|
Name: "Issue IDs",
|
|
Status: StatusError,
|
|
Message: "Unable to open database",
|
|
Detail: err.Error(),
|
|
}
|
|
}
|
|
defer func() { _ = store.Close() }() // Intentionally ignore close error
|
|
db := store.UnderlyingDB()
|
|
|
|
// Get sample of issues to check ID format (up to 10 for pattern analysis)
|
|
rows, err := db.QueryContext(ctx, "SELECT id FROM issues ORDER BY created_at LIMIT 10")
|
|
if err != nil {
|
|
return DoctorCheck{
|
|
Name: "Issue IDs",
|
|
Status: StatusError,
|
|
Message: "Unable to query issues",
|
|
Detail: err.Error(),
|
|
}
|
|
}
|
|
defer rows.Close()
|
|
|
|
var issueIDs []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err == nil {
|
|
issueIDs = append(issueIDs, id)
|
|
}
|
|
}
|
|
|
|
if len(issueIDs) == 0 {
|
|
return DoctorCheck{
|
|
Name: "Issue IDs",
|
|
Status: StatusOK,
|
|
Message: "No issues yet (will use hash-based IDs)",
|
|
}
|
|
}
|
|
|
|
// Detect ID format using robust heuristic
|
|
if DetectHashBasedIDs(db, issueIDs) {
|
|
return DoctorCheck{
|
|
Name: "Issue IDs",
|
|
Status: StatusOK,
|
|
Message: "hash-based ✓",
|
|
}
|
|
}
|
|
|
|
// Sequential IDs - recommend migration
|
|
if backend == configfile.BackendDolt {
|
|
return DoctorCheck{
|
|
Name: "Issue IDs",
|
|
Status: StatusOK,
|
|
Message: "hash-based ✓",
|
|
}
|
|
}
|
|
return DoctorCheck{
|
|
Name: "Issue IDs",
|
|
Status: StatusWarning,
|
|
Message: "sequential (e.g., bd-1, bd-2, ...)",
|
|
Fix: "Run 'bd migrate hash-ids' to upgrade (prevents ID collisions in multi-worker scenarios)",
|
|
}
|
|
}
|
|
|
|
// CheckDependencyCycles checks for circular dependencies in the issue graph
|
|
func CheckDependencyCycles(path string) DoctorCheck {
|
|
// Follow redirect to resolve actual beads directory (bd-tvus fix)
|
|
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
|
|
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
|
|
|
// If no database, skip this check
|
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
|
return DoctorCheck{
|
|
Name: "Dependency Cycles",
|
|
Status: StatusOK,
|
|
Message: "N/A (no database)",
|
|
}
|
|
}
|
|
|
|
// Open database to check for cycles
|
|
db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true))
|
|
if err != nil {
|
|
return DoctorCheck{
|
|
Name: "Dependency Cycles",
|
|
Status: StatusWarning,
|
|
Message: "Unable to open database",
|
|
Detail: err.Error(),
|
|
}
|
|
}
|
|
defer db.Close()
|
|
|
|
// Query for cycles using simplified SQL
|
|
query := `
|
|
WITH RECURSIVE paths AS (
|
|
SELECT
|
|
issue_id,
|
|
depends_on_id,
|
|
issue_id as start_id,
|
|
issue_id || '→' || depends_on_id as path,
|
|
0 as depth
|
|
FROM dependencies
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
d.issue_id,
|
|
d.depends_on_id,
|
|
p.start_id,
|
|
p.path || '→' || d.depends_on_id,
|
|
p.depth + 1
|
|
FROM dependencies d
|
|
JOIN paths p ON d.issue_id = p.depends_on_id
|
|
WHERE p.depth < 100
|
|
AND p.path NOT LIKE '%' || d.depends_on_id || '→%'
|
|
)
|
|
SELECT DISTINCT start_id
|
|
FROM paths
|
|
WHERE depends_on_id = start_id`
|
|
|
|
rows, err := db.Query(query)
|
|
if err != nil {
|
|
return DoctorCheck{
|
|
Name: "Dependency Cycles",
|
|
Status: StatusWarning,
|
|
Message: "Unable to check for cycles",
|
|
Detail: err.Error(),
|
|
}
|
|
}
|
|
defer rows.Close()
|
|
|
|
cycleCount := 0
|
|
var firstCycle string
|
|
for rows.Next() {
|
|
var startID string
|
|
if err := rows.Scan(&startID); err != nil {
|
|
continue
|
|
}
|
|
cycleCount++
|
|
if cycleCount == 1 {
|
|
firstCycle = startID
|
|
}
|
|
}
|
|
|
|
if cycleCount == 0 {
|
|
return DoctorCheck{
|
|
Name: "Dependency Cycles",
|
|
Status: StatusOK,
|
|
Message: "No circular dependencies detected",
|
|
}
|
|
}
|
|
|
|
return DoctorCheck{
|
|
Name: "Dependency Cycles",
|
|
Status: StatusError,
|
|
Message: fmt.Sprintf("Found %d circular dependency cycle(s)", cycleCount),
|
|
Detail: fmt.Sprintf("First cycle involves: %s", firstCycle),
|
|
Fix: "Run 'bd dep cycles' to see full cycle paths, then 'bd dep remove' to break cycles",
|
|
}
|
|
}
|
|
|
|
// CheckTombstones checks the health of tombstone records
|
|
// Reports: total tombstones, expiring soon (within 7 days), already expired
|
|
func CheckTombstones(path string) DoctorCheck {
|
|
// Follow redirect to resolve actual beads directory (bd-tvus fix)
|
|
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
|
|
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
|
|
|
// Skip if database doesn't exist
|
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
|
return DoctorCheck{
|
|
Name: "Tombstones",
|
|
Status: StatusOK,
|
|
Message: "N/A (no database)",
|
|
}
|
|
}
|
|
|
|
db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true))
|
|
if err != nil {
|
|
return DoctorCheck{
|
|
Name: "Tombstones",
|
|
Status: StatusWarning,
|
|
Message: "Unable to open database",
|
|
Detail: err.Error(),
|
|
}
|
|
}
|
|
defer db.Close()
|
|
|
|
// Query tombstone statistics
|
|
var totalTombstones int
|
|
err = db.QueryRow("SELECT COUNT(*) FROM issues WHERE status = 'tombstone'").Scan(&totalTombstones)
|
|
if err != nil {
|
|
// Might be old schema without tombstone support
|
|
return DoctorCheck{
|
|
Name: "Tombstones",
|
|
Status: StatusOK,
|
|
Message: "N/A (schema may not support tombstones)",
|
|
}
|
|
}
|
|
|
|
if totalTombstones == 0 {
|
|
return DoctorCheck{
|
|
Name: "Tombstones",
|
|
Status: StatusOK,
|
|
Message: "None (no deleted issues)",
|
|
}
|
|
}
|
|
|
|
// Check for tombstones expiring within 7 days
|
|
// Default TTL is 30 days, so expiring soon means deleted_at older than 23 days ago
|
|
expiringThreshold := time.Now().Add(-23 * 24 * time.Hour).Format(time.RFC3339)
|
|
expiredThreshold := time.Now().Add(-30 * 24 * time.Hour).Format(time.RFC3339)
|
|
|
|
var expiringSoon, alreadyExpired int
|
|
err = db.QueryRow(`
|
|
SELECT COUNT(*) FROM issues
|
|
WHERE status = 'tombstone'
|
|
AND deleted_at IS NOT NULL
|
|
AND deleted_at < ?
|
|
AND deleted_at >= ?
|
|
`, expiringThreshold, expiredThreshold).Scan(&expiringSoon)
|
|
if err != nil {
|
|
expiringSoon = 0
|
|
}
|
|
|
|
err = db.QueryRow(`
|
|
SELECT COUNT(*) FROM issues
|
|
WHERE status = 'tombstone'
|
|
AND deleted_at IS NOT NULL
|
|
AND deleted_at < ?
|
|
`, expiredThreshold).Scan(&alreadyExpired)
|
|
if err != nil {
|
|
alreadyExpired = 0
|
|
}
|
|
|
|
// Build status message
|
|
if alreadyExpired > 0 {
|
|
return DoctorCheck{
|
|
Name: "Tombstones",
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("%d total, %d expired", totalTombstones, alreadyExpired),
|
|
Detail: "Expired tombstones will be removed on next compact",
|
|
Fix: "Run 'bd compact' to prune expired tombstones",
|
|
}
|
|
}
|
|
|
|
if expiringSoon > 0 {
|
|
return DoctorCheck{
|
|
Name: "Tombstones",
|
|
Status: StatusOK,
|
|
Message: fmt.Sprintf("%d total, %d expiring within 7 days", totalTombstones, expiringSoon),
|
|
}
|
|
}
|
|
|
|
return DoctorCheck{
|
|
Name: "Tombstones",
|
|
Status: StatusOK,
|
|
Message: fmt.Sprintf("%d total", totalTombstones),
|
|
}
|
|
}
|
|
|
|
// CheckDeletionsManifest checks the status of deletions.jsonl and suggests migration to tombstones
|
|
func CheckDeletionsManifest(path string) DoctorCheck {
|
|
// Follow redirect to resolve actual beads directory (bd-tvus fix)
|
|
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
|
|
|
|
// Skip if .beads doesn't exist
|
|
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
|
return DoctorCheck{
|
|
Name: "Deletions Manifest",
|
|
Status: StatusOK,
|
|
Message: "N/A (no .beads directory)",
|
|
}
|
|
}
|
|
|
|
// Check if we're in a git repository using worktree-aware detection
|
|
_, err := git.GetGitDir()
|
|
if err != nil {
|
|
return DoctorCheck{
|
|
Name: "Deletions Manifest",
|
|
Status: StatusOK,
|
|
Message: "N/A (not a git repository)",
|
|
}
|
|
}
|
|
|
|
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
|
|
|
|
// Check if deletions.jsonl exists
|
|
info, err := os.Stat(deletionsPath)
|
|
if err == nil {
|
|
// File exists - count entries (empty file is valid, means no deletions)
|
|
if info.Size() == 0 {
|
|
return DoctorCheck{
|
|
Name: "Deletions Manifest",
|
|
Status: StatusOK,
|
|
Message: "Empty (no legacy deletions)",
|
|
}
|
|
}
|
|
file, err := os.Open(deletionsPath) // #nosec G304 - controlled path
|
|
if err == nil {
|
|
defer file.Close()
|
|
count := 0
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
if len(scanner.Bytes()) > 0 {
|
|
count++
|
|
}
|
|
}
|
|
// Suggest migration to inline tombstones
|
|
if count > 0 {
|
|
return DoctorCheck{
|
|
Name: "Deletions Manifest",
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("Legacy format (%d entries)", count),
|
|
Detail: "deletions.jsonl is deprecated in favor of inline tombstones",
|
|
Fix: "Run 'bd migrate tombstones' to convert to inline tombstones",
|
|
}
|
|
}
|
|
return DoctorCheck{
|
|
Name: "Deletions Manifest",
|
|
Status: StatusOK,
|
|
Message: "Empty (no legacy deletions)",
|
|
}
|
|
}
|
|
}
|
|
|
|
// deletions.jsonl doesn't exist - this is the expected state with tombstones
|
|
// Check for .migrated file to confirm migration happened
|
|
migratedPath := filepath.Join(beadsDir, "deletions.jsonl.migrated")
|
|
if _, err := os.Stat(migratedPath); err == nil {
|
|
return DoctorCheck{
|
|
Name: "Deletions Manifest",
|
|
Status: StatusOK,
|
|
Message: "Migrated to tombstones",
|
|
}
|
|
}
|
|
|
|
// No deletions.jsonl and no .migrated file - check if JSONL exists
|
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
|
|
jsonlPath = filepath.Join(beadsDir, "beads.jsonl")
|
|
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
|
|
return DoctorCheck{
|
|
Name: "Deletions Manifest",
|
|
Status: StatusOK,
|
|
Message: "N/A (no JSONL file)",
|
|
}
|
|
}
|
|
}
|
|
|
|
// JSONL exists but no deletions tracking - this is fine for new repos using tombstones
|
|
return DoctorCheck{
|
|
Name: "Deletions Manifest",
|
|
Status: StatusOK,
|
|
Message: "Using inline tombstones",
|
|
}
|
|
}
|
|
|
|
// CheckRepoFingerprint validates that the database belongs to this repository.
|
|
// This detects when a .beads directory was copied from another repo or when
|
|
// the git remote URL changed. A mismatch can cause data loss during sync.
|
|
func CheckRepoFingerprint(path string) DoctorCheck {
|
|
backend, beadsDir := getBackendAndBeadsDir(path)
|
|
|
|
// Backend-aware existence check
|
|
switch backend {
|
|
case configfile.BackendDolt:
|
|
if info, err := os.Stat(filepath.Join(beadsDir, "dolt")); err != nil || !info.IsDir() {
|
|
return DoctorCheck{
|
|
Name: "Repo Fingerprint",
|
|
Status: StatusOK,
|
|
Message: "N/A (no database)",
|
|
}
|
|
}
|
|
default:
|
|
// SQLite backend: needs a .db file
|
|
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 DoctorCheck{
|
|
Name: "Repo Fingerprint",
|
|
Status: StatusOK,
|
|
Message: "N/A (no database)",
|
|
}
|
|
}
|
|
}
|
|
|
|
// For Dolt, read fingerprint from storage metadata (no sqlite assumptions).
|
|
if backend == configfile.BackendDolt {
|
|
ctx := context.Background()
|
|
store, err := storagefactory.NewFromConfigWithOptions(ctx, beadsDir, storagefactory.Options{ReadOnly: true})
|
|
if err != nil {
|
|
return DoctorCheck{
|
|
Name: "Repo Fingerprint",
|
|
Status: StatusWarning,
|
|
Message: "Unable to open database",
|
|
Detail: err.Error(),
|
|
}
|
|
}
|
|
defer func() { _ = store.Close() }()
|
|
|
|
storedRepoID, err := store.GetMetadata(ctx, "repo_id")
|
|
if err != nil {
|
|
return DoctorCheck{
|
|
Name: "Repo Fingerprint",
|
|
Status: StatusWarning,
|
|
Message: "Unable to read repo fingerprint",
|
|
Detail: err.Error(),
|
|
}
|
|
}
|
|
|
|
// If missing, warn (not the legacy sqlite messaging).
|
|
if storedRepoID == "" {
|
|
return DoctorCheck{
|
|
Name: "Repo Fingerprint",
|
|
Status: StatusWarning,
|
|
Message: "Missing repo fingerprint metadata",
|
|
Detail: "Storage: Dolt",
|
|
Fix: "Run 'bd migrate --update-repo-id' to add fingerprint metadata",
|
|
}
|
|
}
|
|
|
|
currentRepoID, err := beads.ComputeRepoID()
|
|
if err != nil {
|
|
return DoctorCheck{
|
|
Name: "Repo Fingerprint",
|
|
Status: StatusWarning,
|
|
Message: "Unable to compute current repo ID",
|
|
Detail: err.Error(),
|
|
}
|
|
}
|
|
|
|
if storedRepoID != currentRepoID {
|
|
return DoctorCheck{
|
|
Name: "Repo Fingerprint",
|
|
Status: StatusError,
|
|
Message: "Database belongs to different repository",
|
|
Detail: fmt.Sprintf("stored: %s, current: %s", storedRepoID[:8], currentRepoID[:8]),
|
|
Fix: "Run 'bd migrate --update-repo-id' if URL changed, or 'rm -rf .beads && bd init --backend dolt' if wrong database",
|
|
}
|
|
}
|
|
|
|
return DoctorCheck{
|
|
Name: "Repo Fingerprint",
|
|
Status: StatusOK,
|
|
Message: fmt.Sprintf("Verified (%s)", currentRepoID[:8]),
|
|
}
|
|
}
|
|
|
|
// SQLite path (existing behavior)
|
|
// 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)
|
|
}
|
|
|
|
// Skip if database doesn't exist
|
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
|
return DoctorCheck{
|
|
Name: "Repo Fingerprint",
|
|
Status: StatusOK,
|
|
Message: "N/A (no database)",
|
|
}
|
|
}
|
|
|
|
// Open database
|
|
db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true))
|
|
if err != nil {
|
|
return DoctorCheck{
|
|
Name: "Repo Fingerprint",
|
|
Status: StatusWarning,
|
|
Message: "Unable to open database",
|
|
Detail: err.Error(),
|
|
}
|
|
}
|
|
defer db.Close()
|
|
|
|
// Get stored repo ID
|
|
var storedRepoID string
|
|
err = db.QueryRow("SELECT value FROM metadata WHERE key = 'repo_id'").Scan(&storedRepoID)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows || strings.Contains(err.Error(), "no such table") {
|
|
// Legacy database without repo_id - this is an error because daemon won't start
|
|
return DoctorCheck{
|
|
Name: "Repo Fingerprint",
|
|
Status: StatusError,
|
|
Message: "Legacy database (no fingerprint)",
|
|
Detail: "Database was created before version 0.17.5. Daemon will fail to start.",
|
|
Fix: "Run 'bd migrate --update-repo-id' to add fingerprint",
|
|
}
|
|
}
|
|
return DoctorCheck{
|
|
Name: "Repo Fingerprint",
|
|
Status: StatusWarning,
|
|
Message: "Unable to read repo fingerprint",
|
|
Detail: err.Error(),
|
|
}
|
|
}
|
|
|
|
// If repo_id is empty, treat as legacy - this is an error because daemon won't start
|
|
if storedRepoID == "" {
|
|
return DoctorCheck{
|
|
Name: "Repo Fingerprint",
|
|
Status: StatusError,
|
|
Message: "Legacy database (empty fingerprint)",
|
|
Detail: "Database was created before version 0.17.5. Daemon will fail to start.",
|
|
Fix: "Run 'bd migrate --update-repo-id' to add fingerprint",
|
|
}
|
|
}
|
|
|
|
// Compute current repo ID
|
|
currentRepoID, err := beads.ComputeRepoID()
|
|
if err != nil {
|
|
return DoctorCheck{
|
|
Name: "Repo Fingerprint",
|
|
Status: StatusWarning,
|
|
Message: "Unable to compute current repo ID",
|
|
Detail: err.Error(),
|
|
}
|
|
}
|
|
|
|
// Compare
|
|
if storedRepoID != currentRepoID {
|
|
return DoctorCheck{
|
|
Name: "Repo Fingerprint",
|
|
Status: StatusError,
|
|
Message: "Database belongs to different repository",
|
|
Detail: fmt.Sprintf("stored: %s, current: %s", storedRepoID[:8], currentRepoID[:8]),
|
|
Fix: "Run 'bd migrate --update-repo-id' if URL changed, or 'rm -rf .beads && bd init' if wrong database",
|
|
}
|
|
}
|
|
|
|
return DoctorCheck{
|
|
Name: "Repo Fingerprint",
|
|
Status: StatusOK,
|
|
Message: fmt.Sprintf("Verified (%s)", currentRepoID[:8]),
|
|
}
|
|
}
|
|
|
|
// Fix functions
|
|
|
|
// FixMigrateTombstones converts legacy deletions.jsonl entries to inline tombstones
|
|
func FixMigrateTombstones(path string) error {
|
|
return fix.MigrateTombstones(path)
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
// DetectHashBasedIDs uses multiple heuristics to determine if the database uses hash-based IDs.
|
|
// This is more robust than checking a single ID's format, since base36 hash IDs can be all-numeric.
|
|
func DetectHashBasedIDs(db *sql.DB, sampleIDs []string) bool {
|
|
// Heuristic 1: Check for child_counters table (added for hash ID support)
|
|
var tableName string
|
|
err := db.QueryRow(`
|
|
SELECT name FROM sqlite_master
|
|
WHERE type='table' AND name='child_counters'
|
|
`).Scan(&tableName)
|
|
if err == nil {
|
|
// child_counters table exists - this is a strong indicator of hash IDs
|
|
return true
|
|
}
|
|
|
|
// Heuristic 2: Check if any sample ID clearly contains letters (a-z)
|
|
// Hash IDs use base36 (0-9, a-z), sequential IDs are purely numeric
|
|
for _, id := range sampleIDs {
|
|
if isHashID(id) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Heuristic 3: Look for patterns that indicate hash IDs
|
|
if len(sampleIDs) >= 2 {
|
|
// Extract suffixes (part after prefix-) for analysis
|
|
var suffixes []string
|
|
for _, id := range sampleIDs {
|
|
parts := strings.SplitN(id, "-", 2)
|
|
if len(parts) == 2 {
|
|
// Strip hierarchical suffix like .1 or .1.2
|
|
baseSuffix := strings.Split(parts[1], ".")[0]
|
|
suffixes = append(suffixes, baseSuffix)
|
|
}
|
|
}
|
|
|
|
if len(suffixes) >= 2 {
|
|
// Check for variable lengths (strong indicator of adaptive hash IDs)
|
|
// BUT: sequential IDs can also have variable length (1, 10, 100)
|
|
// So we need to check if the length variation is natural (1→2→3 digits)
|
|
// or random (3→8→4 chars typical of adaptive hash IDs)
|
|
lengths := make(map[int]int) // length -> count
|
|
for _, s := range suffixes {
|
|
lengths[len(s)]++
|
|
}
|
|
|
|
// If we have 3+ different lengths, likely hash IDs (adaptive length)
|
|
// Sequential IDs typically have 1-2 lengths (e.g., 1-9, 10-99, 100-999)
|
|
if len(lengths) >= 3 {
|
|
return true
|
|
}
|
|
|
|
// Check for leading zeros (rare in sequential IDs, common in hash IDs)
|
|
// Sequential IDs: bd-1, bd-2, bd-10, bd-100
|
|
// Hash IDs: bd-0088, bd-02a4, bd-05a1
|
|
hasLeadingZero := false
|
|
for _, s := range suffixes {
|
|
if len(s) > 1 && s[0] == '0' {
|
|
hasLeadingZero = true
|
|
break
|
|
}
|
|
}
|
|
if hasLeadingZero {
|
|
return true
|
|
}
|
|
|
|
// Check for non-sequential ordering
|
|
// Try to parse as integers - if they're not sequential, likely hash IDs
|
|
allNumeric := true
|
|
var nums []int
|
|
for _, s := range suffixes {
|
|
var num int
|
|
if _, err := fmt.Sscanf(s, "%d", &num); err == nil {
|
|
nums = append(nums, num)
|
|
} else {
|
|
allNumeric = false
|
|
break
|
|
}
|
|
}
|
|
|
|
if allNumeric && len(nums) >= 2 {
|
|
// Check if they form a roughly sequential pattern (1,2,3 or 10,11,12)
|
|
// Hash IDs would be more random (e.g., 88, 13452, 676)
|
|
isSequentialPattern := true
|
|
for i := 1; i < len(nums); i++ {
|
|
diff := nums[i] - nums[i-1]
|
|
// Allow for some gaps (deleted issues), but should be mostly sequential
|
|
if diff < 0 || diff > 100 {
|
|
isSequentialPattern = false
|
|
break
|
|
}
|
|
}
|
|
// If the numbers are NOT sequential, they're likely hash IDs
|
|
if !isSequentialPattern {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we can't determine for sure, default to assuming sequential IDs
|
|
// This is conservative - better to recommend migration than miss sequential IDs
|
|
return false
|
|
}
|
|
|
|
// isHashID checks if a single ID contains hash characteristics
|
|
// Hash IDs contain hex letters (a-f), sequential IDs are only digits
|
|
// May have hierarchical suffix like .1 or .1.2
|
|
func isHashID(id string) bool {
|
|
lastSeperatorIndex := strings.LastIndex(id, "-")
|
|
if lastSeperatorIndex == -1 {
|
|
return false
|
|
}
|
|
|
|
suffix := id[lastSeperatorIndex+1:]
|
|
// Strip hierarchical suffix like .1 or .1.2
|
|
baseSuffix := strings.Split(suffix, ".")[0]
|
|
|
|
if len(baseSuffix) == 0 {
|
|
return false
|
|
}
|
|
|
|
// Must be valid Base36 (0-9, a-z)
|
|
if !regexp.MustCompile(`^[0-9a-z]+$`).MatchString(baseSuffix) {
|
|
return false
|
|
}
|
|
|
|
// If it's 5+ characters long, it's almost certainly a hash ID
|
|
// (sequential IDs rarely exceed 9999 = 4 digits)
|
|
if len(baseSuffix) >= 5 {
|
|
return true
|
|
}
|
|
|
|
// For shorter IDs, check if it contains any letter (a-z)
|
|
// Sequential IDs are purely numeric
|
|
return regexp.MustCompile(`[a-z]`).MatchString(baseSuffix)
|
|
}
|