Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions cmd/ralph/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand All @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down
157 changes: 157 additions & 0 deletions internal/github/issue.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading