* feat: add performance testing framework foundation Implements foundation for comprehensive performance testing and user diagnostics for beads databases at 10K-20K scale. Components added: - Fixture generator (internal/testutil/fixtures/) for realistic test data * LargeSQLite/XLargeSQLite: 10K/20K issues with epic hierarchies * LargeFromJSONL/XLargeFromJSONL: test JSONL import path * Realistic cross-linked dependencies, labels, assignees * Reproducible with seeded RNG - User diagnostics (bd doctor --perf) for field performance data * Collects platform info (OS, arch, Go/SQLite versions) * Measures key operation timings (ready, list, show, search) * Generates CPU profiles for bug reports * Clean separation in cmd/bd/doctor/perf.go Test data characteristics: - 10% epics, 30% features, 60% tasks - 4-level hierarchies (Epic → Feature → Task → Subtask) - 20% cross-epic blocking dependencies - Realistic status/priority/label distributions Supports bd-l954 (Performance Testing Framework epic) Closes bd-6ed8, bd-q59i * perf: optimize GetReadyWork with compound index (20x speedup) Add compound index on dependencies(depends_on_id, type, issue_id) to eliminate performance bottleneck in GetReadyWork recursive CTE query. Performance improvements (10K issue database): - GetReadyWork: 752ms → 36.6ms (20.5x faster) - Target: <50ms ✓ ACHIEVED - 20K database: ~1500ms → 79.4ms (19x faster) Benchmark infrastructure enhancements: - Add dataset caching in /tmp/beads-bench-cache/ to avoid regenerating 10K-20K issues on every benchmark run (first run: ~2min, subsequent: <5s) - Add progress logging during fixture generation (shows 10%, 20%... completion) - Add database size logging (17.5 MB for 10K, 35.1 MB for 20K) - Document rationale for only benchmarking large datasets (>10K issues) - Add CPU/trace profiling with --profile flag for performance debugging Schema changes: - internal/storage/sqlite/schema.go: Add idx_dependencies_depends_on_type_issue New files: - internal/storage/sqlite/bench_helpers_test.go: Reusable benchmark setup with caching - internal/storage/sqlite/sqlite_bench_test.go: Comprehensive benchmarks for critical operations - Makefile: Convenient benchmark execution (make bench-quick, make bench) Related: - Resolves bd-5qim (optimize GetReadyWork performance) - Builds on bd-6ed8 (fixture generator), bd-q59i (bd doctor --perf) * perf: add WASM compilation cache to eliminate cold-start overhead Configure wazero compilation cache for ncruces/go-sqlite3 to avoid ~220ms JIT compilation on every process start. Cache configuration: - Location: ~/.cache/beads/wasm/ (platform-specific via os.UserCacheDir) - Automatic version management: wazero keys entries by its version - Fallback: in-memory cache if directory creation fails - No cleanup needed: old versions are harmless (~5-10MB each) Performance impact: - First run: ~220ms (populate cache) - Subsequent runs: ~20ms (load from cache) - Savings: ~200ms per cold start Cache invalidation: - Automatic when wazero version changes (upgrades use new cache dir) - Manual cleanup: rm -rf ~/.cache/beads/wasm/ (safe to delete anytime) This complements daemon mode: - Daemon mode: eliminates startup cost by keeping process alive - WASM cache: reduces startup cost for one-off commands or daemon restarts Changes: - internal/storage/sqlite/sqlite.go: Add init() with cache setup * refactor: improve maintainability of performance testing code Extract common patterns and eliminate duplication across benchmarks, fixture generation, and performance diagnostics. Replace magic numbers with explicit configuration to improve readability and make it easier to tune test parameters. * docs: clarify profiling behavior and add missing documentation Add explanatory comments for profiling setup to clarify why --profile forces direct mode (captures actual database operations instead of RPC overhead) and document the stopCPUProfile function's role in flushing profile data to disk. Also fix gosec G104 linter warning by explicitly ignoring Close() error during cleanup. * fix: prevent bench-quick from running indefinitely Added //go:build bench tags and skipped timeout-prone benchmarks to prevent make bench-quick from running for hours. Changes: - Add //go:build bench tag to cycle_bench_test.go and compact_bench_test.go - Skip Dense graph benchmarks (documented to timeout >120s) - Fix compact benchmark prefix: bd- → bd (validation expects prefix without trailing dash) Before: make bench-quick ran for 3.5+ hours (12,699s) before manual interrupt After: make bench-quick completes in ~25 seconds The Dense graph benchmarks are known to timeout and represent rare edge cases that don't need optimization for typical workflows.
542 lines
15 KiB
Go
542 lines
15 KiB
Go
// Package fixtures provides realistic test data generation for benchmarks and tests.
|
|
package fixtures
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/rand"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/storage"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// labels used across all fixtures
|
|
var commonLabels = []string{
|
|
"backend",
|
|
"frontend",
|
|
"urgent",
|
|
"tech-debt",
|
|
"documentation",
|
|
"performance",
|
|
"security",
|
|
"ux",
|
|
"api",
|
|
"database",
|
|
}
|
|
|
|
// assignees used across all fixtures
|
|
var commonAssignees = []string{
|
|
"alice",
|
|
"bob",
|
|
"charlie",
|
|
"diana",
|
|
"eve",
|
|
"frank",
|
|
}
|
|
|
|
// epic titles for realistic data
|
|
var epicTitles = []string{
|
|
"User Authentication System",
|
|
"Payment Processing Integration",
|
|
"Mobile App Redesign",
|
|
"Performance Optimization",
|
|
"API v2 Migration",
|
|
"Search Functionality Enhancement",
|
|
"Analytics Dashboard",
|
|
"Multi-tenant Support",
|
|
"Notification System",
|
|
"Data Export Feature",
|
|
}
|
|
|
|
// feature titles (under epics)
|
|
var featureTitles = []string{
|
|
"OAuth2 Integration",
|
|
"Password Reset Flow",
|
|
"Two-Factor Authentication",
|
|
"Session Management",
|
|
"API Endpoints",
|
|
"Database Schema",
|
|
"UI Components",
|
|
"Background Jobs",
|
|
"Error Handling",
|
|
"Testing Infrastructure",
|
|
}
|
|
|
|
// task titles (under features)
|
|
var taskTitles = []string{
|
|
"Implement login endpoint",
|
|
"Add validation logic",
|
|
"Write unit tests",
|
|
"Update documentation",
|
|
"Fix memory leak",
|
|
"Optimize query performance",
|
|
"Add error logging",
|
|
"Refactor helper functions",
|
|
"Update database migrations",
|
|
"Configure deployment",
|
|
}
|
|
|
|
// Fixture size rationale:
|
|
// We only provide Large (10K) and XLarge (20K) fixtures because:
|
|
// - Performance characteristics only emerge at scale (10K+ issues)
|
|
// - Smaller fixtures don't provide meaningful optimization insights
|
|
// - Code weight matters; we avoid unused complexity
|
|
// - Target use case: repositories with thousands of issues
|
|
|
|
// DataConfig controls the distribution and characteristics of generated test data
|
|
type DataConfig struct {
|
|
TotalIssues int // total number of issues to generate
|
|
EpicRatio float64 // percentage of issues that are epics (e.g., 0.1 for 10%)
|
|
FeatureRatio float64 // percentage of issues that are features (e.g., 0.3 for 30%)
|
|
OpenRatio float64 // percentage of issues that are open (e.g., 0.5 for 50%)
|
|
CrossLinkRatio float64 // percentage of tasks with cross-epic blocking dependencies (e.g., 0.2 for 20%)
|
|
MaxEpicAgeDays int // maximum age in days for epics (e.g., 180)
|
|
MaxFeatureAgeDays int // maximum age in days for features (e.g., 150)
|
|
MaxTaskAgeDays int // maximum age in days for tasks (e.g., 120)
|
|
MaxClosedAgeDays int // maximum days since closure (e.g., 30)
|
|
RandSeed int64 // random seed for reproducibility
|
|
}
|
|
|
|
// DefaultLargeConfig returns configuration for 10K issue dataset
|
|
func DefaultLargeConfig() DataConfig {
|
|
return DataConfig{
|
|
TotalIssues: 10000,
|
|
EpicRatio: 0.1,
|
|
FeatureRatio: 0.3,
|
|
OpenRatio: 0.5,
|
|
CrossLinkRatio: 0.2,
|
|
MaxEpicAgeDays: 180,
|
|
MaxFeatureAgeDays: 150,
|
|
MaxTaskAgeDays: 120,
|
|
MaxClosedAgeDays: 30,
|
|
RandSeed: 42,
|
|
}
|
|
}
|
|
|
|
// DefaultXLargeConfig returns configuration for 20K issue dataset
|
|
func DefaultXLargeConfig() DataConfig {
|
|
return DataConfig{
|
|
TotalIssues: 20000,
|
|
EpicRatio: 0.1,
|
|
FeatureRatio: 0.3,
|
|
OpenRatio: 0.5,
|
|
CrossLinkRatio: 0.2,
|
|
MaxEpicAgeDays: 180,
|
|
MaxFeatureAgeDays: 150,
|
|
MaxTaskAgeDays: 120,
|
|
MaxClosedAgeDays: 30,
|
|
RandSeed: 43,
|
|
}
|
|
}
|
|
|
|
// LargeSQLite creates a 10K issue database with realistic patterns
|
|
func LargeSQLite(ctx context.Context, store storage.Storage) error {
|
|
cfg := DefaultLargeConfig()
|
|
return generateIssuesWithConfig(ctx, store, cfg)
|
|
}
|
|
|
|
// XLargeSQLite creates a 20K issue database with realistic patterns
|
|
func XLargeSQLite(ctx context.Context, store storage.Storage) error {
|
|
cfg := DefaultXLargeConfig()
|
|
return generateIssuesWithConfig(ctx, store, cfg)
|
|
}
|
|
|
|
// LargeFromJSONL creates a 10K issue database by exporting to JSONL and reimporting
|
|
func LargeFromJSONL(ctx context.Context, store storage.Storage, tempDir string) error {
|
|
cfg := DefaultLargeConfig()
|
|
cfg.RandSeed = 44 // different seed for JSONL path
|
|
return generateFromJSONL(ctx, store, tempDir, cfg)
|
|
}
|
|
|
|
// XLargeFromJSONL creates a 20K issue database by exporting to JSONL and reimporting
|
|
func XLargeFromJSONL(ctx context.Context, store storage.Storage, tempDir string) error {
|
|
cfg := DefaultXLargeConfig()
|
|
cfg.RandSeed = 45 // different seed for JSONL path
|
|
return generateFromJSONL(ctx, store, tempDir, cfg)
|
|
}
|
|
|
|
// generateIssuesWithConfig creates issues with realistic epic hierarchies and cross-links using provided configuration
|
|
func generateIssuesWithConfig(ctx context.Context, store storage.Storage, cfg DataConfig) error {
|
|
rng := rand.New(rand.NewSource(cfg.RandSeed))
|
|
|
|
// Calculate breakdown using configuration ratios
|
|
numEpics := int(float64(cfg.TotalIssues) * cfg.EpicRatio)
|
|
numFeatures := int(float64(cfg.TotalIssues) * cfg.FeatureRatio)
|
|
numTasks := cfg.TotalIssues - numEpics - numFeatures
|
|
|
|
// Track created issues for cross-linking
|
|
var allIssues []*types.Issue
|
|
epicIssues := make([]*types.Issue, 0, numEpics)
|
|
featureIssues := make([]*types.Issue, 0, numFeatures)
|
|
taskIssues := make([]*types.Issue, 0, numTasks)
|
|
|
|
// Progress tracking
|
|
createdIssues := 0
|
|
lastPctLogged := -1
|
|
|
|
logProgress := func() {
|
|
pct := (createdIssues * 100) / cfg.TotalIssues
|
|
if pct >= lastPctLogged+10 {
|
|
fmt.Printf(" Progress: %d%% (%d/%d issues created)\n", pct, createdIssues, cfg.TotalIssues)
|
|
lastPctLogged = pct
|
|
}
|
|
}
|
|
|
|
// Create epics
|
|
for i := 0; i < numEpics; i++ {
|
|
issue := &types.Issue{
|
|
Title: fmt.Sprintf("%s (Epic %d)", epicTitles[i%len(epicTitles)], i),
|
|
Description: fmt.Sprintf("Epic for %s", epicTitles[i%len(epicTitles)]),
|
|
Status: randomStatus(rng, cfg.OpenRatio),
|
|
Priority: randomPriority(rng),
|
|
IssueType: types.TypeEpic,
|
|
Assignee: commonAssignees[rng.Intn(len(commonAssignees))],
|
|
CreatedAt: randomTime(rng, cfg.MaxEpicAgeDays),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if issue.Status == types.StatusClosed {
|
|
closedAt := randomTime(rng, cfg.MaxClosedAgeDays)
|
|
issue.ClosedAt = &closedAt
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, issue, "fixture"); err != nil {
|
|
return fmt.Errorf("failed to create epic: %w", err)
|
|
}
|
|
|
|
// Add labels to epics
|
|
for j := 0; j < rng.Intn(3)+1; j++ {
|
|
label := commonLabels[rng.Intn(len(commonLabels))]
|
|
_ = store.AddLabel(ctx, issue.ID, label, "fixture")
|
|
}
|
|
|
|
epicIssues = append(epicIssues, issue)
|
|
allIssues = append(allIssues, issue)
|
|
createdIssues++
|
|
logProgress()
|
|
}
|
|
|
|
// Create features under epics
|
|
for i := 0; i < numFeatures; i++ {
|
|
parentEpic := epicIssues[i%len(epicIssues)]
|
|
|
|
issue := &types.Issue{
|
|
Title: fmt.Sprintf("%s (Feature %d)", featureTitles[i%len(featureTitles)], i),
|
|
Description: fmt.Sprintf("Feature under %s", parentEpic.Title),
|
|
Status: randomStatus(rng, cfg.OpenRatio),
|
|
Priority: randomPriority(rng),
|
|
IssueType: types.TypeFeature,
|
|
Assignee: commonAssignees[rng.Intn(len(commonAssignees))],
|
|
CreatedAt: randomTime(rng, cfg.MaxFeatureAgeDays),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if issue.Status == types.StatusClosed {
|
|
closedAt := randomTime(rng, cfg.MaxClosedAgeDays)
|
|
issue.ClosedAt = &closedAt
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, issue, "fixture"); err != nil {
|
|
return fmt.Errorf("failed to create feature: %w", err)
|
|
}
|
|
|
|
// Add parent-child dependency to epic
|
|
dep := &types.Dependency{
|
|
IssueID: issue.ID,
|
|
DependsOnID: parentEpic.ID,
|
|
Type: types.DepParentChild,
|
|
CreatedAt: time.Now(),
|
|
CreatedBy: "fixture",
|
|
}
|
|
if err := store.AddDependency(ctx, dep, "fixture"); err != nil {
|
|
return fmt.Errorf("failed to add feature-epic dependency: %w", err)
|
|
}
|
|
|
|
// Add labels
|
|
for j := 0; j < rng.Intn(3)+1; j++ {
|
|
label := commonLabels[rng.Intn(len(commonLabels))]
|
|
_ = store.AddLabel(ctx, issue.ID, label, "fixture")
|
|
}
|
|
|
|
featureIssues = append(featureIssues, issue)
|
|
allIssues = append(allIssues, issue)
|
|
createdIssues++
|
|
logProgress()
|
|
}
|
|
|
|
// Create tasks under features
|
|
for i := 0; i < numTasks; i++ {
|
|
parentFeature := featureIssues[i%len(featureIssues)]
|
|
|
|
issue := &types.Issue{
|
|
Title: fmt.Sprintf("%s (Task %d)", taskTitles[i%len(taskTitles)], i),
|
|
Description: fmt.Sprintf("Task under %s", parentFeature.Title),
|
|
Status: randomStatus(rng, cfg.OpenRatio),
|
|
Priority: randomPriority(rng),
|
|
IssueType: types.TypeTask,
|
|
Assignee: commonAssignees[rng.Intn(len(commonAssignees))],
|
|
CreatedAt: randomTime(rng, cfg.MaxTaskAgeDays),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if issue.Status == types.StatusClosed {
|
|
closedAt := randomTime(rng, cfg.MaxClosedAgeDays)
|
|
issue.ClosedAt = &closedAt
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, issue, "fixture"); err != nil {
|
|
return fmt.Errorf("failed to create task: %w", err)
|
|
}
|
|
|
|
// Add parent-child dependency to feature
|
|
dep := &types.Dependency{
|
|
IssueID: issue.ID,
|
|
DependsOnID: parentFeature.ID,
|
|
Type: types.DepParentChild,
|
|
CreatedAt: time.Now(),
|
|
CreatedBy: "fixture",
|
|
}
|
|
if err := store.AddDependency(ctx, dep, "fixture"); err != nil {
|
|
return fmt.Errorf("failed to add task-feature dependency: %w", err)
|
|
}
|
|
|
|
// Add labels
|
|
for j := 0; j < rng.Intn(2)+1; j++ {
|
|
label := commonLabels[rng.Intn(len(commonLabels))]
|
|
_ = store.AddLabel(ctx, issue.ID, label, "fixture")
|
|
}
|
|
|
|
taskIssues = append(taskIssues, issue)
|
|
allIssues = append(allIssues, issue)
|
|
createdIssues++
|
|
logProgress()
|
|
}
|
|
|
|
fmt.Printf(" Progress: 100%% (%d/%d issues created) - Complete!\n", cfg.TotalIssues, cfg.TotalIssues)
|
|
|
|
// Add cross-links between tasks across epics using configured ratio
|
|
numCrossLinks := int(float64(numTasks) * cfg.CrossLinkRatio)
|
|
for i := 0; i < numCrossLinks; i++ {
|
|
fromTask := taskIssues[rng.Intn(len(taskIssues))]
|
|
toTask := taskIssues[rng.Intn(len(taskIssues))]
|
|
|
|
// Avoid self-dependencies
|
|
if fromTask.ID == toTask.ID {
|
|
continue
|
|
}
|
|
|
|
dep := &types.Dependency{
|
|
IssueID: fromTask.ID,
|
|
DependsOnID: toTask.ID,
|
|
Type: types.DepBlocks,
|
|
CreatedAt: time.Now(),
|
|
CreatedBy: "fixture",
|
|
}
|
|
|
|
// Ignore cycle errors for cross-links (they're expected)
|
|
_ = store.AddDependency(ctx, dep, "fixture")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// generateFromJSONL creates issues, exports to JSONL, clears DB, and reimports
|
|
func generateFromJSONL(ctx context.Context, store storage.Storage, tempDir string, cfg DataConfig) error {
|
|
// First generate issues normally
|
|
if err := generateIssuesWithConfig(ctx, store, cfg); err != nil {
|
|
return fmt.Errorf("failed to generate issues: %w", err)
|
|
}
|
|
|
|
// Export to JSONL
|
|
jsonlPath := filepath.Join(tempDir, "issues.jsonl")
|
|
if err := exportToJSONL(ctx, store, jsonlPath); err != nil {
|
|
return fmt.Errorf("failed to export to JSONL: %w", err)
|
|
}
|
|
|
|
// Clear all issues (we'll reimport them)
|
|
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get all issues: %w", err)
|
|
}
|
|
|
|
for _, issue := range allIssues {
|
|
if err := store.DeleteIssue(ctx, issue.ID); err != nil {
|
|
return fmt.Errorf("failed to delete issue %s: %w", issue.ID, err)
|
|
}
|
|
}
|
|
|
|
// Import from JSONL
|
|
if err := importFromJSONL(ctx, store, jsonlPath); err != nil {
|
|
return fmt.Errorf("failed to import from JSONL: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// exportToJSONL exports all issues to a JSONL file
|
|
func exportToJSONL(ctx context.Context, store storage.Storage, path string) error {
|
|
// Get all issues
|
|
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to query issues: %w", err)
|
|
}
|
|
|
|
// Populate dependencies and labels for each issue
|
|
allDeps, err := store.GetAllDependencyRecords(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get dependencies: %w", err)
|
|
}
|
|
|
|
for _, issue := range allIssues {
|
|
issue.Dependencies = allDeps[issue.ID]
|
|
|
|
labels, err := store.GetLabels(ctx, issue.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get labels for %s: %w", issue.ID, err)
|
|
}
|
|
issue.Labels = labels
|
|
}
|
|
|
|
// Write to JSONL file
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create JSONL file: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
encoder := json.NewEncoder(f)
|
|
for _, issue := range allIssues {
|
|
if err := encoder.Encode(issue); err != nil {
|
|
return fmt.Errorf("failed to encode issue: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// importFromJSONL imports issues from a JSONL file
|
|
func importFromJSONL(ctx context.Context, store storage.Storage, path string) error {
|
|
// Read JSONL file
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read JSONL file: %w", err)
|
|
}
|
|
|
|
// Parse issues
|
|
var issues []*types.Issue
|
|
lines := string(data)
|
|
for i, line := range splitLines(lines) {
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
|
|
var issue types.Issue
|
|
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
|
return fmt.Errorf("failed to parse issue at line %d: %w", i+1, err)
|
|
}
|
|
|
|
issues = append(issues, &issue)
|
|
}
|
|
|
|
// Import issues directly using storage interface
|
|
// Step 1: Create all issues first (without dependencies/labels)
|
|
type savedMetadata struct {
|
|
deps []*types.Dependency
|
|
labels []string
|
|
}
|
|
metadata := make(map[string]savedMetadata)
|
|
|
|
for _, issue := range issues {
|
|
// Save dependencies and labels for later
|
|
metadata[issue.ID] = savedMetadata{
|
|
deps: issue.Dependencies,
|
|
labels: issue.Labels,
|
|
}
|
|
issue.Dependencies = nil
|
|
issue.Labels = nil
|
|
|
|
if err := store.CreateIssue(ctx, issue, "fixture"); err != nil {
|
|
// Ignore duplicate errors
|
|
if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
|
return fmt.Errorf("failed to create issue %s: %w", issue.ID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 2: Add all dependencies (now that all issues exist)
|
|
for issueID, meta := range metadata {
|
|
for _, dep := range meta.deps {
|
|
if err := store.AddDependency(ctx, dep, "fixture"); err != nil {
|
|
// Ignore duplicate and cycle errors
|
|
if !strings.Contains(err.Error(), "already exists") &&
|
|
!strings.Contains(err.Error(), "cycle") {
|
|
return fmt.Errorf("failed to add dependency for %s: %w", issueID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add labels
|
|
for _, label := range meta.labels {
|
|
_ = store.AddLabel(ctx, issueID, label, "fixture")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// splitLines splits a string by newlines
|
|
func splitLines(s string) []string {
|
|
var lines []string
|
|
start := 0
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\n' {
|
|
lines = append(lines, s[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
if start < len(s) {
|
|
lines = append(lines, s[start:])
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// randomStatus returns a random status with given open ratio
|
|
func randomStatus(rng *rand.Rand, openRatio float64) types.Status {
|
|
r := rng.Float64()
|
|
if r < openRatio {
|
|
// Open statuses: open, in_progress, blocked
|
|
statuses := []types.Status{types.StatusOpen, types.StatusInProgress, types.StatusBlocked}
|
|
return statuses[rng.Intn(len(statuses))]
|
|
}
|
|
return types.StatusClosed
|
|
}
|
|
|
|
// randomPriority returns a random priority with realistic distribution
|
|
// P0: 5%, P1: 15%, P2: 50%, P3: 25%, P4: 5%
|
|
func randomPriority(rng *rand.Rand) int {
|
|
r := rng.Intn(100)
|
|
switch {
|
|
case r < 5:
|
|
return 0
|
|
case r < 20:
|
|
return 1
|
|
case r < 70:
|
|
return 2
|
|
case r < 95:
|
|
return 3
|
|
default:
|
|
return 4
|
|
}
|
|
}
|
|
|
|
// randomTime returns a random time up to maxDaysAgo days in the past
|
|
func randomTime(rng *rand.Rand, maxDaysAgo int) time.Time {
|
|
daysAgo := rng.Intn(maxDaysAgo)
|
|
return time.Now().Add(-time.Duration(daysAgo) * 24 * time.Hour)
|
|
}
|