Skip to content

fix: custom status validation falls back to config when table is empty#2985

Closed
popandpeek wants to merge 1 commit into
gastownhall:mainfrom
popandpeek:fix/custom-status-fallback
Closed

fix: custom status validation falls back to config when table is empty#2985
popandpeek wants to merge 1 commit into
gastownhall:mainfrom
popandpeek:fix/custom-status-fallback

Conversation

@popandpeek

Copy link
Copy Markdown

Summary

Fixes #2984

  • ResolveCustomStatusesDetailedInTx now falls through to the status.custom config string when the custom_statuses table exists but is empty (e.g. after schema v11 migration)
  • list.go now logs GetCustomStatuses errors instead of silently discarding them, matching update.go's pattern

Root Cause

Schema v11 migration creates the custom_statuses table but doesn't populate it from the existing status.custom config value. The query function returned empty when the table existed (even with no rows), bypassing the config string fallback.

Test Plan

  • bd list --status=in_review now works when status.custom includes in_review but custom_statuses table is empty
  • bd config get status.custom correctly returns configured values
  • Build passes (make build)
  • Existing tests pass

Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com

…es are empty

ResolveCustomStatusesDetailedInTx and ResolveCustomTypesInTx returned
empty results when the custom_statuses/custom_types tables existed but
had no rows (e.g. after schema v11 migration created the tables without
populating them from the existing status.custom/types.custom config).

This caused `bd list --status=<custom>` and `bd update --type=<custom>`
to reject valid custom values with "invalid status/type" errors.

Fix: fall through to the config string fallback when table queries
succeed but return zero rows, matching the pre-migration behavior.

