Skip to content

Commit 398fc74

Browse files
authored
Merge pull request #31 from imcquee/add-pull-request-suggestions
Add command to generate pr titles
2 parents 2b25029 + 0108208 commit 398fc74

File tree

9 files changed

+364
-5
lines changed

9 files changed

+364
-5
lines changed

README.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ AI-powered Git commit message generator that analyzes your staged changes and ou
55
## Features
66

77
- Generates configurable number of commit message suggestions from your staged diff
8+
- Generates 10 pull request titles based on the diff between the current branch and a target branch
89
- Providers: GitHub Copilot (default), OpenAI, Anthropic (Claude Code CLI)
910
- Multi-language support: English and Spanish
1011
- Interactive config to pick provider/model/language and set keys
@@ -28,9 +29,10 @@ go build -o lazycommit main.go
2829

2930
- Root command: `lazycommit`
3031
- Subcommands:
31-
- `lazycommit commit` — prints suggested commit messages to stdout, one per line, based on `git diff --cached`.
32-
- `lazycommit config get` — prints the active provider, model, and language.
33-
- `lazycommit config set` — interactive setup for provider, API key, model, and language.
32+
- `lazycommit commit` — prints 10 suggested commit messages to stdout, one per line, based on `git diff --cached`.
33+
- `lazycommit pr <target-branch>` — prints 10 suggested pull request titles to stdout, one per line, based on diff between current branch and `<target-branch>`.
34+
- `lazycommit config get` — prints the active provider, model and language.
35+
- `lazycommit config set` — interactive setup for provider, API key, model, and language.
3436

