Skip to content

Commit 4640cdf

Browse files
committed
- apps/lazycommit: add lazycommit pr <target-branch> command to generate 10 PR title suggestions based on diff against target branch
1 parent 15565b9 commit 4640cdf

File tree

8 files changed

+270
-2
lines changed

8 files changed

+270
-2
lines changed

README.md

Lines changed: 10 additions & 1 deletion
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 10 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
910
- Interactive config to pick provider/model and set keys
1011
- Simple output suitable for piping into TUI menus (one message per line)
@@ -28,6 +29,7 @@ go build -o lazycommit main.go
2829
- Root command: `lazycommit`
2930
- Subcommands:
3031
- `lazycommit commit` — prints 10 suggested commit messages to stdout, one per line, based on `git diff --cached`.
32+
- `lazycommit pr <target-branch>` — prints 10 suggested pull request titles to stdout, one per line, based on diff between current branch and `<target-branch>`.
3133
- `lazycommit config get` — prints the active provider and model.
3234
- `lazycommit config set` — interactive setup for provider, API key, and model.
3335

@@ -58,6 +60,12 @@ git add .
5860
lazycommit commit | fzf --prompt='Pick commit> ' | xargs -r -I {} git commit -m "{}"
5961
```
6062

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

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

9199
```yaml
92-
system_message: "You are a helpful assistant that generates git commit messages."
100+
system_message: "You are a helpful assistant that generates git commit messages, and pull request titles."
93101
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"
102+
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"
94103
```
95104
96105

cmd/pr.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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+
apiKey, err := config.GetAPIKey()
54+
if err != nil {
55+
fmt.Fprintf(os.Stderr, "Error getting API key: %v\n", err)
56+
os.Exit(1)
57+
}
58+
59+
var model string
60+
if providerName == "copilot" || providerName == "openai" {
61+
var err error
62+
model, err = config.GetModel()
63+
if err != nil {
64+
fmt.Fprintf(os.Stderr, "Error getting model: %v\n", err)
65+
os.Exit(1)
66+
}
67+
}
68+
69+
endpoint, err := config.GetEndpoint()
70+
if err != nil {
71+
fmt.Fprintf(os.Stderr, "Error getting endpoint: %v\n", err)
72+
os.Exit(1)
73+
}
74+
75+
switch providerName {
76+
case "copilot":
77+
aiProvider = provider.NewCopilotProviderWithModel(apiKey, model, endpoint)
78+
case "openai":
79+
aiProvider = provider.NewOpenAIProvider(apiKey, model, endpoint)
80+
default:
81+
// Default to copilot if provider is not set or unknown
82+
aiProvider = provider.NewCopilotProvider(apiKey, endpoint)
83+
}
84+
85+
prTitles, err := aiProvider.GeneratePRTitles(context.Background(), diff)
86+
if err != nil {
87+
fmt.Fprintf(os.Stderr, "Error generating pull request titles %v\n", err)
88+
os.Exit(1)
89+
}
90+
91+
if len(prTitles) == 0 {
92+
fmt.Println("No PR titles generated.")
93+
return
94+
}
95+
96+
for _, title := range prTitles {
97+
fmt.Println(title)
98+
}
99+
100+
},
101+
}
102+
103+
func init() {
104+
RootCmd.AddCommand(prCmd)
105+
}

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

@@ -106,3 +108,13 @@ func GetCommitMessagePromptFromConfig(diff string) string {
106108
// Fallback to hardcoded default
107109
return fmt.Sprintf("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", diff)
108110
}
111+
112+
// GetPRTitlePromptFromConfig returns the pull request title prompt from configuration
113+
func GetPRTitlePromptFromConfig(diff string) string {
114+
config := GetPromptConfig()
115+
if config.PRTitleTemplate != "" {
116+
return fmt.Sprintf(config.PRTitleTemplate, diff)
117+
}
118+
// Fallback to hardcoded default
119+
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)
120+
}

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/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+
}

internal/provider/copilot.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,73 @@ func (c *CopilotProvider) GenerateCommitMessages(ctx context.Context, diff strin
173173
}
174174
return out, nil
175175
}
176+
177+
func (c *CopilotProvider) GeneratePRTitle(ctx context.Context, diff string) (string, error) {
178+
titles, err := c.GeneratePRTitles(ctx, diff)
179+
if err != nil {
180+
return "", err
181+
}
182+
if len(titles) == 0 {
183+
return "", fmt.Errorf("no PR titles generated")
184+
}
185+
return titles[0], nil
186+
}
187+
188+
func (c *CopilotProvider) GeneratePRTitles(ctx context.Context, diff string) ([]string, error) {
189+
if strings.TrimSpace(diff) == "" {
190+
return nil, fmt.Errorf("no diff provided")
191+
}
192+
githubToken := c.getGitHubToken()
193+
if githubToken == "" {
194+
return nil, fmt.Errorf("GitHub token is required for Copilot provider")
195+
}
196+
197+
var bearer string
198+
var err error
199+
200+
// On Windows, use the token directly; on other platforms, exchange it for a Copilot token
201+
if runtime.GOOS == "windows" {
202+
bearer = githubToken
203+
} else {
204+
bearer, err = c.exchangeGitHubToken(ctx, githubToken)
205+
if err != nil {
206+
return nil, err
207+
}
208+
}
209+
210+
client := openai.NewClient(
211+
option.WithBaseURL(c.endpoint),
212+
option.WithAPIKey(bearer),
213+
option.WithHeader("Editor-Version", "lazycommit/1.0"),
214+
option.WithHeader("Editor-Plugin-Version", "lazycommit/1.0"),
215+
option.WithHeader("Copilot-Integration-Id", "vscode-chat"),
216+
)
217+
218+
params := openai.ChatCompletionNewParams{
219+
Model: openai.ChatModel(c.model),
220+
Messages: []openai.ChatCompletionMessageParamUnion{
221+
{OfSystem: &openai.ChatCompletionSystemMessageParam{Content: openai.ChatCompletionSystemMessageParamContentUnion{OfString: openai.String(GetSystemMessage())}}},
222+
{OfUser: &openai.ChatCompletionUserMessageParam{Content: openai.ChatCompletionUserMessageParamContentUnion{OfString: openai.String(GetPRTitlePrompt(diff))}}},
223+
},
224+
}
225+
226+
resp, err := client.Chat.Completions.New(ctx, params)
227+
if err != nil {
228+
return nil, fmt.Errorf("error making request to Copilot: %w", err)
229+
}
230+
if len(resp.Choices) == 0 {
231+
return nil, fmt.Errorf("no PR titles generated")
232+
}
233+
content := resp.Choices[0].Message.Content
234+
parts := strings.Split(content, "\n")
235+
var out []string
236+
for _, p := range parts {
237+
if s := strings.TrimSpace(p); s != "" {
238+
out = append(out, s)
239+
}
240+
}
241+
if len(out) == 0 {
242+
return nil, fmt.Errorf("no valid PR titles generated")
243+
}
244+
return out, nil
245+
}

internal/provider/openai.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,18 @@ func (o *OpenAIProvider) GenerateCommitMessage(ctx context.Context, diff string)
4848
func (o *OpenAIProvider) GenerateCommitMessages(ctx context.Context, diff string) ([]string, error) {
4949
return o.generateCommitMessages(ctx, diff)
5050
}
51+
52+
func (o *OpenAIProvider) GeneratePRTitle(ctx context.Context, diff string) (string, error) {
53+
titles, err := o.generatePRTitles(ctx, diff)
54+
if err != nil {
55+
return "", err
56+
}
57+
if len(titles) == 0 {
58+
return "", fmt.Errorf("no PR titles generated")
59+
}
60+
return titles[0], nil
61+
}
62+
63+
func (o *OpenAIProvider) GeneratePRTitles(ctx context.Context, diff string) ([]string, error) {
64+
return o.generatePRTitles(ctx, diff)
65+
}

internal/provider/prompts.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ func GetCommitMessagePrompt(diff string) string {
77
return config.GetCommitMessagePromptFromConfig(diff)
88
}
99

10+
// GetPRTitlePrompt returns the standardized prompt for generating pull request titles
11+
func GetPRTitlePrompt(diff string) string {
12+
return config.GetPRTitlePromptFromConfig(diff)
13+
}
14+
1015
// GetSystemMessage returns the standardized system message for commit message generation
1116
func GetSystemMessage() string {
1217
return config.GetSystemMessageFromConfig()

0 commit comments

Comments
 (0)