Skip to content

Commit f5d58d2

Browse files
committed
feat ✨ (cmd): Add AI-powered commit message generation
Signed-off-by: Zine Moualhi 🇵🇸 <zmoualhi@outlook.com>
1 parent 48ed425 commit f5d58d2

File tree

9 files changed

+354
-8
lines changed

9 files changed

+354
-8
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
.DS_Store
22
.test
33
goji
4+
cat.sh
45
result
6+
*.txt

.goji.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,35 @@
11
{
2+
"aichoices": {
3+
"Phind": {
4+
"Model": "Phind-70B",
5+
"ApiKey": ""
6+
},
7+
"OpenAI": {
8+
"Model": "",
9+
"ApiKey": ""
10+
},
11+
"Groq": {
12+
"Model": "",
13+
"ApiKey": ""
14+
},
15+
"Claude": {
16+
"Model": "",
17+
"ApiKey": ""
18+
},
19+
"Ollama": {
20+
"Model": "",
21+
"ApiKey": ""
22+
},
23+
"OpenRouter": {
24+
"Model": "",
25+
"ApiKey": ""
26+
},
27+
"Deepseek": {
28+
"Model": "",
29+
"ApiKey": ""
30+
}
31+
},
32+
"aiprovider": "phind",
233
"noemoji": false,
334
"scopes": [
435
"home",

cmd/draft.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"regexp"
8+
"strings"
9+
10+
"github.com/muandane/goji/pkg/ai"
11+
"github.com/muandane/goji/pkg/config"
12+
"github.com/muandane/goji/pkg/git"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
var (
17+
commitDirectly bool
18+
)
19+
20+
var draftCmd = &cobra.Command{
21+
Use: "draft",
22+
Short: "Generate a commit message for staged changes using AI",
23+
Long: `This command connects to an AI provider (e.g., Phind) to generate a commit message based on your staged changes.`,
24+
Run: func(cmd *cobra.Command, args []string) {
25+
cfg, err := config.ViperConfig()
26+
if err != nil {
27+
fmt.Printf("Error loading config: %v\n", err)
28+
os.Exit(1)
29+
}
30+
31+
diff, err := git.GetStagedDiff()
32+
if err != nil {
33+
fmt.Printf("Error getting staged diff: %v\n", err)
34+
os.Exit(1)
35+
}
36+
37+
var provider ai.AIProvider
38+
switch cfg.AIProvider {
39+
case "phind":
40+
provider = ai.NewPhindProvider(cfg.AIChoices.Phind.Model)
41+
default:
42+
fmt.Printf("Unsupported AI provider: %s\n", cfg.AIProvider)
43+
os.Exit(1)
44+
}
45+
46+
aiCommitTypes := make(map[string]string)
47+
for _, t := range cfg.Types {
48+
aiCommitTypes[t.Name] = t.Description
49+
}
50+
51+
commitTypesJSON, err := json.Marshal(aiCommitTypes)
52+
if err != nil {
53+
fmt.Printf("Error marshaling commit types for AI: %v\n", err)
54+
os.Exit(1)
55+
}
56+
57+
fmt.Printf("Generating commit message using %s...\n", provider.GetModel())
58+
commitMessage, err := provider.GenerateCommitMessage(diff, string(commitTypesJSON))
59+
if err != nil {
60+
fmt.Printf("Error generating commit message: %v\n", err)
61+
os.Exit(1)
62+
}
63+
64+
// --- Modified logic for emoji and spacing ---
65+
finalCommitMessage := commitMessage
66+
if !cfg.NoEmoji { // Check if emojis are enabled
67+
// Regex to parse: <type>(<optional scope>): <message>
68+
// Group 1: type (e.g., "feat")
69+
// Group 2: full scope part (e.g., "(cmd)" or empty string)
70+
// Group 3: message content (e.g., "Add AI-powered commit message generation")
71+
re := regexp.MustCompile(`^([a-zA-Z]+)(\([^)]*\))?:\s*(.*)$`) // Regex now captures the full scope part including parentheses
72+
matches := re.FindStringSubmatch(commitMessage)
73+
74+
if len(matches) > 0 {
75+
commitType := matches[1]
76+
fullScopePart := matches[2] // This will be "(cmd)" or ""
77+
messagePart := matches[3]
78+
79+
var emoji string
80+
for _, t := range cfg.Types {
81+
if t.Name == commitType {
82+
emoji = t.Emoji
83+
break
84+
}
85+
}
86+
87+
if emoji != "" {
88+
var builder strings.Builder
89+
builder.WriteString(commitType)
90+
builder.WriteString(" ")
91+
builder.WriteString(emoji)
92+
builder.WriteString(" ")
93+
94+
// Append the full scope part if it exists
95+
if fullScopePart != "" {
96+
builder.WriteString(fullScopePart)
97+
}
98+
builder.WriteString(": ")
99+
builder.WriteString(strings.TrimSpace(messagePart))
100+
101+
finalCommitMessage = builder.String()
102+
}
103+
}
104+
}
105+
// --- End of modified logic ---
106+
107+
fmt.Println("--- Generated Commit Message ---")
108+
fmt.Print(finalCommitMessage)
109+
fmt.Println("\n------------------------------")
110+
111+
if commitDirectly {
112+
fmt.Println("Attempting to commit directly...")
113+
err := executeGitCommit(finalCommitMessage, "", cfg.SignOff)
114+
if err != nil {
115+
fmt.Printf("Error committing changes: %v\n", err)
116+
os.Exit(1)
117+
}
118+
fmt.Println("Successfully committed changes.")
119+
} else {
120+
fmt.Println("You can now manually commit with this message, or integrate it into your commit flow.")
121+
fmt.Println("To commit directly next time, use the `--commit` flag.")
122+
}
123+
},
124+
}
125+
126+
func init() {
127+
rootCmd.AddCommand(draftCmd)
128+
draftCmd.Flags().BoolVarP(&commitDirectly, "commit", "c", false, "Commit the generated message directly")
129+
}

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ func init() {
8080
rootCmd.Flags().BoolVar(&amendFlag, "amend", false, "Change last commit")
8181

8282
rootCmd.Flags().StringArrayVar(&gitFlags, "git-flag", []string{}, "Git flags (can be used multiple times)")
83+
rootCmd.AddCommand(draftCmd)
8384
}
8485

8586
func constructCommitMessage(cfg *config.Config, typeFlag, scopeFlag, messageFlag string) string {

pkg/ai/ai.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package ai
2+
3+
type AIProvider interface {
4+
GenerateCommitMessage(diff string, commitTypes string) (string, error)
5+
GetModel() string
6+
}

pkg/ai/phind.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package ai
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"strings"
10+
"time"
11+
)
12+
13+
const phindAPIURL = "https://https.extension.phind.com/agent/"
14+
const defaultPhindModel = "Phind-70B"
15+
16+
type PhindConfig struct {
17+
Model string
18+
}
19+
20+
type PhindProvider struct {
21+
config PhindConfig
22+
client *http.Client
23+
}
24+
25+
func NewPhindProvider(model string) *PhindProvider {
26+
if model == "" {
27+
model = defaultPhindModel
28+
}
29+
return &PhindProvider{
30+
config: PhindConfig{Model: model},
31+
client: &http.Client{Timeout: 30 * time.Second},
32+
}
33+
}
34+
35+
func (p *PhindProvider) GenerateCommitMessage(diff string, commitTypes string) (string, error) {
36+
// Construct the user prompt based on the Rust implementation's draft prompt
37+
userPrompt := fmt.Sprintf(`Generate a concise git commit message written in present tense for the following code diff with the given specifications below:
38+
The output response must be in format:
39+
<type>(<optional scope>): <commit message>
40+
Choose a type from the type-to-description JSON below that best describes the git diff:
41+
%s
42+
Focus on being accurate and concise.
43+
Commit message must be a maximum of 72 characters.
44+
Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.
45+
46+
Code diff:
47+
'diff
48+
%s
49+
'
50+
`, commitTypes, diff)
51+
52+
payload := map[string]interface{}{
53+
"additional_extension_context": "",
54+
"allow_magic_buttons": true,
55+
"is_vscode_extension": true,
56+
"message_history": []map[string]string{
57+
{
58+
"content": userPrompt,
59+
"role": "user",
60+
},
61+
},
62+
"requested_model": p.config.Model,
63+
"user_input": userPrompt,
64+
}
65+
66+
jsonPayload, err := json.Marshal(payload)
67+
if err != nil {
68+
return "", fmt.Errorf("failed to marshal payload: %w", err)
69+
}
70+
71+
req, err := http.NewRequest("POST", phindAPIURL, bytes.NewBuffer(jsonPayload))
72+
if err != nil {
73+
return "", fmt.Errorf("failed to create request: %w", err)
74+
}
75+
76+
req.Header.Set("Content-Type", "application/json")
77+
req.Header.Set("User-Agent", "")
78+
req.Header.Set("Accept", "*/*")
79+
req.Header.Set("Accept-Encoding", "Identity")
80+
81+
resp, err := p.client.Do(req)
82+
if err != nil {
83+
return "", fmt.Errorf("failed to send request to Phind: %w", err)
84+
}
85+
defer resp.Body.Close()
86+
87+
if resp.StatusCode != http.StatusOK {
88+
bodyBytes, _ := io.ReadAll(resp.Body)
89+
return "", fmt.Errorf("Phind API returned status %d: %s", resp.StatusCode, string(bodyBytes))
90+
}
91+
92+
bodyBytes, err := io.ReadAll(resp.Body)
93+
if err != nil {
94+
return "", fmt.Errorf("failed to read response body: %w", err)
95+
}
96+
97+
var fullContent strings.Builder
98+
lines := strings.Split(string(bodyBytes), "\n")
99+
for _, line := range lines {
100+
if strings.HasPrefix(line, "data: ") {
101+
data := strings.TrimPrefix(line, "data: ")
102+
var responseJson map[string]interface{}
103+
if err := json.Unmarshal([]byte(data), &responseJson); err != nil {
104+
continue // Skip malformed lines
105+
}
106+
if choices, ok := responseJson["choices"].([]interface{}); ok && len(choices) > 0 {
107+
if choice, ok := choices[0].(map[string]interface{}); ok {
108+
if delta, ok := choice["delta"].(map[string]interface{}); ok {
109+
if content, ok := delta["content"].(string); ok {
110+
fullContent.WriteString(content)
111+
}
112+
}
113+
}
114+
}
115+
}
116+
}
117+
if fullContent.Len() == 0 {
118+
return "", fmt.Errorf("no content found in Phind response")
119+
}
120+
121+
return fullContent.String(), nil
122+
}
123+
func (p *PhindProvider) GetModel() string {
124+
return p.config.Model
125+
}

pkg/config/configInit.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/spf13/viper"
1111
)
1212

13+
// AddCustomCommitTypes remains the same
1314
func AddCustomCommitTypes(gitmojis []Gitmoji) []Gitmoji {
1415
custom := []Gitmoji{
1516
{Emoji: "✨", Code: ":sparkles:", Description: "Introduce new features.", Name: "feat"},
@@ -24,29 +25,31 @@ func AddCustomCommitTypes(gitmojis []Gitmoji) []Gitmoji {
2425
{Emoji: "🚧", Code: ":construction:", Description: "Work in progress.", Name: "wip"},
2526
{Emoji: "📦", Code: ":package:", Description: "Add or update compiled files or packages.", Name: "package"},
2627
}
27-
2828
return append(gitmojis, custom...)
2929
}
3030

31+
// GetGitRootDir remains the same
3132
func GetGitRootDir() (string, error) {
3233
gitRoot := exec.Command("git", "rev-parse", "--show-toplevel")
3334
gitDirBytes, err := gitRoot.Output()
3435
if err != nil {
3536
return "", fmt.Errorf("error finding git root directory: %v", err)
3637
}
3738
gitDir := string(gitDirBytes)
38-
gitDir = strings.TrimSpace(gitDir) // Remove newline character at the end
39-
39+
gitDir = strings.TrimSpace(gitDir)
4040
return gitDir, nil
4141
}
4242

43+
// SaveConfigToFile function updated: Removed "commitTypes"
4344
func SaveConfigToFile(config initConfig, file, dir string) error {
4445
viper.Set("types", config.Types)
4546
viper.Set("scopes", config.Scopes)
4647
viper.Set("skipQuestions", config.SkipQuestions)
4748
viper.Set("subjectMaxLength", config.SubjectMaxLength)
4849
viper.Set("signOff", config.SignOff)
4950
viper.Set("noemoji", config.NoEmoji)
51+
viper.Set("aiProvider", config.AIProvider)
52+
viper.Set("aiChoices", config.AIChoices)
5053

5154
viper.SetConfigName(file)
5255
viper.SetConfigType("json")
@@ -55,19 +58,24 @@ func SaveConfigToFile(config initConfig, file, dir string) error {
5558
if err := viper.WriteConfigAs(path.Join(dir, file+".json")); err != nil {
5659
return fmt.Errorf("error writing config file: %v", err)
5760
}
58-
5961
return nil
6062
}
6163

64+
// InitRepoConfig function updated: Removed defaultAICommitTypes and its assignment
6265
func InitRepoConfig(global, repo bool) error {
63-
gitmojis := AddCustomCommitTypes([]Gitmoji{})
66+
gitmojis := AddCustomCommitTypes([]Gitmoji{}) // These are your main commit types
67+
6468
config := initConfig{
65-
Types: gitmojis,
69+
Types: gitmojis, // Use this for both interactive and AI
6670
Scopes: []string{"home", "accounts", "ci"},
6771
SkipQuestions: nil,
6872
SubjectMaxLength: 100,
6973
SignOff: true,
7074
NoEmoji: false,
75+
AIProvider: "phind",
76+
AIChoices: AIChoices{
77+
Phind: AIConfig{Model: "Phind-70B"},
78+
},
7179
}
7280

7381
var location string

0 commit comments

Comments
 (0)