Add --gastown flag to bd doctor for gastown-specific checks (#1162)
When running in gastown multi-workspace mode, two checks produce false positives that are expected behavior: 1. routes.jsonl is a valid configuration file (maps issue prefixes to rig directories), not a duplicate JSONL file 2. Duplicate issues are expected (ephemeral wisps from patrol cycles) and normal up to ~1000, with GC cleaning them up automatically This commit adds flags to bd doctor for gastown-specific checks: - --gastown: Skip routes.jsonl warning and enable duplicate threshold - --gastown-duplicates-threshold=N: Set duplicate tolerance (default 1000) Fixes false positive warnings: Multiple JSONL files found: issues.jsonl, routes.jsonl 70 duplicate issue(s) in 30 group(s) Changes: - Add --gastown flag to bd doctor command - Add --gastown-duplicates-threshold flag (default: 1000) - Update CheckLegacyJSONLFilename to skip routes.jsonl when gastown mode active - Update CheckDuplicateIssues to use configurable threshold when gastown mode active - Add test cases for gastown mode behavior with various thresholds Co-authored-by: Roland Tritsch <roland@ailtir.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -55,7 +55,9 @@ var (
|
|||||||
checkHealthMode bool
|
checkHealthMode bool
|
||||||
doctorCheckFlag string // run specific check (e.g., "pollution")
|
doctorCheckFlag string // run specific check (e.g., "pollution")
|
||||||
doctorClean bool // for pollution check, delete detected issues
|
doctorClean bool // for pollution check, delete detected issues
|
||||||
doctorDeep bool // full graph integrity validation
|
doctorDeep bool // full graph integrity validation
|
||||||
|
doctorGastown bool // running in gastown multi-workspace mode
|
||||||
|
gastownDuplicatesThreshold int // duplicate tolerance threshold for gastown mode
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConfigKeyHintsDoctor is the config key for suppressing doctor hints
|
// ConfigKeyHintsDoctor is the config key for suppressing doctor hints
|
||||||
@@ -226,6 +228,8 @@ func init() {
|
|||||||
doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output during fixes (e.g., list each removed dependency)")
|
doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output during fixes (e.g., list each removed dependency)")
|
||||||
doctorCmd.Flags().BoolVar(&doctorForce, "force", false, "Force repair mode: attempt recovery even when database cannot be opened")
|
doctorCmd.Flags().BoolVar(&doctorForce, "force", false, "Force repair mode: attempt recovery even when database cannot be opened")
|
||||||
doctorCmd.Flags().StringVar(&doctorSource, "source", "auto", "Choose source of truth for recovery: auto (detect), jsonl (prefer JSONL), db (prefer database)")
|
doctorCmd.Flags().StringVar(&doctorSource, "source", "auto", "Choose source of truth for recovery: auto (detect), jsonl (prefer JSONL), db (prefer database)")
|
||||||
|
doctorCmd.Flags().BoolVar(&doctorGastown, "gastown", false, "Running in gastown multi-workspace mode (routes.jsonl is expected, higher duplicate tolerance)")
|
||||||
|
doctorCmd.Flags().IntVar(&gastownDuplicatesThreshold, "gastown-duplicates-threshold", 1000, "Duplicate tolerance threshold for gastown mode (wisps are ephemeral)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDiagnostics(path string) doctorResult {
|
func runDiagnostics(path string) doctorResult {
|
||||||
@@ -320,7 +324,7 @@ func runDiagnostics(path string) doctorResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check 6: Multiple JSONL files (excluding merge artifacts)
|
// Check 6: Multiple JSONL files (excluding merge artifacts)
|
||||||
jsonlCheck := convertWithCategory(doctor.CheckLegacyJSONLFilename(path), doctor.CategoryData)
|
jsonlCheck := convertWithCategory(doctor.CheckLegacyJSONLFilename(path, doctorGastown), doctor.CategoryData)
|
||||||
result.Checks = append(result.Checks, jsonlCheck)
|
result.Checks = append(result.Checks, jsonlCheck)
|
||||||
if jsonlCheck.Status == statusWarning || jsonlCheck.Status == statusError {
|
if jsonlCheck.Status == statusWarning || jsonlCheck.Status == statusError {
|
||||||
result.OverallOK = false
|
result.OverallOK = false
|
||||||
@@ -547,7 +551,7 @@ func runDiagnostics(path string) doctorResult {
|
|||||||
// Don't fail overall check for child→parent deps, just warn
|
// Don't fail overall check for child→parent deps, just warn
|
||||||
|
|
||||||
// Check 23: Duplicate issues (from bd validate)
|
// Check 23: Duplicate issues (from bd validate)
|
||||||
duplicatesCheck := convertDoctorCheck(doctor.CheckDuplicateIssues(path))
|
duplicatesCheck := convertDoctorCheck(doctor.CheckDuplicateIssues(path, doctorGastown, gastownDuplicatesThreshold))
|
||||||
result.Checks = append(result.Checks, duplicatesCheck)
|
result.Checks = append(result.Checks, duplicatesCheck)
|
||||||
// Don't fail overall check for duplicates, just warn
|
// Don't fail overall check for duplicates, just warn
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ func CheckAgentDocumentation(repoPath string) DoctorCheck {
|
|||||||
|
|
||||||
// CheckLegacyJSONLFilename detects if there are multiple JSONL files,
|
// CheckLegacyJSONLFilename detects if there are multiple JSONL files,
|
||||||
// which can cause sync/merge issues. Ignores merge artifacts and backups.
|
// which can cause sync/merge issues. Ignores merge artifacts and backups.
|
||||||
func CheckLegacyJSONLFilename(repoPath string) DoctorCheck {
|
// When gastownMode is true, routes.jsonl is treated as a valid system file.
|
||||||
|
func CheckLegacyJSONLFilename(repoPath string, gastownMode bool) DoctorCheck {
|
||||||
beadsDir := filepath.Join(repoPath, ".beads")
|
beadsDir := filepath.Join(repoPath, ".beads")
|
||||||
|
|
||||||
// Find all .jsonl files
|
// Find all .jsonl files
|
||||||
@@ -160,7 +161,9 @@ func CheckLegacyJSONLFilename(repoPath string) DoctorCheck {
|
|||||||
// Git merge conflict artifacts (e.g., issues.base.jsonl, issues.left.jsonl)
|
// Git merge conflict artifacts (e.g., issues.base.jsonl, issues.left.jsonl)
|
||||||
strings.Contains(lowerName, ".base.jsonl") ||
|
strings.Contains(lowerName, ".base.jsonl") ||
|
||||||
strings.Contains(lowerName, ".left.jsonl") ||
|
strings.Contains(lowerName, ".left.jsonl") ||
|
||||||
strings.Contains(lowerName, ".right.jsonl") {
|
strings.Contains(lowerName, ".right.jsonl") ||
|
||||||
|
// Skip routes.jsonl in gastown mode (valid system file)
|
||||||
|
(gastownMode && name == "routes.jsonl") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,96 +209,126 @@ func TestCheckLegacyJSONLFilename(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
files []string
|
files []string
|
||||||
|
gastownMode bool
|
||||||
expectedStatus string
|
expectedStatus string
|
||||||
expectWarning bool
|
expectWarning bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "no JSONL files",
|
name: "no JSONL files",
|
||||||
files: []string{},
|
files: []string{},
|
||||||
|
gastownMode: false,
|
||||||
expectedStatus: "ok",
|
expectedStatus: "ok",
|
||||||
expectWarning: false,
|
expectWarning: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single issues.jsonl",
|
name: "single issues.jsonl",
|
||||||
files: []string{"issues.jsonl"},
|
files: []string{"issues.jsonl"},
|
||||||
|
gastownMode: false,
|
||||||
expectedStatus: "ok",
|
expectedStatus: "ok",
|
||||||
expectWarning: false,
|
expectWarning: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single beads.jsonl is ok",
|
name: "single beads.jsonl is ok",
|
||||||
files: []string{"beads.jsonl"},
|
files: []string{"beads.jsonl"},
|
||||||
|
gastownMode: false,
|
||||||
expectedStatus: "ok",
|
expectedStatus: "ok",
|
||||||
expectWarning: false,
|
expectWarning: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "custom name is ok",
|
name: "custom name is ok",
|
||||||
files: []string{"my-issues.jsonl"},
|
files: []string{"my-issues.jsonl"},
|
||||||
|
gastownMode: false,
|
||||||
expectedStatus: "ok",
|
expectedStatus: "ok",
|
||||||
expectWarning: false,
|
expectWarning: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple JSONL files warning",
|
name: "multiple JSONL files warning",
|
||||||
files: []string{"beads.jsonl", "issues.jsonl"},
|
files: []string{"beads.jsonl", "issues.jsonl"},
|
||||||
|
gastownMode: false,
|
||||||
|
expectedStatus: "warning",
|
||||||
|
expectWarning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "routes.jsonl with gastown flag",
|
||||||
|
files: []string{"issues.jsonl", "routes.jsonl"},
|
||||||
|
gastownMode: true,
|
||||||
|
expectedStatus: "ok",
|
||||||
|
expectWarning: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "routes.jsonl without gastown flag",
|
||||||
|
files: []string{"issues.jsonl", "routes.jsonl"},
|
||||||
|
gastownMode: false,
|
||||||
expectedStatus: "warning",
|
expectedStatus: "warning",
|
||||||
expectWarning: true,
|
expectWarning: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "backup files ignored",
|
name: "backup files ignored",
|
||||||
files: []string{"issues.jsonl", "issues.jsonl.backup", "BACKUP_issues.jsonl"},
|
files: []string{"issues.jsonl", "issues.jsonl.backup", "BACKUP_issues.jsonl"},
|
||||||
|
gastownMode: false,
|
||||||
expectedStatus: "ok",
|
expectedStatus: "ok",
|
||||||
expectWarning: false,
|
expectWarning: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple real files with backups",
|
name: "multiple real files with backups",
|
||||||
files: []string{"issues.jsonl", "beads.jsonl", "issues.jsonl.backup"},
|
files: []string{"issues.jsonl", "beads.jsonl", "issues.jsonl.backup"},
|
||||||
|
gastownMode: false,
|
||||||
expectedStatus: "warning",
|
expectedStatus: "warning",
|
||||||
expectWarning: true,
|
expectWarning: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "deletions.jsonl ignored as system file",
|
name: "deletions.jsonl ignored as system file",
|
||||||
files: []string{"beads.jsonl", "deletions.jsonl"},
|
files: []string{"beads.jsonl", "deletions.jsonl"},
|
||||||
|
gastownMode: false,
|
||||||
expectedStatus: "ok",
|
expectedStatus: "ok",
|
||||||
expectWarning: false,
|
expectWarning: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "merge artifacts ignored",
|
name: "merge artifacts ignored",
|
||||||
files: []string{"issues.jsonl", "issues.base.jsonl", "issues.left.jsonl"},
|
files: []string{"issues.jsonl", "issues.base.jsonl", "issues.left.jsonl"},
|
||||||
|
gastownMode: false,
|
||||||
expectedStatus: "ok",
|
expectedStatus: "ok",
|
||||||
expectWarning: false,
|
expectWarning: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "merge artifacts with right variant ignored",
|
name: "merge artifacts with right variant ignored",
|
||||||
files: []string{"issues.jsonl", "issues.right.jsonl"},
|
files: []string{"issues.jsonl", "issues.right.jsonl"},
|
||||||
|
gastownMode: false,
|
||||||
expectedStatus: "ok",
|
expectedStatus: "ok",
|
||||||
expectWarning: false,
|
expectWarning: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "beads merge artifacts ignored (bd-ov1)",
|
name: "beads merge artifacts ignored (bd-ov1)",
|
||||||
files: []string{"issues.jsonl", "beads.base.jsonl", "beads.left.jsonl"},
|
files: []string{"issues.jsonl", "beads.base.jsonl", "beads.left.jsonl"},
|
||||||
|
gastownMode: false,
|
||||||
expectedStatus: "ok",
|
expectedStatus: "ok",
|
||||||
expectWarning: false,
|
expectWarning: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "interactions.jsonl ignored as system file (GH#709)",
|
name: "interactions.jsonl ignored as system file (GH#709)",
|
||||||
files: []string{"issues.jsonl", "interactions.jsonl"},
|
files: []string{"issues.jsonl", "interactions.jsonl"},
|
||||||
|
gastownMode: false,
|
||||||
expectedStatus: "ok",
|
expectedStatus: "ok",
|
||||||
expectWarning: false,
|
expectWarning: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "molecules.jsonl ignored as system file",
|
name: "molecules.jsonl ignored as system file",
|
||||||
files: []string{"issues.jsonl", "molecules.jsonl"},
|
files: []string{"issues.jsonl", "molecules.jsonl"},
|
||||||
|
gastownMode: false,
|
||||||
expectedStatus: "ok",
|
expectedStatus: "ok",
|
||||||
expectWarning: false,
|
expectWarning: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "sync_base.jsonl ignored as system file (GH#1021)",
|
name: "sync_base.jsonl ignored as system file (GH#1021)",
|
||||||
files: []string{"issues.jsonl", "sync_base.jsonl"},
|
files: []string{"issues.jsonl", "sync_base.jsonl"},
|
||||||
|
gastownMode: false,
|
||||||
expectedStatus: "ok",
|
expectedStatus: "ok",
|
||||||
expectWarning: false,
|
expectWarning: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "all system files ignored together",
|
name: "all system files ignored together",
|
||||||
files: []string{"issues.jsonl", "deletions.jsonl", "interactions.jsonl", "molecules.jsonl", "sync_base.jsonl"},
|
files: []string{"issues.jsonl", "deletions.jsonl", "interactions.jsonl", "molecules.jsonl", "sync_base.jsonl"},
|
||||||
|
gastownMode: false,
|
||||||
expectedStatus: "ok",
|
expectedStatus: "ok",
|
||||||
expectWarning: false,
|
expectWarning: false,
|
||||||
},
|
},
|
||||||
@@ -320,7 +350,7 @@ func TestCheckLegacyJSONLFilename(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
check := CheckLegacyJSONLFilename(tmpDir)
|
check := CheckLegacyJSONLFilename(tmpDir, tt.gastownMode)
|
||||||
|
|
||||||
if check.Status != tt.expectedStatus {
|
if check.Status != tt.expectedStatus {
|
||||||
t.Errorf("Expected status %s, got %s", tt.expectedStatus, check.Status)
|
t.Errorf("Expected status %s, got %s", tt.expectedStatus, check.Status)
|
||||||
|
|||||||
@@ -181,7 +181,9 @@ func CheckOrphanedDependencies(path string) DoctorCheck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CheckDuplicateIssues detects issues with identical content.
|
// CheckDuplicateIssues detects issues with identical content.
|
||||||
func CheckDuplicateIssues(path string) DoctorCheck {
|
// When gastownMode is true, the threshold parameter defines how many duplicates
|
||||||
|
// are acceptable before warning (default 1000 for gastown's ephemeral wisps).
|
||||||
|
func CheckDuplicateIssues(path string, gastownMode bool, gastownThreshold int) DoctorCheck {
|
||||||
// Follow redirect to resolve actual beads directory (bd-tvus fix)
|
// Follow redirect to resolve actual beads directory (bd-tvus fix)
|
||||||
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
|
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
|
||||||
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||||
@@ -236,7 +238,13 @@ func CheckDuplicateIssues(path string) DoctorCheck {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if duplicateGroups == 0 {
|
// Apply threshold based on mode
|
||||||
|
threshold := 0 // Default: any duplicates are warnings
|
||||||
|
if gastownMode {
|
||||||
|
threshold = gastownThreshold // Gastown: configurable threshold (default 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalDuplicates == 0 {
|
||||||
return DoctorCheck{
|
return DoctorCheck{
|
||||||
Name: "Duplicate Issues",
|
Name: "Duplicate Issues",
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
@@ -244,12 +252,26 @@ func CheckDuplicateIssues(path string) DoctorCheck {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only warn if duplicate count exceeds threshold
|
||||||
|
if totalDuplicates > threshold {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Duplicate Issues",
|
||||||
|
Status: "warning",
|
||||||
|
Message: fmt.Sprintf("%d duplicate issue(s) in %d group(s)", totalDuplicates, duplicateGroups),
|
||||||
|
Detail: "Duplicates cannot be auto-fixed",
|
||||||
|
Fix: "Run 'bd duplicates' to review and merge duplicates",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under threshold - OK
|
||||||
|
message := "No duplicate issues"
|
||||||
|
if gastownMode && totalDuplicates > 0 {
|
||||||
|
message = fmt.Sprintf("%d duplicate(s) detected (within gastown threshold of %d)", totalDuplicates, threshold)
|
||||||
|
}
|
||||||
return DoctorCheck{
|
return DoctorCheck{
|
||||||
Name: "Duplicate Issues",
|
Name: "Duplicate Issues",
|
||||||
Status: "warning",
|
Status: "ok",
|
||||||
Message: fmt.Sprintf("%d duplicate issue(s) in %d group(s)", totalDuplicates, duplicateGroups),
|
Message: message,
|
||||||
Detail: "Duplicates cannot be auto-fixed",
|
|
||||||
Fix: "Run 'bd duplicates' to review and merge duplicates",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func TestCheckDuplicateIssues_ClosedIssuesExcluded(t *testing.T) {
|
|||||||
// Close the store so CheckDuplicateIssues can open it
|
// Close the store so CheckDuplicateIssues can open it
|
||||||
store.Close()
|
store.Close()
|
||||||
|
|
||||||
check := CheckDuplicateIssues(tmpDir)
|
check := CheckDuplicateIssues(tmpDir, false, 1000)
|
||||||
|
|
||||||
// Should NOT report duplicates because all are closed
|
// Should NOT report duplicates because all are closed
|
||||||
if check.Status != StatusOK {
|
if check.Status != StatusOK {
|
||||||
@@ -99,7 +99,7 @@ func TestCheckDuplicateIssues_OpenDuplicatesDetected(t *testing.T) {
|
|||||||
|
|
||||||
store.Close()
|
store.Close()
|
||||||
|
|
||||||
check := CheckDuplicateIssues(tmpDir)
|
check := CheckDuplicateIssues(tmpDir, false, 1000)
|
||||||
|
|
||||||
if check.Status != StatusWarning {
|
if check.Status != StatusWarning {
|
||||||
t.Errorf("Status = %q, want %q (open duplicates should be detected)", check.Status, StatusWarning)
|
t.Errorf("Status = %q, want %q (open duplicates should be detected)", check.Status, StatusWarning)
|
||||||
@@ -148,7 +148,7 @@ func TestCheckDuplicateIssues_DifferentDesignNotDuplicate(t *testing.T) {
|
|||||||
|
|
||||||
store.Close()
|
store.Close()
|
||||||
|
|
||||||
check := CheckDuplicateIssues(tmpDir)
|
check := CheckDuplicateIssues(tmpDir, false, 1000)
|
||||||
|
|
||||||
if check.Status != StatusOK {
|
if check.Status != StatusOK {
|
||||||
t.Errorf("Status = %q, want %q (different design = not duplicates)", check.Status, StatusOK)
|
t.Errorf("Status = %q, want %q (different design = not duplicates)", check.Status, StatusOK)
|
||||||
@@ -200,7 +200,7 @@ func TestCheckDuplicateIssues_MixedOpenClosed(t *testing.T) {
|
|||||||
|
|
||||||
store.Close()
|
store.Close()
|
||||||
|
|
||||||
check := CheckDuplicateIssues(tmpDir)
|
check := CheckDuplicateIssues(tmpDir, false, 1000)
|
||||||
|
|
||||||
// Should detect 1 duplicate (the pair of open issues)
|
// Should detect 1 duplicate (the pair of open issues)
|
||||||
if check.Status != StatusWarning {
|
if check.Status != StatusWarning {
|
||||||
@@ -248,7 +248,7 @@ func TestCheckDuplicateIssues_TombstonesExcluded(t *testing.T) {
|
|||||||
|
|
||||||
store.Close()
|
store.Close()
|
||||||
|
|
||||||
check := CheckDuplicateIssues(tmpDir)
|
check := CheckDuplicateIssues(tmpDir, false, 1000)
|
||||||
|
|
||||||
if check.Status != StatusOK {
|
if check.Status != StatusOK {
|
||||||
t.Errorf("Status = %q, want %q (tombstones should be excluded)", check.Status, StatusOK)
|
t.Errorf("Status = %q, want %q (tombstones should be excluded)", check.Status, StatusOK)
|
||||||
@@ -265,7 +265,7 @@ func TestCheckDuplicateIssues_NoDatabase(t *testing.T) {
|
|||||||
|
|
||||||
// No database file created
|
// No database file created
|
||||||
|
|
||||||
check := CheckDuplicateIssues(tmpDir)
|
check := CheckDuplicateIssues(tmpDir, false, 1000)
|
||||||
|
|
||||||
if check.Status != StatusOK {
|
if check.Status != StatusOK {
|
||||||
t.Errorf("Status = %q, want %q", check.Status, StatusOK)
|
t.Errorf("Status = %q, want %q", check.Status, StatusOK)
|
||||||
@@ -274,3 +274,203 @@ func TestCheckDuplicateIssues_NoDatabase(t *testing.T) {
|
|||||||
t.Errorf("Message = %q, want 'N/A (no database)'", check.Message)
|
t.Errorf("Message = %q, want 'N/A (no database)'", check.Message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCheckDuplicateIssues_GastownUnderThreshold verifies that with gastown mode enabled,
|
||||||
|
// duplicates under the threshold are OK.
|
||||||
|
func TestCheckDuplicateIssues_GastownUnderThreshold(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.Mkdir(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
store, err := sqlite.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
// Initialize database with prefix
|
||||||
|
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to set issue_prefix: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 50 duplicate issues (typical gastown wisp count)
|
||||||
|
for i := 0; i < 51; i++ {
|
||||||
|
issue := &types.Issue{
|
||||||
|
Title: "Check own context limit",
|
||||||
|
Description: "Wisp for patrol cycle",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
}
|
||||||
|
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create issue: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
check := CheckDuplicateIssues(tmpDir, true, 1000)
|
||||||
|
|
||||||
|
// With gastown mode and threshold=1000, 50 duplicates should be OK
|
||||||
|
if check.Status != StatusOK {
|
||||||
|
t.Errorf("Status = %q, want %q (under gastown threshold)", check.Status, StatusOK)
|
||||||
|
t.Logf("Message: %s", check.Message)
|
||||||
|
}
|
||||||
|
if check.Message != "50 duplicate(s) detected (within gastown threshold of 1000)" {
|
||||||
|
t.Errorf("Message = %q, want message about being within threshold", check.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckDuplicateIssues_GastownOverThreshold verifies that with gastown mode enabled,
|
||||||
|
// duplicates over the threshold still warn.
|
||||||
|
func TestCheckDuplicateIssues_GastownOverThreshold(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.Mkdir(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
store, err := sqlite.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
// Initialize database with prefix
|
||||||
|
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to set issue_prefix: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 1500 duplicate issues (over threshold, indicates a problem)
|
||||||
|
for i := 0; i < 1501; i++ {
|
||||||
|
issue := &types.Issue{
|
||||||
|
Title: "Runaway wisps",
|
||||||
|
Description: "Too many wisps",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
}
|
||||||
|
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create issue: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
check := CheckDuplicateIssues(tmpDir, true, 1000)
|
||||||
|
|
||||||
|
// With gastown mode and threshold=1000, 1500 duplicates should warn
|
||||||
|
if check.Status != StatusWarning {
|
||||||
|
t.Errorf("Status = %q, want %q (over gastown threshold)", check.Status, StatusWarning)
|
||||||
|
}
|
||||||
|
if check.Message != "1500 duplicate issue(s) in 1 group(s)" {
|
||||||
|
t.Errorf("Message = %q, want '1500 duplicate issue(s) in 1 group(s)'", check.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckDuplicateIssues_GastownCustomThreshold verifies custom threshold works.
|
||||||
|
func TestCheckDuplicateIssues_GastownCustomThreshold(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.Mkdir(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
store, err := sqlite.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
// Initialize database with prefix
|
||||||
|
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to set issue_prefix: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 500 duplicate issues
|
||||||
|
for i := 0; i < 501; i++ {
|
||||||
|
issue := &types.Issue{
|
||||||
|
Title: "Custom threshold test",
|
||||||
|
Description: "Test custom threshold",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
}
|
||||||
|
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create issue: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
// With custom threshold of 250, 500 duplicates should warn
|
||||||
|
check := CheckDuplicateIssues(tmpDir, true, 250)
|
||||||
|
|
||||||
|
if check.Status != StatusWarning {
|
||||||
|
t.Errorf("Status = %q, want %q (over custom threshold of 250)", check.Status, StatusWarning)
|
||||||
|
}
|
||||||
|
if check.Message != "500 duplicate issue(s) in 1 group(s)" {
|
||||||
|
t.Errorf("Message = %q, want '500 duplicate issue(s) in 1 group(s)'", check.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckDuplicateIssues_NonGastownMode verifies that without gastown mode,
|
||||||
|
// any duplicates are warnings (backward compatibility).
|
||||||
|
func TestCheckDuplicateIssues_NonGastownMode(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.Mkdir(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
store, err := sqlite.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
// Initialize database with prefix
|
||||||
|
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to set issue_prefix: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 50 duplicate issues
|
||||||
|
for i := 0; i < 51; i++ {
|
||||||
|
issue := &types.Issue{
|
||||||
|
Title: "Duplicate task",
|
||||||
|
Description: "Some task",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
}
|
||||||
|
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create issue: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
// Without gastown mode, even 50 duplicates should warn
|
||||||
|
check := CheckDuplicateIssues(tmpDir, false, 1000)
|
||||||
|
|
||||||
|
if check.Status != StatusWarning {
|
||||||
|
t.Errorf("Status = %q, want %q (non-gastown should warn on any duplicates)", check.Status, StatusWarning)
|
||||||
|
}
|
||||||
|
if check.Message != "50 duplicate issue(s) in 1 group(s)" {
|
||||||
|
t.Errorf("Message = %q, want '50 duplicate issue(s) in 1 group(s)'", check.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user