Skip to content

Commit 519bdf9

Browse files
committed
feat: integrate GitHub Models API for commit message generation
- Added `internal/llm` package to interact with the GitHub Models API. - Implemented `Client` for API communication. - Added support for loading prompt configurations from YAML. - Integrated message generation using GPT-based models. - Updated `cmd/commitmsg/main.go`: - Integrated LLM client to generate commit messages from staged changes. - Improved error handling and user feedback. - Updated `internal/git` package: - Translated comments and error messages to English for consistency. - Enhanced function documentation. - Added dependencies in `go.mod` and `go.sum` for GitHub Models API and YAML parsing. - Rewrote `README.md` in English for broader accessibility. - Introduced `commitmsg.prompt.yml` for customizable prompt configurations.
1 parent 4828f1a commit 519bdf9

File tree

7 files changed

+277
-18
lines changed

7 files changed

+277
-18
lines changed

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# gh-commitmsg
22

3-
Идея приложения:
4-
- получить staged changes в git репозитории в текущей директории
5-
- скормить полученные данные LLM GitHub Models для генерации conventional commit message
6-
- вывести полученный commit message на экран
7-
- если будет хорошо получаться, можно в качестве примера для модели давать несколько предыдущих commit messages
3+
Tool idea:
4+
- get staged changes in the git repository in the current directory
5+
- feed the obtained data to the GitHub Models LLM to generate a conventional commit message
6+
- display the generated commit message on the screen
7+
- if it works well, we can provide several previous commit messages as examples for the model

cmd/commitmsg/main.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,39 @@
1+
// CLI tool to get staged changes in a git repository and print them to the console
12
package main
23

34
import (
45
"fmt"
56
"os"
6-
"strings"
77

88
"github.com/hazadus/gh-commitmsg/internal/git"
9+
"github.com/hazadus/gh-commitmsg/internal/llm"
910
)
1011

1112
func main() {
1213
stagedChanges, err := git.GetStagedChanges()
1314
if err != nil {
14-
fmt.Printf("Ошибка при получении staged изменений: %v\n", err)
15+
fmt.Printf("Error retrieving staged changes: %v\n", err)
1516
os.Exit(1)
1617
}
1718

1819
if stagedChanges == "" {
19-
fmt.Println("Нет staged изменений в репозитории.")
20+
fmt.Println("No staged changes in the repository.")
2021
return
2122
}
2223

23-
fmt.Println("Staged изменения:")
24-
fmt.Println(strings.Repeat("-", 50))
25-
fmt.Print(stagedChanges)
24+
llmClient, err := llm.NewClient()
25+
if err != nil {
26+
fmt.Printf("Error creating LLM client: %v\n", err)
27+
os.Exit(1)
28+
}
29+
30+
commitMsg, err := llmClient.GenerateCommitMessage(stagedChanges)
31+
if err != nil {
32+
fmt.Printf("Error generating commit message: %v\n", err)
33+
os.Exit(1)
34+
}
35+
36+
fmt.Println("Generated commit message:")
37+
fmt.Println(commitMsg)
2638
}
2739

go.mod

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
11
module github.com/hazadus/gh-commitmsg
22

33
go 1.23.4
4+
5+
require (
6+
github.com/cli/go-gh/v2 v2.12.1
7+
gopkg.in/yaml.v3 v3.0.1
8+
)
9+
10+
require (
11+
github.com/cli/safeexec v1.0.0 // indirect
12+
github.com/kr/pretty v0.3.1 // indirect
13+
)

go.sum

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
github.com/cli/go-gh/v2 v2.12.1 h1:SVt1/afj5FRAythyMV3WJKaUfDNsxXTIe7arZbwTWKA=
2+
github.com/cli/go-gh/v2 v2.12.1/go.mod h1:+5aXmEOJsH9fc9mBHfincDwnS02j2AIA/DsTH0Bk5uw=
3+
github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI=
4+
github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
5+
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
6+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
7+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
8+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
9+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
10+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
11+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
12+
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
13+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
14+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
15+
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
16+
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
17+
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
18+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
19+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
20+
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
21+
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
22+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
23+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/git/git.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,29 @@
1-
// Package git предоставляет функции для работы с git репозиториями
1+
// Package git contains functions to interact with git repositories
22
package git
33

