Switch from hex to Base36 encoding for issue IDs (GH #213)
This change improves information density by using Base36 (0-9, a-z) instead of hex (0-9, a-f) for hash-based issue IDs. Key benefits: - Shorter IDs: Can now use 3-char IDs (was 4-char minimum) - Better scaling: 3 chars good for ~160 issues, 4 chars for ~980 issues - Case-insensitive: Maintains excellent CLI usability - Backward compatible: Old hex IDs continue to work Changes: - Implemented Base36 encoding with proper truncation (keep LSB) - Updated adaptive length thresholds (3-8 chars instead of 4-8) - Fixed collision probability math to match encoding (was calculating for base36 but encoding in hex - now both use base36) - Fixed ID parser bug (use prefixWithHyphen for substring matching) - Updated all tests and test data patterns Fixes #213 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2
cmd/bd/testdata/blocked.txt
vendored
2
cmd/bd/testdata/blocked.txt
vendored
@@ -4,7 +4,7 @@ bd init --prefix test
|
|||||||
# Create first issue
|
# Create first issue
|
||||||
bd create 'First issue'
|
bd create 'First issue'
|
||||||
cp stdout first.txt
|
cp stdout first.txt
|
||||||
exec sh -c 'grep -oE "test-[a-f0-9]+" first.txt > first_id.txt'
|
exec sh -c 'grep -oE "test-[a-z0-9]+" first.txt > first_id.txt'
|
||||||
|
|
||||||
# Create second issue that depends on first
|
# Create second issue that depends on first
|
||||||
exec sh -c 'bd create "Second issue" --deps $(cat first_id.txt)'
|
exec sh -c 'bd create "Second issue" --deps $(cat first_id.txt)'
|
||||||
|
|||||||
2
cmd/bd/testdata/close.txt
vendored
2
cmd/bd/testdata/close.txt
vendored
@@ -4,7 +4,7 @@ bd init --prefix test
|
|||||||
# Create issue and capture its hash ID
|
# Create issue and capture its hash ID
|
||||||
bd create 'Issue to close'
|
bd create 'Issue to close'
|
||||||
cp stdout issue.txt
|
cp stdout issue.txt
|
||||||
exec sh -c 'grep -oE "test-[a-f0-9]+" issue.txt > issue_id.txt'
|
exec sh -c 'grep -oE "test-[a-z0-9]+" issue.txt > issue_id.txt'
|
||||||
|
|
||||||
# Close the issue
|
# Close the issue
|
||||||
exec sh -c 'bd close $(cat issue_id.txt) --reason Fixed'
|
exec sh -c 'bd close $(cat issue_id.txt) --reason Fixed'
|
||||||
|
|||||||
4
cmd/bd/testdata/dep_add.txt
vendored
4
cmd/bd/testdata/dep_add.txt
vendored
@@ -11,8 +11,8 @@ cp stdout second.txt
|
|||||||
grep 'Created issue: test-' second.txt
|
grep 'Created issue: test-' second.txt
|
||||||
|
|
||||||
# Extract IDs using grep (hash IDs are test-XXXXXXXX format)
|
# Extract IDs using grep (hash IDs are test-XXXXXXXX format)
|
||||||
exec sh -c 'grep -oE "test-[a-f0-9]+" first.txt > first_id.txt'
|
exec sh -c 'grep -oE "test-[a-z0-9]+" first.txt > first_id.txt'
|
||||||
exec sh -c 'grep -oE "test-[a-f0-9]+" second.txt > second_id.txt'
|
exec sh -c 'grep -oE "test-[a-z0-9]+" second.txt > second_id.txt'
|
||||||
|
|
||||||
# Add dependency: second depends on first
|
# Add dependency: second depends on first
|
||||||
exec sh -c 'bd dep add $(cat second_id.txt) $(cat first_id.txt)'
|
exec sh -c 'bd dep add $(cat second_id.txt) $(cat first_id.txt)'
|
||||||
|
|||||||
4
cmd/bd/testdata/dep_remove.txt
vendored
4
cmd/bd/testdata/dep_remove.txt
vendored
@@ -4,11 +4,11 @@ bd init --prefix test
|
|||||||
# Create issues and capture their hash IDs
|
# Create issues and capture their hash IDs
|
||||||
bd create 'First issue'
|
bd create 'First issue'
|
||||||
cp stdout first.txt
|
cp stdout first.txt
|
||||||
exec sh -c 'grep -oE "test-[a-f0-9]+" first.txt > first_id.txt'
|
exec sh -c 'grep -oE "test-[a-z0-9]+" first.txt > first_id.txt'
|
||||||
|
|
||||||
bd create 'Second issue'
|
bd create 'Second issue'
|
||||||
cp stdout second.txt
|
cp stdout second.txt
|
||||||
exec sh -c 'grep -oE "test-[a-f0-9]+" second.txt > second_id.txt'
|
exec sh -c 'grep -oE "test-[a-z0-9]+" second.txt > second_id.txt'
|
||||||
|
|
||||||
# Add dependency
|
# Add dependency
|
||||||
exec sh -c 'bd dep add $(cat second_id.txt) $(cat first_id.txt)'
|
exec sh -c 'bd dep add $(cat second_id.txt) $(cat first_id.txt)'
|
||||||
|
|||||||
4
cmd/bd/testdata/dep_tree.txt
vendored
4
cmd/bd/testdata/dep_tree.txt
vendored
@@ -4,11 +4,11 @@ bd init --prefix test
|
|||||||
# Create issues and capture their hash IDs
|
# Create issues and capture their hash IDs
|
||||||
bd create 'Root issue'
|
bd create 'Root issue'
|
||||||
cp stdout root.txt
|
cp stdout root.txt
|
||||||
exec sh -c 'grep -oE "test-[a-f0-9]+" root.txt > root_id.txt'
|
exec sh -c 'grep -oE "test-[a-z0-9]+" root.txt > root_id.txt'
|
||||||
|
|
||||||
bd create 'Child issue'
|
bd create 'Child issue'
|
||||||
cp stdout child.txt
|
cp stdout child.txt
|
||||||
exec sh -c 'grep -oE "test-[a-f0-9]+" child.txt > child_id.txt'
|
exec sh -c 'grep -oE "test-[a-z0-9]+" child.txt > child_id.txt'
|
||||||
|
|
||||||
# Add dependency: child depends on root
|
# Add dependency: child depends on root
|
||||||
exec sh -c 'bd dep add $(cat child_id.txt) $(cat root_id.txt)'
|
exec sh -c 'bd dep add $(cat child_id.txt) $(cat root_id.txt)'
|
||||||
|
|||||||
2
cmd/bd/testdata/show.txt
vendored
2
cmd/bd/testdata/show.txt
vendored
@@ -7,7 +7,7 @@ cp stdout issue.txt
|
|||||||
grep 'Created issue: test-' issue.txt
|
grep 'Created issue: test-' issue.txt
|
||||||
|
|
||||||
# Extract ID using grep
|
# Extract ID using grep
|
||||||
exec sh -c 'grep -oE "test-[a-f0-9]+" issue.txt > issue_id.txt'
|
exec sh -c 'grep -oE "test-[a-z0-9]+" issue.txt > issue_id.txt'
|
||||||
|
|
||||||
# Show the issue
|
# Show the issue
|
||||||
exec sh -c 'bd show $(cat issue_id.txt)'
|
exec sh -c 'bd show $(cat issue_id.txt)'
|
||||||
|
|||||||
2
cmd/bd/testdata/stats.txt
vendored
2
cmd/bd/testdata/stats.txt
vendored
@@ -4,7 +4,7 @@ bd init --prefix test
|
|||||||
# Create issues
|
# Create issues
|
||||||
bd create 'First issue'
|
bd create 'First issue'
|
||||||
cp stdout first.txt
|
cp stdout first.txt
|
||||||
exec sh -c 'grep -oE "test-[a-f0-9]+" first.txt > first_id.txt'
|
exec sh -c 'grep -oE "test-[a-z0-9]+" first.txt > first_id.txt'
|
||||||
|
|
||||||
bd create 'Second issue'
|
bd create 'Second issue'
|
||||||
|
|
||||||
|
|||||||
2
cmd/bd/testdata/update.txt
vendored
2
cmd/bd/testdata/update.txt
vendored
@@ -7,7 +7,7 @@ cp stdout issue.txt
|
|||||||
grep 'Created issue: test-' issue.txt
|
grep 'Created issue: test-' issue.txt
|
||||||
|
|
||||||
# Extract ID using grep
|
# Extract ID using grep
|
||||||
exec sh -c 'grep -oE "test-[a-f0-9]+" issue.txt > issue_id.txt'
|
exec sh -c 'grep -oE "test-[a-z0-9]+" issue.txt > issue_id.txt'
|
||||||
|
|
||||||
# Update the issue status
|
# Update the issue status
|
||||||
exec sh -c 'bd update $(cat issue_id.txt) --status in_progress'
|
exec sh -c 'bd update $(cat issue_id.txt) --status in_progress'
|
||||||
|
|||||||
@@ -50,36 +50,18 @@ func TestAdaptiveIDLength_E2E(t *testing.T) {
|
|||||||
return issue.ID
|
return issue.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test 1: First few issues should use 4-char IDs
|
// Test 1: First few issues should use 3-char IDs (base36 allows shorter IDs)
|
||||||
t.Run("first_50_issues_use_4_chars", func(t *testing.T) {
|
t.Run("first_50_issues_use_3_chars", func(t *testing.T) {
|
||||||
for i := 0; i < 50; i++ {
|
for i := 0; i < 50; i++ {
|
||||||
title := formatTitle("Issue %d", i)
|
title := formatTitle("Issue %d", i)
|
||||||
createAndCheckLength(title, 4)
|
createAndCheckLength(title, 3)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test 2: Issues 50-500 should still use 4 chars (7% collision at 500)
|
// Test 2: Issues 50-200 should transition to 4 chars
|
||||||
t.Run("issues_50_to_500_use_4_chars", func(t *testing.T) {
|
// (3 chars good up to ~160 issues with 25% threshold)
|
||||||
for i := 50; i < 500; i++ {
|
t.Run("issues_50_to_200_use_3_or_4_chars", func(t *testing.T) {
|
||||||
title := formatTitle("Issue %d", i)
|
for i := 50; i < 200; i++ {
|
||||||
id := createAndCheckLength(title, 4)
|
|
||||||
// Most should be 4 chars, but collisions might push some to 5
|
|
||||||
// We allow up to 5 chars as progressive fallback
|
|
||||||
hashPart := strings.TrimPrefix(id, "test-")
|
|
||||||
if len(hashPart) > 5 {
|
|
||||||
t.Errorf("Issue %d has hash length %d, expected 4-5", i, len(hashPart))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test 3: At 1000 issues, should scale to 5 chars
|
|
||||||
// Note: We don't enforce exact length in this test because the adaptive
|
|
||||||
// algorithm will keep using 4 chars until collision probability exceeds 25%
|
|
||||||
// At 600 issues we're still below that threshold
|
|
||||||
t.Run("verify_adaptive_scaling_works", func(t *testing.T) {
|
|
||||||
// Just verify that we can create more issues and the algorithm doesn't break
|
|
||||||
// The actual length will be determined by the adaptive algorithm
|
|
||||||
for i := 500; i < 550; i++ {
|
|
||||||
title := formatTitle("Issue %d", i)
|
title := formatTitle("Issue %d", i)
|
||||||
issue := &types.Issue{
|
issue := &types.Issue{
|
||||||
Title: title,
|
Title: title,
|
||||||
@@ -93,10 +75,37 @@ func TestAdaptiveIDLength_E2E(t *testing.T) {
|
|||||||
t.Fatalf("Failed to create issue: %v", err)
|
t.Fatalf("Failed to create issue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should use 4-6 chars depending on database size
|
// Most should be 3 chars initially, transitioning to 4 after ~160
|
||||||
hashPart := strings.TrimPrefix(issue.ID, "test-")
|
hashPart := strings.TrimPrefix(issue.ID, "test-")
|
||||||
if len(hashPart) < 4 || len(hashPart) > 6 {
|
if len(hashPart) < 3 || len(hashPart) > 4 {
|
||||||
t.Errorf("Issue %d has hash length %d, expected 4-6", i, len(hashPart))
|
t.Errorf("Issue %d has hash length %d, expected 3-4", i, len(hashPart))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test 3: At 500-1000 issues, should scale to 4-5 chars
|
||||||
|
// (4 chars good up to ~980 issues with 25% threshold)
|
||||||
|
t.Run("verify_adaptive_scaling_works", func(t *testing.T) {
|
||||||
|
// Just verify that we can create more issues and the algorithm doesn't break
|
||||||
|
// The actual length will be determined by the adaptive algorithm
|
||||||
|
for i := 200; i < 250; i++ {
|
||||||
|
title := formatTitle("Issue %d", i)
|
||||||
|
issue := &types.Issue{
|
||||||
|
Title: title,
|
||||||
|
Description: "Test",
|
||||||
|
Status: "open",
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: "task",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.CreateIssue(ctx, issue, "test@example.com"); err != nil {
|
||||||
|
t.Fatalf("Failed to create issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should use 4-5 chars depending on database size
|
||||||
|
hashPart := strings.TrimPrefix(issue.ID, "test-")
|
||||||
|
if len(hashPart) < 3 || len(hashPart) > 5 {
|
||||||
|
t.Errorf("Issue %d has hash length %d, expected 3-5", i, len(hashPart))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,18 +12,25 @@ type AdaptiveIDConfig struct {
|
|||||||
// MaxCollisionProbability is the threshold at which we scale up ID length (e.g., 0.25 = 25%)
|
// MaxCollisionProbability is the threshold at which we scale up ID length (e.g., 0.25 = 25%)
|
||||||
MaxCollisionProbability float64
|
MaxCollisionProbability float64
|
||||||
|
|
||||||
// MinLength is the minimum hash length to use (default 4)
|
// MinLength is the minimum hash length to use (default 3)
|
||||||
MinLength int
|
MinLength int
|
||||||
|
|
||||||
// MaxLength is the maximum hash length to use (default 8)
|
// MaxLength is the maximum hash length to use (default 8)
|
||||||
MaxLength int
|
MaxLength int
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultAdaptiveConfig returns sensible defaults
|
// DefaultAdaptiveConfig returns sensible defaults for base36 encoding
|
||||||
|
// With base36 (0-9, a-z), we can use shorter IDs than hex:
|
||||||
|
// 3 chars: ~46K namespace, good for up to ~160 issues (25% collision prob)
|
||||||
|
// 4 chars: ~1.7M namespace, good for up to ~980 issues
|
||||||
|
// 5 chars: ~60M namespace, good for up to ~5.9K issues
|
||||||
|
// 6 chars: ~2.2B namespace, good for up to ~35K issues
|
||||||
|
// 7 chars: ~78B namespace, good for up to ~212K issues
|
||||||
|
// 8 chars: ~2.8T namespace, good for up to ~1M+ issues
|
||||||
func DefaultAdaptiveConfig() AdaptiveIDConfig {
|
func DefaultAdaptiveConfig() AdaptiveIDConfig {
|
||||||
return AdaptiveIDConfig{
|
return AdaptiveIDConfig{
|
||||||
MaxCollisionProbability: 0.25, // 25% threshold
|
MaxCollisionProbability: 0.25, // 25% threshold
|
||||||
MinLength: 4,
|
MinLength: 3,
|
||||||
MaxLength: 8,
|
MaxLength: 8,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,7 +39,7 @@ func DefaultAdaptiveConfig() AdaptiveIDConfig {
|
|||||||
// P(collision) ≈ 1 - e^(-n²/2N)
|
// P(collision) ≈ 1 - e^(-n²/2N)
|
||||||
// where n = number of items, N = total possible values
|
// where n = number of items, N = total possible values
|
||||||
func collisionProbability(numIssues int, idLength int) float64 {
|
func collisionProbability(numIssues int, idLength int) float64 {
|
||||||
const base = 36.0 // lowercase alphanumeric (0-9, a-z)
|
const base = 36.0 // base36 encoding (0-9, a-z)
|
||||||
totalPossibilities := math.Pow(base, float64(idLength))
|
totalPossibilities := math.Pow(base, float64(idLength))
|
||||||
exponent := -float64(numIssues*numIssues) / (2.0 * totalPossibilities)
|
exponent := -float64(numIssues*numIssues) / (2.0 * totalPossibilities)
|
||||||
return 1.0 - math.Exp(exponent)
|
return 1.0 - math.Exp(exponent)
|
||||||
|
|||||||
@@ -45,35 +45,41 @@ func TestComputeAdaptiveLength(t *testing.T) {
|
|||||||
want int
|
want int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "small database uses 4 chars",
|
name: "tiny database uses 3 chars",
|
||||||
numIssues: 50,
|
numIssues: 50,
|
||||||
config: DefaultAdaptiveConfig(),
|
config: DefaultAdaptiveConfig(),
|
||||||
want: 4,
|
want: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "medium database uses 4 chars",
|
name: "small database uses 4 chars",
|
||||||
numIssues: 500,
|
numIssues: 500,
|
||||||
config: DefaultAdaptiveConfig(),
|
config: DefaultAdaptiveConfig(),
|
||||||
want: 4,
|
want: 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "large database uses 5 chars",
|
name: "medium database uses 5 chars",
|
||||||
numIssues: 1000,
|
numIssues: 3000,
|
||||||
config: DefaultAdaptiveConfig(),
|
config: DefaultAdaptiveConfig(),
|
||||||
want: 5,
|
want: 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "very large database uses 6 chars",
|
name: "large database uses 6 chars",
|
||||||
numIssues: 10000,
|
numIssues: 20000,
|
||||||
config: DefaultAdaptiveConfig(),
|
config: DefaultAdaptiveConfig(),
|
||||||
want: 6,
|
want: 6,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "very large database uses 7 chars",
|
||||||
|
numIssues: 100000,
|
||||||
|
config: DefaultAdaptiveConfig(),
|
||||||
|
want: 7,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "custom threshold - stricter",
|
name: "custom threshold - stricter",
|
||||||
numIssues: 200,
|
numIssues: 200,
|
||||||
config: AdaptiveIDConfig{
|
config: AdaptiveIDConfig{
|
||||||
MaxCollisionProbability: 0.01, // 1% threshold
|
MaxCollisionProbability: 0.01, // 1% threshold
|
||||||
MinLength: 4,
|
MinLength: 3,
|
||||||
MaxLength: 8,
|
MaxLength: 8,
|
||||||
},
|
},
|
||||||
want: 5,
|
want: 5,
|
||||||
@@ -83,7 +89,7 @@ func TestComputeAdaptiveLength(t *testing.T) {
|
|||||||
numIssues: 1000,
|
numIssues: 1000,
|
||||||
config: AdaptiveIDConfig{
|
config: AdaptiveIDConfig{
|
||||||
MaxCollisionProbability: 0.50, // 50% threshold
|
MaxCollisionProbability: 0.50, // 50% threshold
|
||||||
MinLength: 4,
|
MinLength: 3,
|
||||||
MaxLength: 8,
|
MaxLength: 8,
|
||||||
},
|
},
|
||||||
want: 4,
|
want: 4,
|
||||||
@@ -112,6 +118,7 @@ func TestGenerateHashID_VariableLengths(t *testing.T) {
|
|||||||
length int
|
length int
|
||||||
expectedLen int // length of hash portion (without prefix)
|
expectedLen int // length of hash portion (without prefix)
|
||||||
}{
|
}{
|
||||||
|
{3, 3},
|
||||||
{4, 4},
|
{4, 4},
|
||||||
{5, 5},
|
{5, 5},
|
||||||
{6, 6},
|
{6, 6},
|
||||||
@@ -152,7 +159,7 @@ func TestGetAdaptiveIDLength_Integration(t *testing.T) {
|
|||||||
t.Fatalf("Failed to set prefix: %v", err)
|
t.Fatalf("Failed to set prefix: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test default config (should use 4 chars for empty database)
|
// Test default config (should use 3 chars for empty database)
|
||||||
conn, err := db.db.Conn(ctx)
|
conn, err := db.db.Conn(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get connection: %v", err)
|
t.Fatalf("Failed to get connection: %v", err)
|
||||||
@@ -164,8 +171,8 @@ func TestGetAdaptiveIDLength_Integration(t *testing.T) {
|
|||||||
t.Fatalf("GetAdaptiveIDLength failed: %v", err)
|
t.Fatalf("GetAdaptiveIDLength failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if length != 4 {
|
if length != 3 {
|
||||||
t.Errorf("Empty database should use 4 chars, got %d", length)
|
t.Errorf("Empty database should use 3 chars, got %d", length)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test custom config
|
// Test custom config
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ func TestHashIDGeneration(t *testing.T) {
|
|||||||
t.Fatalf("Failed to create issue: %v", err)
|
t.Fatalf("Failed to create issue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify hash ID format: bd-<4-8 hex chars> with adaptive length (bd-ea2a13)
|
// Verify hash ID format: bd-<3-8 base36 chars> with adaptive length
|
||||||
// For empty/small database, should use 4 chars
|
// For empty/small database, should use 3 chars
|
||||||
if len(issue.ID) < 7 || len(issue.ID) > 11 { // "bd-" (3) + 4-8 hex chars = 7-11
|
if len(issue.ID) < 6 || len(issue.ID) > 11 { // "bd-" (3) + 3-8 base36 chars = 6-11
|
||||||
t.Errorf("Expected ID length 7-11, got %d: %s", len(issue.ID), issue.ID)
|
t.Errorf("Expected ID length 6-11, got %d: %s", len(issue.ID), issue.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if issue.ID[:3] != "bd-" {
|
if issue.ID[:3] != "bd-" {
|
||||||
@@ -182,9 +182,9 @@ func TestHashIDBatchCreation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
ids[issue.ID] = true
|
ids[issue.ID] = true
|
||||||
|
|
||||||
// Verify hash ID format (4-8 chars with adaptive length)
|
// Verify hash ID format (3-8 chars with adaptive length)
|
||||||
if len(issue.ID) < 7 || len(issue.ID) > 11 {
|
if len(issue.ID) < 6 || len(issue.ID) > 11 {
|
||||||
t.Errorf("Expected ID length 7-11, got %d: %s", len(issue.ID), issue.ID)
|
t.Errorf("Expected ID length 6-11, got %d: %s", len(issue.ID), issue.ID)
|
||||||
}
|
}
|
||||||
if issue.ID[:3] != "bd-" {
|
if issue.ID[:3] != "bd-" {
|
||||||
t.Errorf("Expected ID to start with 'bd-', got: %s", issue.ID)
|
t.Errorf("Expected ID to start with 'bd-', got: %s", issue.ID)
|
||||||
|
|||||||
@@ -4,14 +4,75 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// base36Alphabet is the character set for base36 encoding (0-9, a-z)
|
||||||
|
const base36Alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||||
|
|
||||||
|
// encodeBase36 converts a byte slice to a base36 string of specified length
|
||||||
|
// Takes the first N bytes and converts them to base36 representation
|
||||||
|
func encodeBase36(data []byte, length int) string {
|
||||||
|
// Convert bytes to big integer
|
||||||
|
num := new(big.Int).SetBytes(data)
|
||||||
|
|
||||||
|
// Convert to base36
|
||||||
|
var result strings.Builder
|
||||||
|
base := big.NewInt(36)
|
||||||
|
zero := big.NewInt(0)
|
||||||
|
mod := new(big.Int)
|
||||||
|
|
||||||
|
// Build the string in reverse
|
||||||
|
chars := make([]byte, 0, length)
|
||||||
|
for num.Cmp(zero) > 0 {
|
||||||
|
num.DivMod(num, base, mod)
|
||||||
|
chars = append(chars, base36Alphabet[mod.Int64()])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse the string
|
||||||
|
for i := len(chars) - 1; i >= 0; i-- {
|
||||||
|
result.WriteByte(chars[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad with zeros if needed
|
||||||
|
str := result.String()
|
||||||
|
if len(str) < length {
|
||||||
|
str = strings.Repeat("0", length-len(str)) + str
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate to exact length if needed (keep least significant digits)
|
||||||
|
if len(str) > length {
|
||||||
|
str = str[len(str)-length:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidBase36 checks if a string contains only base36 characters
|
||||||
|
func isValidBase36(s string) bool {
|
||||||
|
for _, c := range s {
|
||||||
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidHex checks if a string contains only hex characters
|
||||||
|
func isValidHex(s string) bool {
|
||||||
|
for _, c := range s {
|
||||||
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ValidateIssueIDPrefix validates that an issue ID matches the configured prefix
|
// ValidateIssueIDPrefix validates that an issue ID matches the configured prefix
|
||||||
// Supports both top-level (bd-a3f8e9) and hierarchical (bd-a3f8e9.1) IDs
|
// Supports both top-level (bd-a3f8e9) and hierarchical (bd-a3f8e9.1) IDs
|
||||||
func ValidateIssueIDPrefix(id, prefix string) error {
|
func ValidateIssueIDPrefix(id, prefix string) error {
|
||||||
@@ -150,9 +211,10 @@ func EnsureIDs(ctx context.Context, conn *sql.Conn, prefix string, issues []*typ
|
|||||||
}
|
}
|
||||||
|
|
||||||
// generateHashID creates a hash-based ID for a top-level issue.
|
// generateHashID creates a hash-based ID for a top-level issue.
|
||||||
// For child issues, use the parent ID with a numeric suffix (e.g., "bd-a3f8e9.1").
|
// For child issues, use the parent ID with a numeric suffix (e.g., "bd-x7k9p.1").
|
||||||
// Supports adaptive length from 4-8 chars based on database size (bd-ea2a13).
|
// Supports adaptive length from 3-8 chars based on database size.
|
||||||
// Includes a nonce parameter to handle same-length collisions.
|
// Includes a nonce parameter to handle same-length collisions.
|
||||||
|
// Uses base36 encoding (0-9, a-z) for better information density than hex.
|
||||||
func generateHashID(prefix, title, description, creator string, timestamp time.Time, length, nonce int) string {
|
func generateHashID(prefix, title, description, creator string, timestamp time.Time, length, nonce int) string {
|
||||||
// Combine inputs into a stable content string
|
// Combine inputs into a stable content string
|
||||||
// Include nonce to handle hash collisions
|
// Include nonce to handle hash collisions
|
||||||
@@ -161,25 +223,27 @@ func generateHashID(prefix, title, description, creator string, timestamp time.T
|
|||||||
// Hash the content
|
// Hash the content
|
||||||
hash := sha256.Sum256([]byte(content))
|
hash := sha256.Sum256([]byte(content))
|
||||||
|
|
||||||
// Use variable length (4-8 hex chars)
|
// Use base36 encoding with variable length (3-8 chars)
|
||||||
// length determines how many bytes to use (2, 2.5, 3, 3.5, or 4)
|
// Determine how many bytes to use based on desired output length
|
||||||
var shortHash string
|
var numBytes int
|
||||||
switch length {
|
switch length {
|
||||||
|
case 3:
|
||||||
|
numBytes = 2 // 2 bytes = 16 bits ≈ 3.09 base36 chars
|
||||||
case 4:
|
case 4:
|
||||||
shortHash = hex.EncodeToString(hash[:2])
|
numBytes = 3 // 3 bytes = 24 bits ≈ 4.63 base36 chars
|
||||||
case 5:
|
case 5:
|
||||||
// 2.5 bytes: use 3 bytes but take only first 5 chars
|
numBytes = 4 // 4 bytes = 32 bits ≈ 6.18 base36 chars
|
||||||
shortHash = hex.EncodeToString(hash[:3])[:5]
|
|
||||||
case 6:
|
case 6:
|
||||||
shortHash = hex.EncodeToString(hash[:3])
|
numBytes = 4 // 4 bytes = 32 bits ≈ 6.18 base36 chars
|
||||||
case 7:
|
case 7:
|
||||||
// 3.5 bytes: use 4 bytes but take only first 7 chars
|
numBytes = 5 // 5 bytes = 40 bits ≈ 7.73 base36 chars
|
||||||
shortHash = hex.EncodeToString(hash[:4])[:7]
|
|
||||||
case 8:
|
case 8:
|
||||||
shortHash = hex.EncodeToString(hash[:4])
|
numBytes = 5 // 5 bytes = 40 bits ≈ 7.73 base36 chars
|
||||||
default:
|
default:
|
||||||
shortHash = hex.EncodeToString(hash[:3]) // default to 6
|
numBytes = 3 // default to 3 chars
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shortHash := encodeBase36(hash[:numBytes], length)
|
||||||
|
|
||||||
return fmt.Sprintf("%s-%s", prefix, shortHash)
|
return fmt.Sprintf("%s-%s", prefix, shortHash)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,11 +78,11 @@ func ResolvePartialID(ctx context.Context, store storage.Storage, input string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract the hash part for substring matching
|
// Extract the hash part for substring matching
|
||||||
hashPart := strings.TrimPrefix(normalizedID, prefix)
|
hashPart := strings.TrimPrefix(normalizedID, prefixWithHyphen)
|
||||||
|
|
||||||
var matches []string
|
var matches []string
|
||||||
for _, issue := range issues {
|
for _, issue := range issues {
|
||||||
issueHash := strings.TrimPrefix(issue.ID, prefix)
|
issueHash := strings.TrimPrefix(issue.ID, prefixWithHyphen)
|
||||||
// Check if the issue hash contains the input hash as substring
|
// Check if the issue hash contains the input hash as substring
|
||||||
if strings.Contains(issueHash, hashPart) {
|
if strings.Contains(issueHash, hashPart) {
|
||||||
matches = append(matches, issue.ID)
|
matches = append(matches, issue.ID)
|
||||||
|
|||||||
Reference in New Issue
Block a user