diff --git a/internal/mq/id.go b/internal/mq/id.go new file mode 100644 index 00000000..ce2753d3 --- /dev/null +++ b/internal/mq/id.go @@ -0,0 +1,49 @@ +// Package mq provides merge queue functionality. +package mq + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" +) + +// GenerateMRID generates a merge request ID following the convention: -mr- +// +// The hash is derived from the branch name + current timestamp + random bytes to ensure uniqueness. +// Example: gt-mr-abc123 for a gastown merge request. +// +// Parameters: +// - prefix: The project prefix (e.g., "gt" for gastown) +// - branch: The source branch name (e.g., "polecat/Nux/gt-xyz") +// +// Returns a string in the format "-mr-<6-char-hash>" +func GenerateMRID(prefix, branch string) string { + // Generate 8 random bytes for additional uniqueness + randomBytes := make([]byte, 8) + rand.Read(randomBytes) + + return generateMRIDInternal(prefix, branch, time.Now(), randomBytes) +} + +// GenerateMRIDWithTime generates a merge request ID using a specific timestamp. +// This is primarily useful for testing to ensure deterministic output. +// Note: Without randomness, two calls with identical inputs will produce the same ID. +func GenerateMRIDWithTime(prefix, branch string, timestamp time.Time) string { + return generateMRIDInternal(prefix, branch, timestamp, nil) +} + +// generateMRIDInternal is the internal implementation that combines all inputs. +func generateMRIDInternal(prefix, branch string, timestamp time.Time, randomBytes []byte) string { + // Combine branch, timestamp, and optional random bytes for uniqueness + input := fmt.Sprintf("%s:%d:%x", branch, timestamp.UnixNano(), randomBytes) + + // Generate SHA256 hash + hash := sha256.Sum256([]byte(input)) + + // Take first 6 characters of hex-encoded hash + hashStr := hex.EncodeToString(hash[:])[:6] + + return fmt.Sprintf("%s-mr-%s", prefix, hashStr) +} diff --git a/internal/mq/id_test.go b/internal/mq/id_test.go new file mode 100644 index 00000000..e6dd1e72 --- /dev/null +++ b/internal/mq/id_test.go @@ -0,0 +1,143 @@ +package mq + +import ( + "strings" + "testing" + "time" +) + +func TestGenerateMRIDWithTime(t *testing.T) { + tests := []struct { + name string + prefix string + branch string + timestamp time.Time + want string + }{ + { + name: "basic gastown MR", + prefix: "gt", + branch: "polecat/Nux/gt-xyz", + timestamp: time.Date(2025, 12, 17, 10, 0, 0, 0, time.UTC), + want: "gt-mr-", // Will verify prefix, actual hash varies + }, + { + name: "different prefix", + prefix: "hop", + branch: "feature/auth", + timestamp: time.Date(2025, 12, 17, 10, 0, 0, 0, time.UTC), + want: "hop-mr-", + }, + { + name: "empty prefix", + prefix: "", + branch: "main", + timestamp: time.Date(2025, 12, 17, 10, 0, 0, 0, time.UTC), + want: "-mr-", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GenerateMRIDWithTime(tt.prefix, tt.branch, tt.timestamp) + + // Verify prefix format + if !strings.HasPrefix(got, tt.want) { + t.Errorf("GenerateMRIDWithTime() = %q, want prefix %q", got, tt.want) + } + + // Verify total format: prefix-mr-XXXXXX (6 hex chars) + parts := strings.Split(got, "-mr-") + if len(parts) != 2 { + t.Errorf("GenerateMRIDWithTime() = %q, expected format -mr-", got) + return + } + + if parts[0] != tt.prefix { + t.Errorf("GenerateMRIDWithTime() prefix = %q, want %q", parts[0], tt.prefix) + } + + if len(parts[1]) != 6 { + t.Errorf("GenerateMRIDWithTime() hash length = %d, want 6", len(parts[1])) + } + + // Verify hash is valid hex + for _, c := range parts[1] { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Errorf("GenerateMRIDWithTime() hash contains invalid hex char: %c", c) + } + } + }) + } +} + +func TestGenerateMRIDWithTime_Deterministic(t *testing.T) { + // Same inputs should produce same output + prefix := "gt" + branch := "polecat/Nux/gt-xyz" + ts := time.Date(2025, 12, 17, 10, 0, 0, 0, time.UTC) + + id1 := GenerateMRIDWithTime(prefix, branch, ts) + id2 := GenerateMRIDWithTime(prefix, branch, ts) + + if id1 != id2 { + t.Errorf("Same inputs produced different outputs: %q != %q", id1, id2) + } +} + +func TestGenerateMRIDWithTime_DifferentTimestamps(t *testing.T) { + // Different timestamps should produce different IDs + prefix := "gt" + branch := "polecat/Nux/gt-xyz" + ts1 := time.Date(2025, 12, 17, 10, 0, 0, 0, time.UTC) + ts2 := time.Date(2025, 12, 17, 10, 0, 0, 1, time.UTC) // 1 nanosecond later + + id1 := GenerateMRIDWithTime(prefix, branch, ts1) + id2 := GenerateMRIDWithTime(prefix, branch, ts2) + + if id1 == id2 { + t.Errorf("Different timestamps produced same ID: %q", id1) + } +} + +func TestGenerateMRIDWithTime_DifferentBranches(t *testing.T) { + // Different branches should produce different IDs + prefix := "gt" + ts := time.Date(2025, 12, 17, 10, 0, 0, 0, time.UTC) + + id1 := GenerateMRIDWithTime(prefix, "branch-a", ts) + id2 := GenerateMRIDWithTime(prefix, "branch-b", ts) + + if id1 == id2 { + t.Errorf("Different branches produced same ID: %q", id1) + } +} + +func TestGenerateMRID(t *testing.T) { + // GenerateMRID uses current time, so we just verify format + id := GenerateMRID("gt", "polecat/Nux/gt-xyz") + + if !strings.HasPrefix(id, "gt-mr-") { + t.Errorf("GenerateMRID() = %q, want prefix gt-mr-", id) + } + + parts := strings.Split(id, "-mr-") + if len(parts) != 2 || len(parts[1]) != 6 { + t.Errorf("GenerateMRID() = %q, invalid format", id) + } +} + +func TestGenerateMRID_Uniqueness(t *testing.T) { + // Generate multiple IDs and verify they're unique + ids := make(map[string]bool) + prefix := "gt" + branch := "test-branch" + + for i := 0; i < 100; i++ { + id := GenerateMRID(prefix, branch) + if ids[id] { + t.Errorf("Duplicate ID generated: %q", id) + } + ids[id] = true + } +}