44
import (
55
"fmt"
66
"os/exec"
77
)
88

9-
// GetStagedChanges выполняет команду git diff --staged и возвращает её вывод
9+
// GetStagedChanges executes the command git diff --staged and returns its output
1010
func GetStagedChanges() (string, error) {
11-
// Проверяем, что мы находимся в git репозитории
11+
// Check if we are in a git repository
1212
if !isGitRepository() {
13-
return "", fmt.Errorf("текущая директория не является git репозиторием")
13+
return "", fmt.Errorf("current directory is not a git repository")
1414
}
1515

16-
// Выполняем команду git diff --staged
16+
// Execute the command git diff --staged
1717
cmd := exec.Command("git", "diff", "--staged")
1818
output, err := cmd.Output()
1919
if err != nil {
20-
return "", fmt.Errorf("ошибка при выполнении git diff --staged: %v", err)
20+
return "", fmt.Errorf("error executing git diff --staged: %v", err)
2121
}
2222

2323
return string(output), nil
2424
}
2525

26-
// isGitRepository проверяет, является ли текущая директория git репозиторием
26+
// isGitRepository checks if the current directory is a git repository
2727
func isGitRepository() bool {
2828
cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree")
2929
err := cmd.Run()

internal/llm/client.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Package llm contains utilities for working with GitHub Models API
2+
package llm
3+
4+
import (
5+
"bytes"
6+
_ "embed"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"strings"
12+
"time"
13+
14+
"github.com/cli/go-gh/v2/pkg/auth"
15+
"gopkg.in/yaml.v3"
16+
)
17+
18+
//go:embed commitmsg.prompt.yml
19+
var standupPromptYAML []byte
20+
21+
// PromptConfig represents the structure of the prompt configuration file
22+
// It includes the model parameters and the messages to be sent to the model.
23+
type PromptConfig struct {
24+
Name string `yaml:"name"`
25+
Description string `yaml:"description"`
26+
Model string `yaml:"model"`
27+
ModelParameters ModelParameters `yaml:"modelParameters"`
28+
Messages []PromptMessage `yaml:"messages"`
29+
}
30+
31+
// ModelParameters defines the parameters for the model
32+
type ModelParameters struct {
33+
Temperature float64 `yaml:"temperature"`
34+
TopP float64 `yaml:"topP"`
35+
}
36+
37+
// PromptMessage represents a single message in the prompt configuration
38+
type PromptMessage struct {
39+
Role string `yaml:"role"`
40+
Content string `yaml:"content"`
41+
}
42+
43+
// Request represents the structure of the request to the GitHub Models API
44+
type Request struct {
45+
Messages []Message `json:"messages"`
46+
Model string `json:"model"`
47+
Temperature float64 `json:"temperature"`
48+
TopP float64 `json:"top_p"`
49+
Stream bool `json:"stream"`
50+
}
51+
52+
// Message represents a single message in the request to the GitHub Models API
53+
type Message struct {
54+
Role string `json:"role"`
55+
Content string `json:"content"`
56+
}
57+
58+
// Response represents the structure of the response from the GitHub Models API
59+
type Response struct {
60+
Choices []struct {
61+
Message struct {
62+
Content string `json:"content"`
63+
} `json:"message"`
64+
} `json:"choices"`
65+
}
66+
67+
// Client is a wrapper around the GitHub Models API client
68+
type Client struct {
69+
token string
70+
}
71+
72+
// NewClient initializes a new GitHub Models API client
73+
// It retrieves the GitHub token from the environment installed by the `gh` CLI tool.
74+
func NewClient() (*Client, error) {
75+
fmt.Print(" Checking GitHub token... ")
76+
77+
host, _ := auth.DefaultHost()
78+
token, _ := auth.TokenForHost(host) // check GH_TOKEN, GITHUB_TOKEN, keychain, etc
79+
80+
if token == "" {
81+
fmt.Println("Failed")
82+
return nil, fmt.Errorf("no GitHub token found, please run 'gh auth login' to authenticate")
83+
}
84+
fmt.Println("Done")
85+
86+
return &Client{token: token}, nil
87+
}
88+
89+
// GenerateCommitMessage generates a commit message based on the provided changes summary
90+
func (c *Client) GenerateCommitMessage(changesSummary string) (string, error) {
91+
fmt.Print(" Loading prompt configuration... ")
92+
promptConfig, err := loadPromptConfig()
93+
if err != nil {
94+
fmt.Println("Failed")
95+
return "", err
96+
}
97+
fmt.Println("Done")
98+
99+
selectedModel := promptConfig.Model
100+
101+
// Build messages from the prompt config, replacing template variables
102+
messages := make([]Message, len(promptConfig.Messages))
103+
for i, msg := range promptConfig.Messages {
104+
content := msg.Content
105+
// Replace the {{changes}} template variable
106+
content = strings.ReplaceAll(content, "{{changes}}", changesSummary)
107+
108+
messages[i] = Message{
109+
Role: msg.Role,
110+
Content: content,
111+
}
112+
}
113+
114+
request := Request{
115+
Messages: messages,
116+
Model: selectedModel,
117+
Temperature: promptConfig.ModelParameters.Temperature,
118+
TopP: promptConfig.ModelParameters.TopP,
119+
Stream: false,
120+
}
121+
122+
fmt.Printf(" Calling GitHub Models API (%s)... ", selectedModel)
123+
response, err := c.callGitHubModels(request)
124+
if err != nil {
125+
fmt.Println("Failed")
126+
return "", err
127+
}
128+
fmt.Println("Done")
129+
130+
if len(response.Choices) == 0 {
131+
return "", fmt.Errorf("no response generated from the model")
132+
}
133+
134+
return strings.TrimSpace(response.Choices[0].Message.Content), nil
135+
}
136+
137+
func loadPromptConfig() (*PromptConfig, error) {
138+
var config PromptConfig
139+
err := yaml.Unmarshal(standupPromptYAML, &config)
140+
if err != nil {
141+
return nil, fmt.Errorf("failed to parse prompt configuration: %w", err)
142+
}
143+
return &config, nil
144+
}
145+
146+
// callGitHubModels makes the API call to GitHub Models
147+
func (c *Client) callGitHubModels(request Request) (*Response, error) {
148+
jsonData, err := json.Marshal(request)
149+
if err != nil {
150+
return nil, fmt.Errorf("failed to marshal request: %w", err)
151+
}
152+
153+
req, err := http.NewRequest("POST", "https://models.github.ai/inference/chat/completions", bytes.NewBuffer(jsonData))
154+
if err != nil {
155+
return nil, fmt.Errorf("failed to create request: %w", err)
156+
}
157+
158+
req.Header.Set("Content-Type", "application/json")
159+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
160+
161+
client := &http.Client{Timeout: 30 * time.Second}
162+
resp, err := client.Do(req)
163+
if err != nil {
164+
return nil, fmt.Errorf("failed to make request: %w", err)
165+
}
166+
defer resp.Body.Close()
167+
168+
body, err := io.ReadAll(resp.Body)
169+
if err != nil {
170+
return nil, fmt.Errorf("failed to read response: %w", err)
171+
}
172+
173+
if resp.StatusCode != http.StatusOK {
174+
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
175+
}
176+
177+
var response Response
178+
err = json.Unmarshal(body, &response)
179+
if err != nil {
180+
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
181+
}
182+
183+
return &response, nil
184+
}

internal/llm/commitmsg.prompt.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: Commit Message Generator
2+
description: Generates professional commit messages based on git changes
3+
model: openai/gpt-4o
4+
messages:
5+
- role: system
6+
content: >
7+
You are an AI assistant helping to generate professional commit messages.
8+
9+
Your task is to create a concise, well-structured commit message that
10+
follows "conventional commits" style. The message should be clear,
11+
informative, and suitable for a professional software development context.
12+
13+
Guidelines:
14+
15+
- Keep it professional but conversational
16+
17+
- Focus on meaningful work rather than trivial changes
18+
19+
- Group related changes together
20+
21+
- Highlight significant contributions like new features, bug fixes
22+
23+
- Be concise but informative
24+
25+
- Use bullet points for clarity
26+
- role: user
27+
content: |
28+
Based on the following changes, generate a conventional commit message:
29+
30+
{{changes}}

0 commit comments

Comments
 (0)