Also fix list.go to log GetCustomStatuses errors instead of silently
discarding them (matching update.go's pattern).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@popandpeek popandpeek force-pushed the fix/custom-status-fallback branch from aff4c0a to e2a9dbc Compare April 3, 2026 15:29

@hilmes hilmes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix: custom status validation falls back to config when table is empty

Verdict: Correct fix for schema migration edge case. Two components working in concert: fallback logic in config helpers + error handling in list.go. One observation on test coverage.

+16 −6 across 2 files (cmd/bd/list.go +6, internal/storage/issueops/config_helpers.go +10).


Root Cause: Schema v11 Migration Gap ✅

After schema v11 migration, the custom_statuses and custom_types tables are created but not populated from the existing config values (status.custom / types.custom).

Old code logic:

  1. Query table
  2. If query succeeds (table exists), return result even if empty → stops here
  3. If query fails (table doesn't exist), fall back to config string

Problem: Step 2 returns empty array, bypassing the config fallback. Users with pre-migration status.custom configs lose access to their custom statuses after v11 because the table is empty and the fallback is skipped.

Fix: Two-Layer Approach ✅

Layer 1 (config_helpers.go): Modified fallback logic in ResolveCustomStatusesDetailedInTx and ResolveCustomTypesInTx:

-// Table query succeeded — return result even if empty.
-return result, nil

+if len(result) > 0 {
+    return result, nil
+}
+// Table exists but is empty — fall through to config string.

This shifts the fallback condition from "table exists?" to "table has rows?" Good semantic fix.

Layer 2 (list.go): Previously, GetCustomStatuses errors were silently swallowed:

-cs, _ := store.GetCustomStatuses(rootCtx)
-customStatuses = cs

Now errors are logged (except in JSON output):

+cs, err := store.GetCustomStatuses(rootCtx)
+if err != nil {
+    if !jsonOutput {
+        fmt.Fprintf(os.Stderr, "⚠ Failed to load custom statuses: %v\n", err)
+    }
+} else {
+    customStatuses = cs
+}

This matches the error handling pattern in update.go (per PR description). Good consistency.


Correctness: Precedence and Fallbacks ✅

After the fix, the resolution precedence is:

  1. custom_statuses table (if has rows)
  2. status.custom config string (if table empty or doesn't exist)
  3. YAML config fallback (via GetCustomStatusesFromYAML())

Same logic for custom_types:

  1. custom_types table (if has rows)
  2. types.custom config string (if table empty or doesn't exist)
  3. YAML config fallback (via GetCustomTypesFromYAML())

This is correct. The migration gap is bridged: users with pre-v11 status.custom configs will still work after v11 if the table is empty.

Edge Cases ✅

Case 1: Fully migrated (table populated)

  • Query returns rows
  • len(result) > 0 → return table result
  • Config fallback skipped ✓

Case 2: Post-migration, table empty (THE FIX)

  • Query succeeds but returns no rows
  • len(result) > 0 is false → fall through to config
  • Config string is parsed and returned ✓

Case 3: Pre-migration (table doesn't exist)

  • Query fails (QueryContext error)
  • Falls through to config string immediately ✓

Case 4: All paths empty/nil

  • Table empty, config empty, YAML empty
  • Returns nil (safe, no custom statuses defined) ✓

Error Handling: list.go Changes ✅

The error logging is conditional:

if err != nil {
    if !jsonOutput {
        fmt.Fprintf(os.Stderr, "⚠ Failed to load custom statuses: %v\n", err)
    }
}

This respects JSON output mode (doesn't emit stderr in JSON mode). Good. The error is logged but not fatal — the command continues with customStatuses = [] (nil slice). This is safe: list will show issues without custom status filtering/validation, but won't crash.

Test Coverage: Partial ✅

PR test plan shows:

  • bd list --status=in_review works (manual test)
  • bd config get status.custom returns values (manual test)
  • ✅ Build passes
  • ❌ "Existing tests pass" is marked incomplete

The fix changes core fallback logic in two functions (ResolveCustomStatusesDetailedInTx, ResolveCustomTypesInTx). These functions likely have existing unit tests in config_helpers_test.go or similar. Running existing tests is critical before merging — the two-layer fix could have subtle interactions.


🟡 Empty slice vs nil return semantics

The functions return different types in different paths:

  • Table has rows: returns []types.CustomStatus (non-nil slice with len > 0)
  • Table empty or doesn't exist: returns fallback result (could be nil, empty slice, or populated)
  • All empty: returns nil

This is acceptable (Go convention), but means callers need to handle both len(statuses) == 0 (empty slice) and statuses == nil (nil slice). The list.go code uses len(customStatuses) == 1 (single status) logic, which handles both correctly. But worth noting for future callers.

🟡 Error message in list.go uses generic format

fmt.Fprintf(os.Stderr, "⚠ Failed to load custom statuses: %v\n", err)

The error err comes from store.GetCustomStatuses(), which wraps underlying SQL/storage errors. For user clarity, could be more specific (e.g., "Failed to load custom statuses from database"). But the current message is acceptable and matches update.go pattern.


Summary

Component Verdict Notes
Root cause analysis Schema v11 gap correctly identified
Fallback logic fix "Table empty" → "fall through" is correct
Error handling in list.go Matches update.go pattern, JSON-aware
Edge cases (pre/post-migration) All four scenarios handled correctly
Precedence (table→config→YAML) Clear and correct
Test coverage 🟡 Manual tests good, but existing tests not yet run
Nil vs empty slice semantics 🟡 Works, but implicit contract for callers

LGTM. This is a focused, correct fix for a real schema migration edge case. The two-layer approach (config helper + error logging) is sound. The only blocker is confirming existing tests pass — the PR test plan marks that as incomplete. Once confirmed, this is ready to ship.

@maphew maphew left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. The empty-table fallback to config is correct. One suggested improvement from rebasing: in list.go, wrap the stderr warning in if !jsonOutput so JSON consumers don't get warning noise mixed into structured output. This guard was missing from HEAD and combining both makes the final result cleaner. Otherwise LGTM. ✓

@maphew

maphew commented Apr 6, 2026

Copy link
Copy Markdown
Collaborator

Thank you for tracking down this migration edge-case! The empty-table fallback to config is exactly right. I've resolved the conflicts with upstream/main: kept HEAD's comment wording in config_helpers.go (same logic), and in list.go combined HEAD's ui.RenderWarn style with your !jsonOutput guard — the guard is a genuine improvement that suppresses warning noise for JSON consumers and I wanted to make sure it lands. Ready to merge.

maphew added a commit that referenced this pull request Apr 6, 2026
#2985)

Fixes migration edge-case: when custom_statuses table exists but is empty, validation now falls back to config-defined statuses. Conflicts resolved; !jsonOutput guard added to suppress warning noise in JSON mode.
@maphew

maphew commented Apr 6, 2026

Copy link
Copy Markdown
Collaborator

Merged via conflict-resolution fixup branch fix/custom-status-conflict-2985 — merge commit 8710636 on gastownhall/beads main.

@maphew maphew closed this Apr 6, 2026
maphew added a commit that referenced this pull request Apr 6, 2026
#2985)

Fixes migration edge-case: when custom_statuses table exists but is empty, validation now falls back to config-defined statuses. Conflicts resolved; !jsonOutput guard added to suppress warning noise in JSON mode.

Co-authored-by: mayor <ben@popandpeek.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bd list --status rejects custom statuses when custom_statuses table is empty

3 participants