package cmd import ( "testing" "github.com/steveyegge/gastown/internal/beads" ) func TestAddIntegrationBranchField(t *testing.T) { tests := []struct { name string description string branchName string want string }{ { name: "empty description", description: "", branchName: "integration/gt-epic", want: "integration_branch: integration/gt-epic", }, { name: "simple description", description: "Epic for authentication", branchName: "integration/gt-auth", want: "integration_branch: integration/gt-auth\nEpic for authentication", }, { name: "existing integration_branch field", description: "integration_branch: integration/old-epic\nSome description", branchName: "integration/new-epic", want: "integration_branch: integration/new-epic\nSome description", }, { name: "multiline description", description: "Line 1\nLine 2\nLine 3", branchName: "integration/gt-xyz", want: "integration_branch: integration/gt-xyz\nLine 1\nLine 2\nLine 3", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := addIntegrationBranchField(tt.description, tt.branchName) if got != tt.want { t.Errorf("addIntegrationBranchField() = %q, want %q", got, tt.want) } }) } } func TestParseBranchName(t *testing.T) { tests := []struct { name string branch string wantIssue string wantWorker string }{ { name: "polecat branch format", branch: "polecat/Nux/gt-xyz", wantIssue: "gt-xyz", wantWorker: "Nux", }, { name: "polecat branch with subtask", branch: "polecat/Worker/gt-abc.1", wantIssue: "gt-abc.1", wantWorker: "Worker", }, { name: "simple issue branch", branch: "gt-xyz", wantIssue: "gt-xyz", wantWorker: "", }, { name: "feature branch with issue", branch: "feature/gt-abc-impl", wantIssue: "gt-abc", wantWorker: "", }, { name: "no issue pattern", branch: "main", wantIssue: "", wantWorker: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { info := parseBranchName(tt.branch) if info.Issue != tt.wantIssue { t.Errorf("parseBranchName() Issue = %q, want %q", info.Issue, tt.wantIssue) } if info.Worker != tt.wantWorker { t.Errorf("parseBranchName() Worker = %q, want %q", info.Worker, tt.wantWorker) } }) } } func TestFormatMRAge(t *testing.T) { tests := []struct { name string createdAt string wantOk bool // just check it doesn't panic/error }{ { name: "RFC3339 format", createdAt: "2025-01-01T12:00:00Z", wantOk: true, }, { name: "alternative format", createdAt: "2025-01-01T12:00:00", wantOk: true, }, { name: "invalid format", createdAt: "not-a-date", wantOk: true, // returns "?" for invalid }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := formatMRAge(tt.createdAt) if tt.wantOk && result == "" { t.Errorf("formatMRAge() returned empty for %s", tt.createdAt) } }) } } func TestGetDescriptionWithoutMRFields(t *testing.T) { tests := []struct { name string description string want string }{ { name: "empty description", description: "", want: "", }, { name: "only MR fields", description: "branch: polecat/Nux/gt-xyz\ntarget: main\nworker: Nux", want: "", }, { name: "mixed content", description: "branch: polecat/Nux/gt-xyz\nSome custom notes\ntarget: main", want: "Some custom notes", }, { name: "no MR fields", description: "Just a regular description\nWith multiple lines", want: "Just a regular description\nWith multiple lines", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := getDescriptionWithoutMRFields(tt.description) if got != tt.want { t.Errorf("getDescriptionWithoutMRFields() = %q, want %q", got, tt.want) } }) } } func TestTruncateString(t *testing.T) { tests := []struct { name string s string maxLen int want string }{ { name: "short string", s: "hello", maxLen: 10, want: "hello", }, { name: "exact length", s: "hello", maxLen: 5, want: "hello", }, { name: "needs truncation", s: "hello world", maxLen: 8, want: "hello...", }, { name: "very short max", s: "hello", maxLen: 3, want: "hel", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := truncateString(tt.s, tt.maxLen) if got != tt.want { t.Errorf("truncateString() = %q, want %q", got, tt.want) } }) } } func TestFormatStatus(t *testing.T) { tests := []struct { name string status string want string // We check for substring since styling adds ANSI codes }{ { name: "open status", status: "open", want: "open", }, { name: "in_progress status", status: "in_progress", want: "in_progress", }, { name: "closed status", status: "closed", want: "closed", }, { name: "unknown status", status: "pending", want: "pending", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := formatStatus(tt.status) if got == "" { t.Errorf("formatStatus(%q) returned empty string", tt.status) } // The result contains ANSI codes, so just check the status text is present if !contains(got, tt.want) { t.Errorf("formatStatus(%q) = %q, should contain %q", tt.status, got, tt.want) } }) } } func TestGetStatusIcon(t *testing.T) { tests := []struct { status string want string }{ {"open", "○"}, {"in_progress", "▶"}, {"closed", "✓"}, {"unknown", "•"}, {"", "•"}, } for _, tt := range tests { t.Run(tt.status, func(t *testing.T) { got := getStatusIcon(tt.status) if got != tt.want { t.Errorf("getStatusIcon(%q) = %q, want %q", tt.status, got, tt.want) } }) } } func TestFormatTimeAgo(t *testing.T) { tests := []struct { name string timestamp string wantEmpty bool }{ { name: "RFC3339 format", timestamp: "2025-01-01T12:00:00Z", wantEmpty: false, }, { name: "RFC3339 with timezone", timestamp: "2025-01-01T12:00:00-08:00", wantEmpty: false, }, { name: "date only format", timestamp: "2025-01-01", wantEmpty: false, }, { name: "datetime without Z", timestamp: "2025-01-01T12:00:00", wantEmpty: false, }, { name: "invalid format returns empty", timestamp: "not-a-date", wantEmpty: true, }, { name: "empty string returns empty", timestamp: "", wantEmpty: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := formatTimeAgo(tt.timestamp) if tt.wantEmpty && got != "" { t.Errorf("formatTimeAgo(%q) = %q, want empty", tt.timestamp, got) } if !tt.wantEmpty && got == "" { t.Errorf("formatTimeAgo(%q) returned empty, want non-empty", tt.timestamp) } }) } } // contains checks if s contains substr (helper for styled output) func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(substr) == 0 || (len(s) > 0 && len(substr) > 0 && stringContains(s, substr))) } func stringContains(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return true } } return false } func TestFilterMRsByTarget(t *testing.T) { // Create test MRs with different targets mrs := []*beads.Issue{ makeTestMR("mr-1", "polecat/Nux/gt-001", "integration/gt-epic", "Nux", "open"), makeTestMR("mr-2", "polecat/Toast/gt-002", "main", "Toast", "open"), makeTestMR("mr-3", "polecat/Able/gt-003", "integration/gt-epic", "Able", "open"), makeTestMR("mr-4", "polecat/Baker/gt-004", "integration/gt-other", "Baker", "open"), } tests := []struct { name string targetBranch string wantCount int wantIDs []string }{ { name: "filter to integration/gt-epic", targetBranch: "integration/gt-epic", wantCount: 2, wantIDs: []string{"mr-1", "mr-3"}, }, { name: "filter to main", targetBranch: "main", wantCount: 1, wantIDs: []string{"mr-2"}, }, { name: "filter to non-existent branch", targetBranch: "integration/no-such-epic", wantCount: 0, wantIDs: []string{}, }, { name: "filter to other integration branch", targetBranch: "integration/gt-other", wantCount: 1, wantIDs: []string{"mr-4"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := filterMRsByTarget(mrs, tt.targetBranch) if len(got) != tt.wantCount { t.Errorf("filterMRsByTarget() returned %d MRs, want %d", len(got), tt.wantCount) } // Verify correct IDs gotIDs := make(map[string]bool) for _, mr := range got { gotIDs[mr.ID] = true } for _, wantID := range tt.wantIDs { if !gotIDs[wantID] { t.Errorf("filterMRsByTarget() missing expected MR %s", wantID) } } }) } } func TestFilterMRsByTarget_EmptyInput(t *testing.T) { got := filterMRsByTarget(nil, "integration/gt-epic") if got != nil { t.Errorf("filterMRsByTarget(nil) = %v, want nil", got) } got = filterMRsByTarget([]*beads.Issue{}, "integration/gt-epic") if len(got) != 0 { t.Errorf("filterMRsByTarget([]) = %v, want empty slice", got) } } func TestFilterMRsByTarget_NoMRFields(t *testing.T) { // Issue without MR fields in description plainIssue := &beads.Issue{ ID: "issue-1", Title: "Not an MR", Type: "merge-request", Status: "open", Description: "Just a plain description with no MR fields", } got := filterMRsByTarget([]*beads.Issue{plainIssue}, "main") if len(got) != 0 { t.Errorf("filterMRsByTarget() should filter out issues without MR fields, got %d", len(got)) } } // Tests for configurable integration branch naming (Issue #104) func TestBuildIntegrationBranchName(t *testing.T) { tests := []struct { name string template string epicID string want string }{ { name: "default template", template: "", epicID: "RA-123", want: "integration/RA-123", }, { name: "explicit default template", template: "integration/{epic}", epicID: "PROJ-456", want: "integration/PROJ-456", }, { name: "custom template with prefix", template: "{prefix}/{epic}", epicID: "RA-123", want: "RA/RA-123", }, { name: "complex template", template: "feature/{prefix}/work/{epic}", epicID: "PROJ-789", want: "feature/PROJ/work/PROJ-789", }, { name: "epic without hyphen", template: "{prefix}/{epic}", epicID: "epicname", want: "epicname/epicname", }, { name: "user variable left as-is without git config", template: "{user}/{epic}", epicID: "RA-123", // Note: {user} is replaced with git user.name if available, // otherwise left as placeholder. In tests, it depends on git config. want: "", // We'll check pattern instead }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := buildIntegrationBranchName(tt.template, tt.epicID) if tt.want == "" { // For user variable test, just check {epic} was replaced if stringContains(got, "{epic}") { t.Errorf("buildIntegrationBranchName() = %q, should have replaced {epic}", got) } } else if got != tt.want { t.Errorf("buildIntegrationBranchName() = %q, want %q", got, tt.want) } }) } } func TestExtractEpicPrefix(t *testing.T) { tests := []struct { epicID string want string }{ {"RA-123", "RA"}, {"PROJ-456", "PROJ"}, {"gt-auth-epic", "gt"}, {"epicname", "epicname"}, {"X-1", "X"}, {"-123", "-123"}, // No prefix before hyphen, return full string {"", ""}, } for _, tt := range tests { t.Run(tt.epicID, func(t *testing.T) { got := extractEpicPrefix(tt.epicID) if got != tt.want { t.Errorf("extractEpicPrefix(%q) = %q, want %q", tt.epicID, got, tt.want) } }) } } func TestValidateBranchName(t *testing.T) { tests := []struct { name string branchName string wantErr bool }{ { name: "valid simple branch", branchName: "integration/gt-epic", wantErr: false, }, { name: "valid nested branch", branchName: "user/project/feature", wantErr: false, }, { name: "valid with hyphens and underscores", branchName: "user-name/feature_branch", wantErr: false, }, { name: "empty branch name", branchName: "", wantErr: true, }, { name: "contains tilde", branchName: "branch~1", wantErr: true, }, { name: "contains caret", branchName: "branch^2", wantErr: true, }, { name: "contains colon", branchName: "branch:ref", wantErr: true, }, { name: "contains space", branchName: "branch name", wantErr: true, }, { name: "contains backslash", branchName: "branch\\name", wantErr: true, }, { name: "contains double dot", branchName: "branch..name", wantErr: true, }, { name: "contains at-brace", branchName: "branch@{name}", wantErr: true, }, { name: "ends with .lock", branchName: "branch.lock", wantErr: true, }, { name: "starts with slash", branchName: "/branch", wantErr: true, }, { name: "ends with slash", branchName: "branch/", wantErr: true, }, { name: "starts with dot", branchName: ".branch", wantErr: true, }, { name: "ends with dot", branchName: "branch.", wantErr: true, }, { name: "consecutive slashes", branchName: "branch//name", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validateBranchName(tt.branchName) if (err != nil) != tt.wantErr { t.Errorf("validateBranchName(%q) error = %v, wantErr %v", tt.branchName, err, tt.wantErr) } }) } } func TestGetIntegrationBranchField(t *testing.T) { tests := []struct { name string description string want string }{ { name: "empty description", description: "", want: "", }, { name: "field at beginning", description: "integration_branch: klauern/PROJ-123/RA-epic\nSome description", want: "klauern/PROJ-123/RA-epic", }, { name: "field in middle", description: "Some text\nintegration_branch: custom/branch\nMore text", want: "custom/branch", }, { name: "field with extra whitespace", description: " integration_branch: spaced/branch \nOther content", want: "spaced/branch", }, { name: "no integration_branch field", description: "Just a plain description\nWith multiple lines", want: "", }, { name: "mixed case field name", description: "Integration_branch: CamelCase/branch", want: "CamelCase/branch", }, { name: "default format", description: "integration_branch: integration/gt-epic\nEpic for auth work", want: "integration/gt-epic", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := getIntegrationBranchField(tt.description) if got != tt.want { t.Errorf("getIntegrationBranchField() = %q, want %q", got, tt.want) } }) } }