Skip to content

Commit 87f3286

Browse files
authored
feat: support issue_id_mode=counter for sequential IDs (#2013)
* feat: support issue_id_mode=counter for sequential IDs (GH#2002) When `issue_id_mode=counter` is set via `bd config set issue_id_mode counter`, `bd create` now assigns monotonically increasing integer IDs instead of hash-based IDs. The counter is stored in a new `issue_counter` table (one row per prefix) and incremented atomically within the same transaction as the issue insert. - Add `issue_counter` table to schema (version bumped to 5) - Add migration 006 to create `issue_counter` table on existing databases - Add `GetNextIssueCounter()` to queries.go (standalone transaction, for callers outside CreateIssue) - Modify `generateIssueID()` to check `issue_id_mode` config and use counter path when set to "counter"; hash mode remains the default when unset - Explicit `--id` flag continues to take precedence over counter mode - Tests: TestGetNextIssueCounter_Sequential, TestGetNextIssueCounter_MultiplePrefixes, TestCreateIssue_CounterMode, TestCreateIssue_ExplicitIDOverridesCounter, TestCreateIssue_HashModeDefault, TestMigrateIssueCounterTable * docs: document issue_id_mode=counter feature (GH#2002) Add documentation for the sequential counter ID mode across all relevant docs: - docs/CONFIG.md: new issue_id_mode namespace entry and full example section with tradeoff table, migration guidance, and per-prefix counter isolation - .beads/BD_GUIDE.md: counter mode section with when-to-use, migration considerations, and explicit --id override behavior - docs/ADAPTIVE_IDS.md: alternative counter mode section with cross-reference to CONFIG.md - website/docs/reference/configuration.md: issue_id_mode under ID Generation with comparison table * fix: seed counter from existing issues when enabling counter mode (GH#2002) When issue_id_mode=counter is enabled on a repo that already has manually-created sequential IDs (e.g., plug-1 through plug-50), the counter used to start at 1, causing immediate collisions. Add seedCounterFromExistingIssuesTx() which scans existing issue IDs, finds the highest numeric suffix for the given prefix, and seeds the issue_counter table from there. The function is idempotent (skips if a counter row already exists) and only counts purely-numeric suffixes (ignoring hash-based IDs like test-a3f2). The seeding is called at first counter use: in generateIssueID() (issues.go) and GetNextIssueCounter() (queries.go) when sql.ErrNoRows is returned for the prefix. Add four tests covering: seeding from existing numeric IDs, mixed hash+numeric IDs, fresh repos (counter starts at 1), and already-seeded counters (no regress). * feat: add bd config schema and describe commands for agent discoverability (GH#2002) Adds machine-readable config schema so agents can programmatically discover all available configuration keys without reading source code. - internal/config/schema.go: new ConfigKeyDef struct and Schema slice with 43 entries covering core, sync, routing, jira, linear, gitlab, team, mail, and YAML-only keys; includes type, default, valid values, description, and storage location per key - cmd/bd/config.go: adds 'bd config schema' (table + --json) and 'bd config describe <key>' (single-key detail + --json) subcommands Usage: bd config schema bd config schema --json | jq '.[] | select(.key == "issue_id_mode")' bd config describe issue_id_mode bd config describe jira.url --json * refactor: remove overengineered config schema (keep docs only) Remove the configSchemaCmd and configDescribeCmd subcommands added in the previous commit, along with internal/config/schema.go (390 lines of config key definitions). These are out of scope for GH#2002.
1 parent befc254 commit 87f3286

File tree

11 files changed

+751
-3
lines changed

11 files changed

+751
-3
lines changed

.beads/BD_GUIDE.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,60 @@ history/
132132
- ✅ Preserves planning history for archeological research
133133
- ✅ Reduces noise when browsing the project
134134

135+
### Counter Mode (Sequential IDs)
136+
137+
By default, beads assigns hash-based IDs (e.g., `bd-a3f2`). For projects that prefer
138+
human-readable sequential IDs (e.g., `bd-1`, `bd-2`), enable counter mode:
139+
140+
```bash
141+
bd config set issue_id_mode counter
142+
```
143+
144+
**When to use counter mode:**
145+
146+
- Project-management workflows where stakeholders reference issue numbers in conversations
147+
- Multi-agent coordination where readable IDs reduce confusion (e.g., "fix bd-42")
148+
- Teams migrating from Jira/Linear/GitHub Issues that expect sequential numbering
149+
150+
**When to keep hash IDs (default):**
151+
152+
- Multi-agent or multi-branch workflows where issues may be created concurrently on different branches
153+
- Hash IDs are collision-free by construction; counter IDs can diverge if parallel branches both create issues
154+
155+
**How to enable:**
156+
157+
```bash
158+
# Enable for this project
159+
bd config set issue_id_mode counter
160+
161+
# New issues now get sequential IDs
162+
bd create "Fix login bug" -p 1 # → bd-1
163+
bd create "Add dark mode" -p 2 # → bd-2
164+
```
165+
166+
**Migration considerations:**
167+
168+
If the repo already has hash-based IDs, those existing IDs are unchanged. New issues created
169+
after enabling counter mode will start from 1 (or wherever the counter currently sits). To
170+
avoid collisions with any existing sequential IDs (e.g., from a previous counter-mode period),
171+
check the highest integer ID in use before switching.
172+
173+
**Explicit --id overrides counter mode:**
174+
175+
Passing `--id` on `bd create` always uses the provided ID and does not increment the counter:
176+
177+
```bash
178+
bd create "Backport fix" -p 1 --id bd-special
179+
# → bd-special (counter unchanged)
180+
```
181+
182+
**Per-prefix isolation:**
183+
184+
Each prefix has its own counter. If this project routes to multiple prefixes, each prefix
185+
counts independently (e.g., `bd-1`, `bd-2` and `plug-1`, `plug-2` are separate sequences).
186+
187+
See [docs/CONFIG.md](../docs/CONFIG.md) for full `issue_id_mode` reference.
188+
135189
### Important Rules
136190

137191
- ✅ Use bd for ALL task tracking

docs/ADAPTIVE_IDS.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,29 @@ Potential improvements (not yet implemented):
192192
- **Dynamic adjustment**: Auto-adjust threshold based on observed collision rate
193193
- **Compaction-aware**: Don't count compacted issues in collision calculation
194194

195+
## Alternative: Sequential Counter IDs
196+
197+
Adaptive hash IDs are the default, but beads also supports sequential integer IDs
198+
(`bd-1`, `bd-2`, ...) for projects that prefer human-readable numbering.
199+
200+
Counter mode is controlled by the `issue_id_mode` config key:
201+
202+
```bash
203+
# Switch to sequential IDs
204+
bd config set issue_id_mode counter
205+
206+
# Revert to hash IDs (default)
207+
bd config set issue_id_mode hash
208+
```
209+
210+
**Tradeoff:**
211+
212+
- **Hash IDs** (this document): Collision-free across parallel branches and agents; IDs are less predictable but always unique.
213+
- **Counter IDs**: Human-friendly and sequential; require care in multi-branch workflows where counters can diverge.
214+
215+
See [CONFIG.md](CONFIG.md) for full documentation on `issue_id_mode=counter`, including migration
216+
guidance and per-prefix counter isolation.
217+
195218
## Related
196219

197220
- [Migration Guide](../README.md#migration) - Converting from sequential to hash IDs

docs/CONFIG.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ Configuration keys use dot-notation namespaces to organize settings:
311311

312312
- `compact_*` - Compaction settings (see EXTENDING.md)
313313
- `issue_prefix` - Issue ID prefix (managed by `bd init`)
314+
- `issue_id_mode` - ID generation mode: `hash` (default) or `counter` (sequential integers)
314315
- `max_collision_prob` - Maximum collision probability for adaptive hash IDs (default: 0.25)
315316
- `min_hash_length` - Minimum hash ID length (default: 4)
316317
- `max_hash_length` - Maximum hash ID length (default: 8)
@@ -333,6 +334,83 @@ Use these namespaces for external integrations:
333334
- `github.*` - GitHub integration settings
334335
- `custom.*` - Custom integration settings
335336

337+
### Example: Sequential Counter IDs (issue_id_mode=counter)
338+
339+
By default, beads generates hash-based IDs (e.g., `bd-a3f2`, `bd-7f3a8`). For projects that prefer
340+
short sequential IDs (e.g., `bd-1`, `bd-2`, `bd-3`), enable counter mode:
341+
342+
```bash
343+
bd config set issue_id_mode counter
344+
```
345+
346+
**Valid values:**
347+
348+
| Value | Behavior |
349+
|-------|----------|
350+
| `hash` | (default) Hash-based IDs, adaptive length, collision-safe |
351+
| `counter` | Sequential integers per prefix: `bd-1`, `bd-2`, `bd-3`, ... |
352+
353+
**Counter mode behavior:**
354+
- Each prefix (`bd`, `plug`, etc.) has its own independent counter
355+
- Counter is stored atomically in the database; concurrent creates within a single Dolt session are safe
356+
- Explicit `--id` flag always overrides counter mode (the counter is not incremented)
357+
358+
**Enabling counter mode:**
359+
360+
```bash
361+
bd config set issue_id_mode counter
362+
363+
# Now new issues get sequential IDs
364+
bd create "First issue" -p 1
365+
# → bd-1
366+
367+
bd create "Second issue" -p 2
368+
# → bd-2
369+
```
370+
371+
**Migration warning:** If you switch an existing repository to counter mode, seed the counter
372+
to avoid collisions with existing IDs. Find your highest current integer ID and set the counter
373+
accordingly:
374+
375+
```bash
376+
# Check your highest existing sequential ID (if any)
377+
bd list --json | jq -r '.[].id' | grep -E '^bd-[0-9]+$' | sort -t- -k2 -n | tail -1
378+
379+
# Seed the counter (e.g., if highest existing ID is bd-42)
380+
bd config set issue_id_mode counter
381+
# The counter auto-initializes at 0; new issues start at 1
382+
# If you already have bd-1 through bd-42, manually set counter:
383+
# (no direct CLI for seeding — use bd dolt sql or create/delete N issues)
384+
```
385+
386+
For fresh repositories switching to counter mode before any issues exist, no seeding is needed.
387+
388+
**Per-prefix counter isolation:**
389+
390+
Each issue prefix maintains its own counter independently. In multi-repo or routed setups,
391+
`bd-*` issues and `plug-*` issues each start at 1:
392+
393+
```bash
394+
# Prefix "bd" and prefix "plug" have independent counters
395+
bd create "Core task" -p 1 # → bd-1
396+
bd create "Plugin task" -p 1 # → plug-1 (if prefix is "plug")
397+
```
398+
399+
**Tradeoff — hash vs. counter:**
400+
401+
| | Hash IDs | Counter IDs |
402+
|---|---|---|
403+
| Human readability | Lower (e.g., `bd-a3f2`) | Higher (e.g., `bd-1`) |
404+
| Distributed/concurrent safety | Excellent (collision-free across branches) | Needs care (counters can diverge on parallel branches) |
405+
| Predictability | Unpredictable | Sequential |
406+
| Best for | Multi-agent, multi-branch workflows | Single-writer or project-management UIs |
407+
408+
Counter IDs are well-suited for linear project-management workflows and human-facing issue tracking.
409+
Hash IDs are safer when multiple agents or branches create issues concurrently, since each hash is
410+
independently unique without coordination.
411+
412+
See [ADAPTIVE_IDS.md](ADAPTIVE_IDS.md) for full documentation on hash-based ID generation.
413+
336414
### Example: Adaptive Hash ID Configuration
337415

338416
```bash

internal/storage/dolt/issues.go

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,9 +1084,115 @@ func recordEvent(ctx context.Context, tx *sql.Tx, issueID string, eventType type
10841084
return err
10851085
}
10861086

1087-
// generateIssueID generates a unique hash-based ID for an issue
1088-
// Uses adaptive length based on database size and tries multiple nonces on collision
1087+
// seedCounterFromExistingIssuesTx scans existing issues to find the highest numeric suffix
1088+
// for the given prefix, then seeds the issue_counter table if no row exists yet.
1089+
// This is called when counter mode is first enabled on a repo that already has issues,
1090+
// to prevent counter collisions with manually-created sequential IDs (GH#2002).
1091+
// It is idempotent: if a counter row already exists for this prefix, it does nothing.
1092+
func seedCounterFromExistingIssuesTx(ctx context.Context, tx *sql.Tx, prefix string) error {
1093+
// Check whether a counter row already exists for this prefix.
1094+
// If it does, we must not overwrite it (the counter may already be in use).
1095+
var existing int
1096+
err := tx.QueryRowContext(ctx, "SELECT last_id FROM issue_counter WHERE prefix = ?", prefix).Scan(&existing)
1097+
if err == nil {
1098+
// Row exists - counter is already initialized, nothing to do.
1099+
return nil
1100+
}
1101+
if err != sql.ErrNoRows {
1102+
return fmt.Errorf("failed to check issue_counter for prefix %q: %w", prefix, err)
1103+
}
1104+
1105+
// No counter row yet. Scan existing issues to find the highest numeric suffix.
1106+
likePattern := prefix + "-%"
1107+
rows, err := tx.QueryContext(ctx, "SELECT id FROM issues WHERE id LIKE ?", likePattern)
1108+
if err != nil {
1109+
return fmt.Errorf("failed to query existing issues for prefix %q: %w", prefix, err)
1110+
}
1111+
defer rows.Close()
1112+
1113+
maxNum := 0
1114+
prefixDash := prefix + "-"
1115+
for rows.Next() {
1116+
var id string
1117+
if err := rows.Scan(&id); err != nil {
1118+
return fmt.Errorf("failed to scan issue id: %w", err)
1119+
}
1120+
// Strip the prefix and attempt to parse the remainder as an integer.
1121+
suffix := strings.TrimPrefix(id, prefixDash)
1122+
if suffix == id {
1123+
// id did not start with prefix- (should not happen given LIKE, but be safe)
1124+
continue
1125+
}
1126+
var num int
1127+
if _, parseErr := fmt.Sscanf(suffix, "%d", &num); parseErr == nil && fmt.Sprintf("%d", num) == suffix {
1128+
if num > maxNum {
1129+
maxNum = num
1130+
}
1131+
}
1132+
}
1133+
if err := rows.Err(); err != nil {
1134+
return fmt.Errorf("failed to iterate existing issues for prefix %q: %w", prefix, err)
1135+
}
1136+
1137+
// Only insert a seed row if we found at least one numeric ID.
1138+
// If no numeric IDs exist, the counter will naturally start at 1 on first use.
1139+
if maxNum > 0 {
1140+
_, err = tx.ExecContext(ctx,
1141+
"INSERT INTO issue_counter (prefix, last_id) VALUES (?, ?)",
1142+
prefix, maxNum)
1143+
if err != nil {
1144+
return fmt.Errorf("failed to seed issue_counter for prefix %q at %d: %w", prefix, maxNum, err)
1145+
}
1146+
}
1147+
1148+
return nil
1149+
}
1150+
1151+
// generateIssueID generates a unique ID for an issue.
1152+
// If issue_id_mode=counter is configured, generates sequential IDs (bd-1, bd-2, ...).
1153+
// Otherwise uses the default hash-based ID generation.
10891154
func generateIssueID(ctx context.Context, tx *sql.Tx, prefix string, issue *types.Issue, actor string) (string, error) {
1155+
// Check issue_id_mode config (within the current transaction)
1156+
var idMode string
1157+
err := tx.QueryRowContext(ctx, "SELECT value FROM config WHERE `key` = ?", "issue_id_mode").Scan(&idMode)
1158+
if err != nil && err != sql.ErrNoRows {
1159+
return "", fmt.Errorf("failed to read issue_id_mode config: %w", err)
1160+
}
1161+
1162+
if idMode == "counter" {
1163+
// Sequential counter mode: increment atomically within this transaction.
1164+
// If no counter row exists yet, seed from existing issues first to avoid
1165+
// collisions with manually-created sequential IDs (GH#2002).
1166+
var lastID int
1167+
err2 := tx.QueryRowContext(ctx, "SELECT last_id FROM issue_counter WHERE prefix = ?", prefix).Scan(&lastID)
1168+
if err2 == sql.ErrNoRows {
1169+
// No counter row yet - seed from existing issues before proceeding.
1170+
if seedErr := seedCounterFromExistingIssuesTx(ctx, tx, prefix); seedErr != nil {
1171+
return "", fmt.Errorf("failed to seed issue counter for prefix %q: %w", prefix, seedErr)
1172+
}
1173+
// Re-read the (possibly just-seeded) counter value.
1174+
err2 = tx.QueryRowContext(ctx, "SELECT last_id FROM issue_counter WHERE prefix = ?", prefix).Scan(&lastID)
1175+
if err2 != nil && err2 != sql.ErrNoRows {
1176+
return "", fmt.Errorf("failed to read issue counter after seeding for prefix %q: %w", prefix, err2)
1177+
}
1178+
if err2 == sql.ErrNoRows {
1179+
lastID = 0
1180+
}
1181+
} else if err2 != nil {
1182+
return "", fmt.Errorf("failed to read issue counter for prefix %q: %w", prefix, err2)
1183+
}
1184+
nextID := lastID + 1
1185+
_, err3 := tx.ExecContext(ctx, `
1186+
INSERT INTO issue_counter (prefix, last_id) VALUES (?, ?)
1187+
ON DUPLICATE KEY UPDATE last_id = ?
1188+
`, prefix, nextID, nextID)
1189+
if err3 != nil {
1190+
return "", fmt.Errorf("failed to update issue counter for prefix %q: %w", prefix, err3)
1191+
}
1192+
return fmt.Sprintf("%s-%d", prefix, nextID), nil
1193+
}
1194+
1195+
// Default hash-based ID generation
10901196
// Get adaptive base length based on current database size
10911197
baseLength, err := GetAdaptiveIDLengthTx(ctx, tx, prefix)
10921198
if err != nil {

internal/storage/dolt/migrations.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ var migrationsList = []Migration{
2424
{"orphan_detection", migrations.DetectOrphanedChildren},
2525
{"wisps_table", migrations.MigrateWispsTable},
2626
{"wisp_auxiliary_tables", migrations.MigrateWispAuxiliaryTables},
27+
{"issue_counter_table", migrations.MigrateIssueCounterTable},
2728
}
2829

2930
// RunMigrations executes all registered Dolt migrations in order.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package migrations
2+
3+
import (
4+
"database/sql"
5+
"fmt"
6+
)
7+
8+
// MigrateIssueCounterTable creates the issue_counter table used for
9+
// sequential issue ID generation when issue_id_mode=counter is configured.
10+
// The table stores one row per prefix, tracking the last assigned integer.
11+
func MigrateIssueCounterTable(db *sql.DB) error {
12+
exists, err := tableExists(db, "issue_counter")
13+
if err != nil {
14+
return fmt.Errorf("failed to check issue_counter existence: %w", err)
15+
}
16+
if exists {
17+
return nil
18+
}
19+
20+
_, err = db.Exec(`CREATE TABLE issue_counter (
21+
prefix VARCHAR(255) PRIMARY KEY,
22+
last_id INT NOT NULL DEFAULT 0
23+
)`)
24+
if err != nil {
25+
return fmt.Errorf("failed to create issue_counter table: %w", err)
26+
}
27+
28+
return nil
29+
}

internal/storage/dolt/migrations/migrations_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,50 @@ func TestMigrateWispsTable(t *testing.T) {
291291
t.Fatalf("expected title 'Test Wisp', got %q", title)
292292
}
293293
}
294+
295+
func TestMigrateIssueCounterTable(t *testing.T) {
296+
db := openTestDolt(t)
297+
298+
// Verify issue_counter table does not exist yet
299+
exists, err := tableExists(db, "issue_counter")
300+
if err != nil {
301+
t.Fatalf("failed to check table: %v", err)
302+
}
303+
if exists {
304+
t.Fatal("issue_counter should not exist yet")
305+
}
306+
307+
// Run migration
308+
if err := MigrateIssueCounterTable(db); err != nil {
309+
t.Fatalf("migration failed: %v", err)
310+
}
311+
312+
// Verify issue_counter table now exists
313+
exists, err = tableExists(db, "issue_counter")
314+
if err != nil {
315+
t.Fatalf("failed to check table after migration: %v", err)
316+
}
317+
if !exists {
318+
t.Fatal("issue_counter should exist after migration")
319+
}
320+
321+
// Run migration again (idempotent)
322+
if err := MigrateIssueCounterTable(db); err != nil {
323+
t.Fatalf("re-running migration should be idempotent: %v", err)
324+
}
325+
326+
// Verify we can INSERT and query from issue_counter
327+
_, err = db.Exec("INSERT INTO issue_counter (prefix, last_id) VALUES ('bd', 5)")
328+
if err != nil {
329+
t.Fatalf("failed to insert into issue_counter: %v", err)
330+
}
331+
332+
var lastID int
333+
err = db.QueryRow("SELECT last_id FROM issue_counter WHERE prefix = 'bd'").Scan(&lastID)
334+
if err != nil {
335+
t.Fatalf("failed to query issue_counter: %v", err)
336+
}
337+
if lastID != 5 {
338+
t.Errorf("expected last_id 5, got %d", lastID)
339+
}
340+
}

0 commit comments

Comments
 (0)