From fc4f725e9f245de64de9512084836b2fd1982aa5 Mon Sep 17 00:00:00 2001 From: hev Date: Sat, 10 Jan 2026 06:59:18 -0700 Subject: [PATCH] Add GitHub issue support for prompt generation Implement support for starting a ralph loop from a GitHub issue: - Add internal/github package with issue parsing and prompt generation - Support issue formats: 42, #42, owner/repo#42, full URL - Auto-generate branch names from issue: ralph/issue-42-short-title - Add Fixes # to PR body when issue is specified - Add --issue/-i flag to CLI Co-Authored-By: Claude Opus 4.5 --- cmd/ralph/main.go | 51 ++++++++++ internal/config/config.go | 13 +++ internal/github/issue.go | 157 +++++++++++++++++++++++++++++ internal/github/issue_test.go | 148 +++++++++++++++++++++++++++ internal/github/prompt.go | 38 +++++++ internal/github/prompt_test.go | 120 ++++++++++++++++++++++ internal/runner/runner.go | 21 +++- internal/worktree/worktree.go | 43 ++++++++ internal/worktree/worktree_test.go | 145 ++++++++++++++++++++++++++ 9 files changed, 735 insertions(+), 1 deletion(-) create mode 100644 internal/github/issue.go create mode 100644 internal/github/issue_test.go create mode 100644 internal/github/prompt.go create mode 100644 internal/github/prompt_test.go diff --git a/cmd/ralph/main.go b/cmd/ralph/main.go index 7ac2508..ca27d4d 100644 --- a/cmd/ralph/main.go +++ b/cmd/ralph/main.go @@ -3,9 +3,11 @@ package main import ( "fmt" "os" + "path/filepath" "github.com/fatih/color" "github.com/hev/ralph/internal/config" + "github.com/hev/ralph/internal/github" "github.com/hev/ralph/internal/runner" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -82,6 +84,9 @@ func init() { rootCmd.Flags().StringVar(&cfg.PRTitle, "pr-title", cfg.PRTitle, "Custom title for the PR (empty = auto-generate)") rootCmd.Flags().StringVar(&cfg.PRBase, "pr-base", cfg.PRBase, "Base branch for the PR (empty = default branch)") + // GitHub issue options + rootCmd.Flags().StringVarP(&cfg.Issue, "issue", "i", cfg.Issue, "GitHub issue number or URL (generates prompt from issue)") + // Worktree options rootCmd.Flags().BoolVarP(&cfg.WorktreeEnabled, "worktree", "w", cfg.WorktreeEnabled, "Run in a git worktree") rootCmd.Flags().StringVarP(&cfg.WorktreeBranch, "branch", "b", cfg.WorktreeBranch, "Branch name for worktree (empty = auto-generate)") @@ -158,6 +163,8 @@ func init() { savedValues["pr-title"] = cfg.PRTitle case "pr-base": savedValues["pr-base"] = cfg.PRBase + case "issue": + savedValues["issue"] = cfg.Issue case "worktree": savedValues["worktree"] = cfg.WorktreeEnabled case "branch": @@ -249,6 +256,8 @@ func init() { cfg.PRTitle = val.(string) case "pr-base": cfg.PRBase = val.(string) + case "issue": + cfg.Issue = val.(string) case "worktree": cfg.WorktreeEnabled = val.(bool) case "branch": @@ -271,6 +280,40 @@ func init() { cfg.Verbose = false } + // Handle --issue flag: fetch issue and generate prompt + if cfg.Issue != "" { + // Check if -p was also provided (conflict) + if cmd.Flags().Changed("prompt") { + return fmt.Errorf("cannot use both --issue and --prompt flags") + } + + // Fetch the issue + issue, err := github.FetchIssueFromRef(cfg.Issue) + if err != nil { + return fmt.Errorf("failed to fetch issue: %w", err) + } + + // Store the issue info for later use (e.g., PR body, branch name) + cfg.IssueNumber = issue.Number + cfg.IssueTitle = issue.Title + cfg.IssueURL = issue.URL + + // Generate prompt content + promptContent := github.GeneratePrompt(issue) + + // Write to prompt.md + promptPath := filepath.Join(".", "prompt.md") + if err := os.WriteFile(promptPath, []byte(promptContent), 0644); err != nil { + return fmt.Errorf("failed to write prompt file: %w", err) + } + + // Update config to use the generated prompt + cfg.PromptFile = promptPath + + // Log that we generated the prompt from an issue + fmt.Printf("[ralph] Generated prompt from issue #%d: %s\n", issue.Number, issue.Title) + } + return nil } @@ -327,6 +370,10 @@ PR Options: --pr-title TITLE Custom title for the PR (default: auto-generate) --pr-base BRANCH Base branch for the PR (default: repo default) +GitHub Issue Options: + -i, --issue REF GitHub issue number or URL (generates prompt from issue) + Formats: 42, #42, owner/repo#42, or full URL + Worktree Options: -w, --worktree Run in a git worktree (default: false) -b, --branch NAME Branch name for worktree (default: auto-generate) @@ -356,6 +403,10 @@ Examples: ralph -s --code-review --cleanup # Full pipeline: work, review, cleanup ralph -s --pr # Stop on completion, create PR ralph -w -s --pr # Worktree + stop + PR (common pattern) + ralph -i 42 # Start loop from issue #42 + ralph -i owner/repo#42 # Issue from specific repo + ralph -i 42 -w # Issue + worktree (auto branch from issue) + ralph -i 42 -w --pr # Full workflow: issue -> worktree -> PR ralph --sound # Play Ralph Wiggum quotes after each iteration ralph --test-mode # Run in test mode (mock Claude) ralph --test-mode --slack-enabled # Test mode with Slack notifications diff --git a/internal/config/config.go b/internal/config/config.go index 83ffab8..bc9583b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -68,6 +68,12 @@ type Config struct { PRTitle string // Custom title for the PR (empty = auto-generate) PRBase string // Base branch for the PR (empty = default branch) + // GitHub issue options + Issue string // GitHub issue reference (number, URL, or owner/repo#number) + IssueNumber int // Parsed issue number (set internally) + IssueTitle string // Parsed issue title (set internally) + IssueURL string // Parsed issue URL (set internally) + // Prompt options ScratchpadPrompt string // Custom scratchpad instructions (appended to prompt) @@ -143,6 +149,8 @@ type yamlConfig struct { Base string `yaml:"base"` } `yaml:"pr"` + Issue string `yaml:"issue"` // GitHub issue reference + Sound struct { Enabled *bool `yaml:"enabled"` Mute *bool `yaml:"mute"` @@ -487,6 +495,11 @@ func (c *Config) LoadFromFile(path string) error { c.PRBase = yc.PR.Base } + // GitHub issue options + if yc.Issue != "" { + c.Issue = yc.Issue + } + // Sound options if yc.Sound.Enabled != nil { c.SoundEnabled = *yc.Sound.Enabled diff --git a/internal/github/issue.go b/internal/github/issue.go new file mode 100644 index 0000000..509da1b --- /dev/null +++ b/internal/github/issue.go @@ -0,0 +1,157 @@ +package github + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" +) + +// Issue represents a GitHub issue with its metadata +type Issue struct { + Number int + Title string + Body string + Labels []string + URL string + Assignees []string + Milestone string +} + +// ParseIssueRef parses an issue reference into owner, repo, and issue number +// Supported formats: +// - "42" or "#42" (issue number only, requires repo context) +// - "owner/repo#42" (short reference) +// - "https://github.com/owner/repo/issues/42" (full URL) +func ParseIssueRef(ref string) (owner, repo string, number int, err error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return "", "", 0, fmt.Errorf("empty issue reference") + } + + // Try full URL format: https://github.com/owner/repo/issues/42 + urlPattern := regexp.MustCompile(`^https?://github\.com/([^/]+)/([^/]+)/issues/(\d+)$`) + if matches := urlPattern.FindStringSubmatch(ref); matches != nil { + number, _ = strconv.Atoi(matches[3]) + return matches[1], matches[2], number, nil + } + + // Try short reference format: owner/repo#42 + shortPattern := regexp.MustCompile(`^([^/]+)/([^#]+)#(\d+)$`) + if matches := shortPattern.FindStringSubmatch(ref); matches != nil { + number, _ = strconv.Atoi(matches[3]) + return matches[1], matches[2], number, nil + } + + // Try issue number only: 42 or #42 + numberPattern := regexp.MustCompile(`^#?(\d+)$`) + if matches := numberPattern.FindStringSubmatch(ref); matches != nil { + number, _ = strconv.Atoi(matches[1]) + return "", "", number, nil + } + + return "", "", 0, fmt.Errorf("invalid issue reference format: %s", ref) +} + +// ghIssueResponse represents the JSON response from gh issue view +type ghIssueResponse struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + Labels []label `json:"labels"` + URL string `json:"url"` + Assignees []user `json:"assignees"` + Milestone *msInfo `json:"milestone"` +} + +type label struct { + Name string `json:"name"` +} + +type user struct { + Login string `json:"login"` +} + +type msInfo struct { + Title string `json:"title"` +} + +// FetchIssue fetches an issue from GitHub using the gh CLI +// If owner/repo are empty, uses the current repository context +func FetchIssue(owner, repo string, number int) (*Issue, error) { + // Check if gh CLI is available + if _, err := exec.LookPath("gh"); err != nil { + return nil, fmt.Errorf("gh CLI not found: please install GitHub CLI (https://cli.github.com)") + } + + // Build the gh issue view command + args := []string{"issue", "view", strconv.Itoa(number), "--json", "number,title,body,labels,url,assignees,milestone"} + + // Add repo flag if owner/repo are specified + if owner != "" && repo != "" { + args = append(args, "--repo", fmt.Sprintf("%s/%s", owner, repo)) + } + + cmd := exec.Command("gh", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + errMsg := strings.TrimSpace(stderr.String()) + if strings.Contains(errMsg, "Could not resolve to an issue") { + if owner != "" && repo != "" { + return nil, fmt.Errorf("issue #%d not found in %s/%s", number, owner, repo) + } + return nil, fmt.Errorf("issue #%d not found", number) + } + if strings.Contains(errMsg, "not a git repository") || strings.Contains(errMsg, "Could not resolve") { + return nil, fmt.Errorf("cannot determine repository context: use full URL or run from a git repo") + } + return nil, fmt.Errorf("failed to fetch issue: %s", errMsg) + } + + // Parse the JSON response + var ghResp ghIssueResponse + if err := json.Unmarshal(stdout.Bytes(), &ghResp); err != nil { + return nil, fmt.Errorf("failed to parse issue response: %w", err) + } + + // Convert to our Issue type + issue := &Issue{ + Number: ghResp.Number, + Title: ghResp.Title, + Body: ghResp.Body, + URL: ghResp.URL, + } + + // Extract label names + for _, l := range ghResp.Labels { + issue.Labels = append(issue.Labels, l.Name) + } + + // Extract assignee logins + for _, a := range ghResp.Assignees { + issue.Assignees = append(issue.Assignees, a.Login) + } + + // Extract milestone title + if ghResp.Milestone != nil { + issue.Milestone = ghResp.Milestone.Title + } + + return issue, nil +} + +// FetchIssueFromRef parses a reference and fetches the issue +// Convenience function that combines ParseIssueRef and FetchIssue +func FetchIssueFromRef(ref string) (*Issue, error) { + owner, repo, number, err := ParseIssueRef(ref) + if err != nil { + return nil, err + } + return FetchIssue(owner, repo, number) +} diff --git a/internal/github/issue_test.go b/internal/github/issue_test.go new file mode 100644 index 0000000..bf82786 --- /dev/null +++ b/internal/github/issue_test.go @@ -0,0 +1,148 @@ +package github + +import ( + "testing" +) + +func TestParseIssueRef(t *testing.T) { + tests := []struct { + name string + ref string + wantOwner string + wantRepo string + wantNumber int + wantErr bool + }{ + { + name: "issue number only", + ref: "42", + wantOwner: "", + wantRepo: "", + wantNumber: 42, + wantErr: false, + }, + { + name: "issue number with hash", + ref: "#42", + wantOwner: "", + wantRepo: "", + wantNumber: 42, + wantErr: false, + }, + { + name: "short reference", + ref: "owner/repo#42", + wantOwner: "owner", + wantRepo: "repo", + wantNumber: 42, + wantErr: false, + }, + { + name: "short reference with dashes", + ref: "my-org/my-repo#123", + wantOwner: "my-org", + wantRepo: "my-repo", + wantNumber: 123, + wantErr: false, + }, + { + name: "full URL https", + ref: "https://github.com/owner/repo/issues/42", + wantOwner: "owner", + wantRepo: "repo", + wantNumber: 42, + wantErr: false, + }, + { + name: "full URL http", + ref: "http://github.com/owner/repo/issues/42", + wantOwner: "owner", + wantRepo: "repo", + wantNumber: 42, + wantErr: false, + }, + { + name: "full URL with dashes", + ref: "https://github.com/my-org/my-repo/issues/999", + wantOwner: "my-org", + wantRepo: "my-repo", + wantNumber: 999, + wantErr: false, + }, + { + name: "whitespace trimmed", + ref: " 42 ", + wantOwner: "", + wantRepo: "", + wantNumber: 42, + wantErr: false, + }, + { + name: "empty string", + ref: "", + wantErr: true, + }, + { + name: "invalid format", + ref: "not-an-issue", + wantErr: true, + }, + { + name: "invalid URL", + ref: "https://github.com/owner/repo/pull/42", + wantErr: true, + }, + { + name: "incomplete short reference", + ref: "owner/repo", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner, repo, number, err := ParseIssueRef(tt.ref) + if (err != nil) != tt.wantErr { + t.Errorf("ParseIssueRef() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil { + return + } + if owner != tt.wantOwner { + t.Errorf("ParseIssueRef() owner = %v, want %v", owner, tt.wantOwner) + } + if repo != tt.wantRepo { + t.Errorf("ParseIssueRef() repo = %v, want %v", repo, tt.wantRepo) + } + if number != tt.wantNumber { + t.Errorf("ParseIssueRef() number = %v, want %v", number, tt.wantNumber) + } + }) + } +} + +func TestIssue_Fields(t *testing.T) { + issue := &Issue{ + Number: 42, + Title: "Test Issue", + Body: "This is the body", + Labels: []string{"bug", "help wanted"}, + URL: "https://github.com/owner/repo/issues/42", + Assignees: []string{"user1", "user2"}, + Milestone: "v1.0", + } + + if issue.Number != 42 { + t.Errorf("Issue.Number = %v, want 42", issue.Number) + } + if issue.Title != "Test Issue" { + t.Errorf("Issue.Title = %v, want 'Test Issue'", issue.Title) + } + if len(issue.Labels) != 2 { + t.Errorf("Issue.Labels length = %v, want 2", len(issue.Labels)) + } + if len(issue.Assignees) != 2 { + t.Errorf("Issue.Assignees length = %v, want 2", len(issue.Assignees)) + } +} diff --git a/internal/github/prompt.go b/internal/github/prompt.go new file mode 100644 index 0000000..5ef3b18 --- /dev/null +++ b/internal/github/prompt.go @@ -0,0 +1,38 @@ +package github + +import ( + "fmt" + "strings" +) + +// GeneratePrompt converts a GitHub issue into prompt.md content +func GeneratePrompt(issue *Issue) string { + var sb strings.Builder + + // Header with issue number and title + sb.WriteString(fmt.Sprintf("# Issue #%d: %s\n\n", issue.Number, issue.Title)) + + // Issue body + if issue.Body != "" { + sb.WriteString(issue.Body) + sb.WriteString("\n") + } + + // Metadata section + sb.WriteString("\n---\n") + sb.WriteString(fmt.Sprintf("Source: %s\n", issue.URL)) + + if len(issue.Labels) > 0 { + sb.WriteString(fmt.Sprintf("Labels: %s\n", strings.Join(issue.Labels, ", "))) + } + + if len(issue.Assignees) > 0 { + sb.WriteString(fmt.Sprintf("Assignees: %s\n", strings.Join(issue.Assignees, ", "))) + } + + if issue.Milestone != "" { + sb.WriteString(fmt.Sprintf("Milestone: %s\n", issue.Milestone)) + } + + return sb.String() +} diff --git a/internal/github/prompt_test.go b/internal/github/prompt_test.go new file mode 100644 index 0000000..1f61eb1 --- /dev/null +++ b/internal/github/prompt_test.go @@ -0,0 +1,120 @@ +package github + +import ( + "strings" + "testing" +) + +func TestGeneratePrompt(t *testing.T) { + tests := []struct { + name string + issue *Issue + contains []string + excludes []string + }{ + { + name: "full issue", + issue: &Issue{ + Number: 42, + Title: "Add new feature", + Body: "This is the issue description.\n\nWith multiple lines.", + Labels: []string{"enhancement", "help wanted"}, + URL: "https://github.com/owner/repo/issues/42", + Assignees: []string{"user1", "user2"}, + Milestone: "v1.0", + }, + contains: []string{ + "# Issue #42: Add new feature", + "This is the issue description.", + "With multiple lines.", + "Source: https://github.com/owner/repo/issues/42", + "Labels: enhancement, help wanted", + "Assignees: user1, user2", + "Milestone: v1.0", + }, + }, + { + name: "minimal issue", + issue: &Issue{ + Number: 1, + Title: "Bug fix", + Body: "", + URL: "https://github.com/owner/repo/issues/1", + }, + contains: []string{ + "# Issue #1: Bug fix", + "Source: https://github.com/owner/repo/issues/1", + }, + excludes: []string{ + "Labels:", + "Assignees:", + "Milestone:", + }, + }, + { + name: "issue with only labels", + issue: &Issue{ + Number: 5, + Title: "Documentation update", + Body: "Update the README", + Labels: []string{"docs"}, + URL: "https://github.com/owner/repo/issues/5", + }, + contains: []string{ + "# Issue #5: Documentation update", + "Update the README", + "Labels: docs", + }, + excludes: []string{ + "Assignees:", + "Milestone:", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GeneratePrompt(tt.issue) + + for _, want := range tt.contains { + if !strings.Contains(result, want) { + t.Errorf("GeneratePrompt() missing expected string %q\nGot:\n%s", want, result) + } + } + + for _, excluded := range tt.excludes { + if strings.Contains(result, excluded) { + t.Errorf("GeneratePrompt() should not contain %q\nGot:\n%s", excluded, result) + } + } + }) + } +} + +func TestGeneratePrompt_Format(t *testing.T) { + issue := &Issue{ + Number: 42, + Title: "Test Issue", + Body: "Body content", + URL: "https://github.com/owner/repo/issues/42", + } + + result := GeneratePrompt(issue) + + // Check that it starts with the header + if !strings.HasPrefix(result, "# Issue #42:") { + t.Errorf("Prompt should start with issue header, got: %s", result[:50]) + } + + // Check that it contains the separator + if !strings.Contains(result, "\n---\n") { + t.Error("Prompt should contain --- separator") + } + + // Check that Source comes after separator + separatorIdx := strings.Index(result, "\n---\n") + sourceIdx := strings.Index(result, "Source:") + if sourceIdx < separatorIdx { + t.Error("Source should come after the --- separator") + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 8a6ee09..dffb8e7 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -65,8 +65,16 @@ func Run(cfg *config.Config) error { if cfg.WorktreeEnabled { wtManager = worktree.NewManager(cfg.WorktreeBaseDir, cfg.WorktreeBranchPrefix, cfg.WorktreeCleanup) + // Determine branch name: explicit > issue-based > auto-generated + branchName := cfg.WorktreeBranch + if branchName == "" && cfg.IssueNumber > 0 { + // Generate branch name from issue + branchName = worktree.BranchNameFromIssue(cfg.WorktreeBranchPrefix, cfg.IssueNumber, cfg.IssueTitle) + logVerbose(cfg, "Auto-generated branch from issue: %s", branchName) + } + log("Creating worktree...") - worktreePath, err := wtManager.Create(cfg.WorktreeBranch) + worktreePath, err := wtManager.Create(branchName) if err != nil { logError("Failed to create worktree: %v", err) return err @@ -752,6 +760,12 @@ func generatePRBody(cfg *config.Config, tracker *metrics.Tracker, baseBranch str var body strings.Builder body.WriteString("## Summary\n\n") + + // Add issue reference if this PR was created from an issue + if cfg.IssueNumber > 0 { + body.WriteString(fmt.Sprintf("Fixes #%d\n\n", cfg.IssueNumber)) + } + body.WriteString("Changes made by Ralph automated loop.\n\n") // Add todo summary if available @@ -774,6 +788,11 @@ func generatePRBody(cfg *config.Config, tracker *metrics.Tracker, baseBranch str } } + // Add source issue link if available + if cfg.IssueURL != "" { + body.WriteString(fmt.Sprintf("\n## Source Issue\n\n[Issue #%d](%s)\n", cfg.IssueNumber, cfg.IssueURL)) + } + body.WriteString("\n---\n\n") body.WriteString("*Generated by [Ralph](https://github.com/hev/ralph)*\n") diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 5eae705..135e806 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "time" @@ -150,6 +151,48 @@ func sanitizeBranchName(branch string) string { return strings.ReplaceAll(branch, "/", "-") } +// BranchNameFromIssue generates a branch name from an issue number and title +// Format: ralph/issue-42-short-title (truncated to reasonable length) +func BranchNameFromIssue(prefix string, number int, title string) string { + // Sanitize the title for use in a branch name + slug := slugifyTitle(title) + + // Limit slug length to keep branch names reasonable + if len(slug) > 40 { + slug = slug[:40] + // Don't end with a dash + slug = strings.TrimSuffix(slug, "-") + } + + if slug == "" { + return fmt.Sprintf("%sissue-%d", prefix, number) + } + return fmt.Sprintf("%sissue-%d-%s", prefix, number, slug) +} + +// slugifyTitle converts a title to a URL/branch-safe slug +func slugifyTitle(title string) string { + // Convert to lowercase + slug := strings.ToLower(title) + + // Replace spaces and underscores with dashes + slug = strings.ReplaceAll(slug, " ", "-") + slug = strings.ReplaceAll(slug, "_", "-") + + // Remove any characters that aren't alphanumeric or dashes + reg := regexp.MustCompile(`[^a-z0-9-]`) + slug = reg.ReplaceAllString(slug, "") + + // Collapse multiple dashes into one + reg = regexp.MustCompile(`-+`) + slug = reg.ReplaceAllString(slug, "-") + + // Trim leading/trailing dashes + slug = strings.Trim(slug, "-") + + return slug +} + // List returns a list of all worktrees func List() ([]string, error) { cmd := exec.Command("git", "worktree", "list", "--porcelain") diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go index 081b2d8..2b5bd95 100644 --- a/internal/worktree/worktree_test.go +++ b/internal/worktree/worktree_test.go @@ -647,3 +647,148 @@ func TestManager_FullWorkflow(t *testing.T) { t.Error("Remove() did not remove worktree directory") } } + +func TestBranchNameFromIssue(t *testing.T) { + tests := []struct { + name string + prefix string + number int + title string + expected string + }{ + { + name: "simple title", + prefix: "ralph/", + number: 42, + title: "Add new feature", + expected: "ralph/issue-42-add-new-feature", + }, + { + name: "title with special characters", + prefix: "ralph/", + number: 123, + title: "Fix bug: handle @mentions & #hashtags!", + expected: "ralph/issue-123-fix-bug-handle-mentions-hashtags", + }, + { + name: "empty title", + prefix: "ralph/", + number: 1, + title: "", + expected: "ralph/issue-1", + }, + { + name: "title with underscores", + prefix: "feature/", + number: 99, + title: "update_config_file", + expected: "feature/issue-99-update-config-file", + }, + { + name: "very long title gets truncated", + prefix: "ralph/", + number: 42, + title: "This is a very long issue title that should be truncated to keep the branch name reasonable", + expected: "ralph/issue-42-this-is-a-very-long-issue-title-that-sho", + }, + { + name: "title with only special characters", + prefix: "ralph/", + number: 5, + title: "!@#$%^&*()", + expected: "ralph/issue-5", + }, + { + name: "title with unicode", + prefix: "ralph/", + number: 7, + title: "Add emoji support 🎉", + expected: "ralph/issue-7-add-emoji-support", + }, + { + name: "empty prefix", + prefix: "", + number: 10, + title: "No prefix test", + expected: "issue-10-no-prefix-test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := BranchNameFromIssue(tt.prefix, tt.number, tt.title) + if result != tt.expected { + t.Errorf("BranchNameFromIssue(%q, %d, %q) = %q, want %q", + tt.prefix, tt.number, tt.title, result, tt.expected) + } + }) + } +} + +func TestSlugifyTitle(t *testing.T) { + tests := []struct { + name string + title string + expected string + }{ + { + name: "simple words", + title: "Hello World", + expected: "hello-world", + }, + { + name: "with numbers", + title: "Version 2.0 release", + expected: "version-20-release", + }, + { + name: "multiple spaces", + title: "Multiple spaces here", + expected: "multiple-spaces-here", + }, + { + name: "leading and trailing spaces", + title: " trimmed ", + expected: "trimmed", + }, + { + name: "mixed case", + title: "CamelCase and UPPERCASE", + expected: "camelcase-and-uppercase", + }, + { + name: "special characters removed", + title: "Fix: bug #123 (urgent!)", + expected: "fix-bug-123-urgent", + }, + { + name: "underscores to dashes", + title: "snake_case_title", + expected: "snake-case-title", + }, + { + name: "empty string", + title: "", + expected: "", + }, + { + name: "only special chars", + title: "!@#$%", + expected: "", + }, + { + name: "preserves alphanumeric", + title: "abc123xyz", + expected: "abc123xyz", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := slugifyTitle(tt.title) + if result != tt.expected { + t.Errorf("slugifyTitle(%q) = %q, want %q", tt.title, result, tt.expected) + } + }) + } +}