3537
Exit behaviors:
3638
- If no staged changes: prints "No staged changes to commit." and exits 0.
@@ -59,6 +61,12 @@ git add .
5961
lazycommit commit | fzf --prompt='Pick commit> ' | xargs -r -I {} git commit -m "{}"
6062
```
6163

64+
Generate PR titles against `main` branch:
65+
66+
```bash
67+
lazycommit pr main
68+
```
69+
6270
## Configuration
6371

6472
lazycommit uses a two-file configuration system to separate sensitive provider settings from shareable prompt configurations:
@@ -106,8 +114,9 @@ Contains prompt templates and message configurations. **Safe to share in dotfile
106114
This file is automatically created on first run with sensible defaults:
107115

108116
```yaml
109-
system_message: "You are a helpful assistant that generates git commit messages."
117+
system_message: "You are a helpful assistant that generates git commit messages, and pull request titles."
110118
commit_message_template: "Based on the following git diff, generate 10 conventional commit messages. Each message should be on a new line, without any numbering or bullet points:\n\n%s"
119+
pr_title_template: "Based on the following git diff, generate 10 pull request title suggestions. Each title should be on a new line, without any numbering or bullet points:\n\n%s"
111120
```
112121
113122

cmd/pr.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
8+
"github.com/m7medvision/lazycommit/internal/config"
9+
"github.com/m7medvision/lazycommit/internal/git"
10+
"github.com/m7medvision/lazycommit/internal/provider"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
// PrProvider defines the interface for generating pull request titles
15+
type PrProvider interface {
16+
GeneratePRTitle(ctx context.Context, diff string) (string, error)
17+
GeneratePRTitles(ctx context.Context, diff string) ([]string, error)
18+
}
19+
20+
// prCmd represents the pr command
21+
var prCmd = &cobra.Command{
22+
Use: "pr",
23+
Short: "Generate pull request title suggestions",
24+
Long: `Analyzes the diff of the current branch compared to a target branch, and generates a list of 10 suggested pull request titles.
25+
26+
Arguments:
27+
<target-branch> The branch to compare against (e.g., main, develop)`,
28+
Args: func(cmd *cobra.Command, args []string) error {
29+
if len(args) < 1 {
30+
return fmt.Errorf("missing required argument: <target-branch>")
31+
}
32+
if len(args) > 1 {
33+
return fmt.Errorf("too many arguments, expected 1 but got %d", len(args))
34+
}
35+
return nil
36+
},
37+
Example: "lazycommit pr main\n lazycommit pr develop",
38+
Run: func(cmd *cobra.Command, args []string) {
39+
diff, err := git.GetDiffAgainstBranch(args[0])
40+
if err != nil {
41+
fmt.Fprintf(os.Stderr, "Error getting branch comparison diff: %v\n", err)
42+
os.Exit(1)
43+
}
44+
45+
if diff == "" {
46+
fmt.Println("No changes compared to base branch.")
47+
return
48+
}
49+
50+
var aiProvider PrProvider
51+
52+
providerName := config.GetProvider()
53+
54+
// API key is not needed for anthropic provider (uses CLI)
55+
var apiKey string
56+
if providerName != "anthropic" {
57+
var err error
58+
apiKey, err = config.GetAPIKey()
59+
if err != nil {
60+
fmt.Fprintf(os.Stderr, "Error getting API key: %v\n", err)
61+
os.Exit(1)
62+
}
63+
}
64+
65+
var model string
66+
if providerName == "copilot" || providerName == "openai" || providerName == "anthropic" {
67+
var err error
68+
model, err = config.GetModel()
69+
if err != nil {
70+
fmt.Fprintf(os.Stderr, "Error getting model: %v\n", err)
71+
os.Exit(1)
72+
}
73+
}
74+
75+
endpoint, err := config.GetEndpoint()
76+
if err != nil {
77+
fmt.Fprintf(os.Stderr, "Error getting endpoint: %v\n", err)
78+
os.Exit(1)
79+
}
80+
81+
switch providerName {
82+
case "copilot":
83+
aiProvider = provider.NewCopilotProviderWithModel(apiKey, model, endpoint)
84+
case "openai":
85+
aiProvider = provider.NewOpenAIProvider(apiKey, model, endpoint)
86+
case "anthropic":
87+
// Get num_suggestions from config
88+
numSuggestions := config.GetNumSuggestions()
89+
aiProvider = provider.NewAnthropicProvider(model, numSuggestions)
90+
default:
91+
// Default to copilot if provider is not set or unknown
92+
aiProvider = provider.NewCopilotProvider(apiKey, endpoint)
93+
}
94+
95+
prTitles, err := aiProvider.GeneratePRTitles(context.Background(), diff)
96+
if err != nil {
97+
fmt.Fprintf(os.Stderr, "Error generating pull request titles %v\n", err)
98+
os.Exit(1)
99+
}
100+
101+
if len(prTitles) == 0 {
102+
fmt.Println("No PR titles generated.")
103+
return
104+
}
105+
106+
for _, title := range prTitles {
107+
fmt.Println(title)
108+
}
109+
110+
},
111+
}
112+
113+
func init() {
114+
RootCmd.AddCommand(prCmd)
115+
}

internal/config/prompts.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
type PromptConfig struct {
1212
SystemMessage string `yaml:"system_message"`
1313
CommitMessageTemplate string `yaml:"commit_message_template"`
14+
PRTitleTemplate string `yaml:"pr_title_template"`
1415
}
1516

1617
var promptsCfg *PromptConfig
@@ -60,8 +61,9 @@ func InitPromptConfig() {
6061
// getDefaultPromptConfig returns the default prompt configuration
6162
func getDefaultPromptConfig() *PromptConfig {
6263
return &PromptConfig{
63-
SystemMessage: "You are a helpful assistant that generates git commit messages.",
64+
SystemMessage: "You are a helpful assistant that generates git commit messages, and pull request titles.",
6465
CommitMessageTemplate: "Based on the following git diff, generate 10 conventional commit messages. Each message should be on a new line, without any numbering or bullet points:\n\n%s",
66+
PRTitleTemplate: "Based on the following git diff, generate 10 pull request title suggestions. Each title should be on a new line, without any numbering or bullet points:\n\n%s",
6567
}
6668
}
6769

@@ -118,3 +120,13 @@ func GetCommitMessagePromptFromConfig(diff string) string {
118120

119121
return basePrompt
120122
}
123+
124+
// GetPRTitlePromptFromConfig returns the pull request title prompt from configuration
125+
func GetPRTitlePromptFromConfig(diff string) string {
126+
config := GetPromptConfig()
127+
if config.PRTitleTemplate != "" {
128+
return fmt.Sprintf(config.PRTitleTemplate, diff)
129+
}
130+
// Fallback to hardcoded default
131+
return fmt.Sprintf("Based on the following git diff, generate 10 pull request title suggestions. Each title should be on a new line, without any numbering or bullet points:\n\n%s", diff)
132+
}

internal/git/git.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,24 @@ func GetStagedDiff() (string, error) {
1919
return out.String(), nil
2020
}
2121

22+
// GetDiffAgainstBranch returns the diff against the specified branch. For example "main" when creating a PR.
23+
func GetDiffAgainstBranch(branch string) (string, error) {
24+
// Check if the branch exists
25+
checkCmd := exec.Command("git", "rev-parse", "--verify", branch)
26+
if err := checkCmd.Run(); err != nil {
27+
return "", fmt.Errorf("branch '%s' does not exist", branch)
28+
}
29+
30+
cmd := exec.Command("git", "diff", branch)
31+
var out bytes.Buffer
32+
cmd.Stdout = &out
33+
err := cmd.Run()
34+
if err != nil {
35+
return "", fmt.Errorf("error running git diff %s: %w", branch, err)
36+
}
37+
return out.String(), nil
38+
}
39+
2240
// GetWorkingTreeDiff returns the diff of the working tree.
2341
func GetWorkingTreeDiff() (string, error) {
2442
cmd := exec.Command("git", "diff")

internal/provider/anthropic.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,84 @@ func (a *AnthropicProvider) GenerateCommitMessages(ctx context.Context, diff str
109109

110110
return commitMessages, nil
111111
}
112+
113+
func (a *AnthropicProvider) GeneratePRTitle(ctx context.Context, diff string) (string, error) {
114+
titles, err := a.GeneratePRTitles(ctx, diff)
115+
if err != nil {
116+
return "", err
117+
}
118+
if len(titles) == 0 {
119+
return "", fmt.Errorf("no PR titles generated")
120+
}
121+
return titles[0], nil
122+
}
123+
124+
func (a *AnthropicProvider) GeneratePRTitles(ctx context.Context, diff string) ([]string, error) {
125+
if strings.TrimSpace(diff) == "" {
126+
return nil, fmt.Errorf("no diff provided")
127+
}
128+
129+
// Check if claude CLI is available
130+
if _, err := exec.LookPath("claude"); err != nil {
131+
return nil, fmt.Errorf("claude CLI not found in PATH. Please install Claude Code CLI: %w", err)
132+
}
133+
134+
// Build the prompt using PR title template
135+
systemMsg := GetSystemMessage()
136+
userPrompt := GetPRTitlePrompt(diff)
137+
138+
// Modify the prompt to request specific number of suggestions
139+
fullPrompt := fmt.Sprintf("%s\n\nUser request: %s\n\nIMPORTANT: Generate exactly %d pull request titles, one per line. Do not include any other text, explanations, or formatting - just the PR titles.",
140+
systemMsg, userPrompt, a.numSuggestions)
141+
142+
// Execute claude CLI with the specified model
143+
cmd := exec.CommandContext(ctx, "claude", "--model", a.model, "-p", fullPrompt)
144+
145+
output, err := cmd.CombinedOutput()
146+
if err != nil {
147+
return nil, fmt.Errorf("error executing claude CLI: %w\nOutput: %s", err, string(output))
148+
}
149+
150+
// Parse the output - same logic as commit message generation
151+
content := string(output)
152+
lines := strings.Split(content, "\n")
153+
154+
var prTitles []string
155+
for _, line := range lines {
156+
trimmed := strings.TrimSpace(line)
157+
if trimmed == "" {
158+
continue
159+
}
160+
if len(trimmed) > 200 {
161+
continue
162+
}
163+
// Skip markdown formatting or numbered lists
164+
if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "-") || strings.HasPrefix(trimmed, "*") {
165+
parts := strings.SplitN(trimmed, " ", 2)
166+
if len(parts) == 2 {
167+
trimmed = strings.TrimSpace(parts[1])
168+
}
169+
}
170+
// Remove numbered list formatting like "1. " or "1) "
171+
if len(trimmed) > 3 {
172+
if (trimmed[0] >= '0' && trimmed[0] <= '9') && (trimmed[1] == '.' || trimmed[1] == ')') {
173+
trimmed = strings.TrimSpace(trimmed[2:])
174+
}
175+
}
176+
177+
if trimmed != "" {
178+
prTitles = append(prTitles, trimmed)
179+
}
180+
181+
// Stop once we have enough titles
182+
if len(prTitles) >= a.numSuggestions {
183+
break
184+
}
185+
}
186+
187+
if len(prTitles) == 0 {
188+
return nil, fmt.Errorf("no valid PR titles generated from Claude output")
189+
}
190+
191+
return prTitles, nil
192+
}

internal/provider/common.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,37 @@ func (c *commonProvider) generateCommitMessages(ctx context.Context, diff string
4747
}
4848
return cleanMessages, nil
4949
}
50+
51+
// generatePRTitles is a helper function to generate pull request titles using the OpenAI API.
52+
func (c *commonProvider) generatePRTitles(ctx context.Context, diff string) ([]string, error) {
53+
if diff == "" {
54+
return nil, fmt.Errorf("no diff provided")
55+
}
56+
57+
params := openai.ChatCompletionNewParams{
58+
Model: openai.ChatModel(c.model),
59+
Messages: []openai.ChatCompletionMessageParamUnion{
60+
{OfSystem: &openai.ChatCompletionSystemMessageParam{Content: openai.ChatCompletionSystemMessageParamContentUnion{OfString: openai.String(GetSystemMessage())}}},
61+
{OfUser: &openai.ChatCompletionUserMessageParam{Content: openai.ChatCompletionUserMessageParamContentUnion{OfString: openai.String(GetPRTitlePrompt(diff))}}},
62+
},
63+
}
64+
65+
resp, err := c.client.Chat.Completions.New(ctx, params)
66+
if err != nil {
67+
return nil, fmt.Errorf("error making request to OpenAI compatible API: %w", err)
68+
}
69+
70+
if len(resp.Choices) == 0 {
71+
return nil, fmt.Errorf("no pr titles generated")
72+
}
73+
74+
content := resp.Choices[0].Message.Content
75+
messages := strings.Split(content, "\n")
76+
var cleanMessages []string
77+
for _, msg := range messages {
78+
if strings.TrimSpace(msg) != "" {
79+
cleanMessages = append(cleanMessages, strings.TrimSpace(msg))
80+
}
81+
}
82+
return cleanMessages, nil
83+
}

0 commit comments

Comments
 (0)