ai_git 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +28 -0
- data/README.md +66 -0
- data/bin/ai_git +9 -1
- data/lib/ai_git/ai_client.rb +127 -0
- data/lib/ai_git/commands/config.rb +26 -0
- data/lib/ai_git/commands/default.rb +145 -0
- data/lib/ai_git/config.rb +28 -0
- data/lib/ai_git/git.rb +36 -6
- data/lib/ai_git/ui.rb +59 -0
- data/lib/ai_git/version.rb +1 -1
- data/lib/ai_git.rb +42 -7
- metadata +17 -8
- data/lib/ai_git/default.rb +0 -152
- data/lib/ai_git/review.rb +0 -148
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e698533dcbb5389f8ada1834ad78bbc65cbd6671e37874203de67c7376b11b51
|
|
4
|
+
data.tar.gz: 858e8d1de39b0034506da5fbd7858029e6ca77e922c551ceee4da1c2b4feb732
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 330b7566ca8ec91486faffd05ebefe651e543df74f39b796001d568793342cfa53689699baec35be9ec6a15b1f11505c60a06e17297e696593e752661465a6c2
|
|
7
|
+
data.tar.gz: 357d164f5a63cfb02bd173902bee1c098a3b24590a2347f33a97b0bff259c786c87aee1fca5e79937ccf7e916df9214d21cd280884370c877e520dd8dc2aea5b
|
data/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Kaíque Kandy Koga
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# AI Git
|
|
2
|
+
|
|
3
|
+
AI-powered Git commit messages using a local LLM
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
#### Requirements
|
|
8
|
+
|
|
9
|
+
- [llama.cpp](https://github.com/ggml-org/llama.cpp) running locally (e.g. `./llama-server --port 8080`)
|
|
10
|
+
|
|
11
|
+
#### Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
gem install ai_git
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
#### Environment Variables
|
|
18
|
+
|
|
19
|
+
| Variable | Description | Default |
|
|
20
|
+
|----------|-------------|---------|
|
|
21
|
+
| `AI_GIT_MODEL_NAME` | Model name | `ggml-org/gemma-4-E4B-it-GGUF:Q8_0` |
|
|
22
|
+
| `AI_GIT_BASE_URL` | Base URL of the llama.cpp server | `http://127.0.0.1:8080` |
|
|
23
|
+
| `NO_COLOR` | Disable colored terminal output when set | — |
|
|
24
|
+
|
|
25
|
+
Run `ai_git config` to see exactly which model, URL and endpoint are resolved from your environment.
|
|
26
|
+
|
|
27
|
+
ai_git talks to a local [llama.cpp](https://github.com/ggml-org/llama.cpp) server over its OpenAI-compatible
|
|
28
|
+
`/v1/chat/completions` endpoint. No API key is needed.
|
|
29
|
+
|
|
30
|
+
##### Example
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Start llama.cpp's server, then run ai_git with defaults
|
|
34
|
+
./llama-server --port 8080
|
|
35
|
+
ai_git
|
|
36
|
+
|
|
37
|
+
# Or point at a custom model/port
|
|
38
|
+
export AI_GIT_MODEL_NAME=my-model
|
|
39
|
+
export AI_GIT_BASE_URL=http://127.0.0.1:8081
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
#### Run
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
git add <files>
|
|
46
|
+
ai_git
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`ai_git` generates a commit message from your staged changes, commits, and
|
|
50
|
+
pushes to the current branch's upstream — no prompts, no flags.
|
|
51
|
+
|
|
52
|
+
#### Subcommands
|
|
53
|
+
|
|
54
|
+
| Subcommand | Description |
|
|
55
|
+
|------------|-------------|
|
|
56
|
+
| `ai_git` | Generate a commit message, commit, and push staged files |
|
|
57
|
+
| `ai_git config` | Show the resolved provider configuration |
|
|
58
|
+
| `ai_git --help` | Show usage |
|
|
59
|
+
| `ai_git --version` | Print version |
|
|
60
|
+
|
|
61
|
+
## Development
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
gem build ai_git.gemspec
|
|
65
|
+
gem install ./ai_git-$(ruby -r./lib/ai_git/version -e 'print AIGit::VERSION').gem
|
|
66
|
+
```
|
data/bin/ai_git
CHANGED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "config"
|
|
8
|
+
|
|
9
|
+
module AIGit
|
|
10
|
+
module AIClient
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
READ_TIMEOUT_SECONDS = 120
|
|
14
|
+
OPEN_TIMEOUT_SECONDS = 10
|
|
15
|
+
|
|
16
|
+
# Transient failures worth retrying with backoff.
|
|
17
|
+
MAX_ATTEMPTS = 3
|
|
18
|
+
RETRY_BASE_DELAY = 0.5
|
|
19
|
+
TRANSIENT_STATUSES = [408, 425, 429, 500, 502, 503, 504].freeze
|
|
20
|
+
RETRYABLE_ERRORS = [
|
|
21
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EPIPE,
|
|
22
|
+
Net::OpenTimeout, Net::ReadTimeout, SocketError, EOFError
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
OUTPUT_NOISE_PREFIXES = /^(Here|Output|Generated|Based on|The changes)/i.freeze
|
|
26
|
+
OUTPUT_NOISE_HEADERS = /^(Here is|The (commit message|review) is|```|json|markdown)/i.freeze
|
|
27
|
+
|
|
28
|
+
# Sends `prompt` to the local llama.cpp server and returns the cleaned text body.
|
|
29
|
+
def complete(prompt:, model_name:, temperature:)
|
|
30
|
+
sanitize(openai_complete(prompt, model_name, temperature))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def openai_complete(prompt, model_name, temperature)
|
|
34
|
+
body = {
|
|
35
|
+
model: model_name,
|
|
36
|
+
messages: [{ role: "user", content: prompt }],
|
|
37
|
+
stream: false,
|
|
38
|
+
temperature: temperature
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
data = post_json(body)
|
|
42
|
+
data.dig("choices", 0, "message", "content").to_s
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def transient_status?(code)
|
|
46
|
+
TRANSIENT_STATUSES.include?(code.to_i)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def retry_delay(attempt)
|
|
50
|
+
RETRY_BASE_DELAY * (2**(attempt - 1))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def post_json(body)
|
|
54
|
+
uri = URI("#{AIGit::Config.base_url}#{AIGit::Config.endpoint}")
|
|
55
|
+
attempt = 0
|
|
56
|
+
|
|
57
|
+
loop do
|
|
58
|
+
attempt += 1
|
|
59
|
+
|
|
60
|
+
begin
|
|
61
|
+
response = perform_request(uri, body)
|
|
62
|
+
rescue *RETRYABLE_ERRORS => e
|
|
63
|
+
raise connection_error_message(e) if attempt >= MAX_ATTEMPTS
|
|
64
|
+
|
|
65
|
+
sleep retry_delay(attempt)
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
|
|
70
|
+
|
|
71
|
+
if transient_status?(response.code) && attempt < MAX_ATTEMPTS
|
|
72
|
+
sleep retry_delay(attempt)
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
raise http_error_message(uri, response)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def perform_request(uri, body)
|
|
81
|
+
request = Net::HTTP::Post.new(uri)
|
|
82
|
+
request["Content-Type"] = "application/json"
|
|
83
|
+
request.body = body.to_json
|
|
84
|
+
|
|
85
|
+
Net::HTTP.start(
|
|
86
|
+
uri.host,
|
|
87
|
+
uri.port,
|
|
88
|
+
use_ssl: uri.scheme == "https",
|
|
89
|
+
open_timeout: OPEN_TIMEOUT_SECONDS,
|
|
90
|
+
read_timeout: READ_TIMEOUT_SECONDS
|
|
91
|
+
) { |http| http.request(request) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def http_error_message(uri, response)
|
|
95
|
+
provider = AIGit::Config.provider
|
|
96
|
+
body = response.body.to_s.strip
|
|
97
|
+
body = "#{body[0, 500]}…" if body.length > 500
|
|
98
|
+
|
|
99
|
+
hint = response.code.to_i == 404 ? " Check the model name and base URL (see `ai_git config`)." : ""
|
|
100
|
+
|
|
101
|
+
"#{provider} returned HTTP #{response.code} at #{uri}.#{hint}" \
|
|
102
|
+
"#{body.empty? ? '' : "\n#{body}"}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def connection_error_message(error)
|
|
106
|
+
provider = AIGit::Config.provider
|
|
107
|
+
base_url = AIGit::Config.base_url
|
|
108
|
+
hint = "Is the local server running? See `ai_git config`."
|
|
109
|
+
|
|
110
|
+
"Cannot reach #{provider} at #{base_url} after #{MAX_ATTEMPTS} attempts: #{error.message}. #{hint}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def sanitize(text)
|
|
114
|
+
cleaned = text.to_s
|
|
115
|
+
.gsub(OUTPUT_NOISE_HEADERS, "")
|
|
116
|
+
.gsub(/^>\s*/, "")
|
|
117
|
+
.gsub(/\\n/, "\n")
|
|
118
|
+
.strip
|
|
119
|
+
|
|
120
|
+
cleaned.lines
|
|
121
|
+
.map(&:strip)
|
|
122
|
+
.reject { |line| line.match?(OUTPUT_NOISE_PREFIXES) }
|
|
123
|
+
.join("\n")
|
|
124
|
+
.strip
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ai_client"
|
|
4
|
+
require_relative "../config"
|
|
5
|
+
require_relative "../ui"
|
|
6
|
+
|
|
7
|
+
module AIGit
|
|
8
|
+
module Commands
|
|
9
|
+
# `ai_git config` — print the resolved provider configuration so users can
|
|
10
|
+
# see exactly which provider, model, URL and key will be used.
|
|
11
|
+
module Config
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def call(_argv = [])
|
|
15
|
+
cfg = AIGit::Config
|
|
16
|
+
|
|
17
|
+
AIGit::UI.heading("ai_git configuration")
|
|
18
|
+
AIGit::UI.kv("Provider", cfg.provider)
|
|
19
|
+
AIGit::UI.kv("Model", cfg.model_name)
|
|
20
|
+
AIGit::UI.kv("Base URL", cfg.base_url)
|
|
21
|
+
AIGit::UI.kv("Endpoint", cfg.endpoint)
|
|
22
|
+
AIGit::UI.kv("Read timeout", "#{AIGit::AIClient::READ_TIMEOUT_SECONDS}s")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ai_client"
|
|
4
|
+
require_relative "../config"
|
|
5
|
+
require_relative "../git"
|
|
6
|
+
require_relative "../ui"
|
|
7
|
+
|
|
8
|
+
module AIGit
|
|
9
|
+
module Commands
|
|
10
|
+
module Default
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def generate_commit_message(diff, model_name, temperature: 0.3)
|
|
14
|
+
raise "No staged changes to generate commit message for" if diff.to_s.strip.empty?
|
|
15
|
+
|
|
16
|
+
message = AIClient.complete(
|
|
17
|
+
prompt: build_prompt(diff),
|
|
18
|
+
model_name: model_name,
|
|
19
|
+
temperature: temperature
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
message = normalize_message(message)
|
|
23
|
+
message.empty? ? "chore: update code" : message
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Keep the commit body readable: collapse runs of blank lines to a single
|
|
27
|
+
# one and guarantee a blank line after the title so git sees a proper
|
|
28
|
+
# subject/body split (otherwise `git log --oneline` mashes them together).
|
|
29
|
+
def normalize_message(message)
|
|
30
|
+
text = message.to_s.gsub(/\n{3,}/, "\n\n").strip
|
|
31
|
+
lines = text.lines.map(&:chomp)
|
|
32
|
+
return text if lines.length < 2
|
|
33
|
+
|
|
34
|
+
lines.insert(1, "") unless lines[1].empty?
|
|
35
|
+
lines.join("\n").gsub(/\n{3,}/, "\n\n").strip
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call(_argv = [])
|
|
39
|
+
provider = AIGit::Config.provider
|
|
40
|
+
model_name = AIGit::Config.model_name
|
|
41
|
+
|
|
42
|
+
staged = AIGit::Git.staged_files
|
|
43
|
+
abort "Error: No staged files. Use `git add` first." if staged.to_s.strip.empty?
|
|
44
|
+
|
|
45
|
+
diff = AIGit::Git.diff
|
|
46
|
+
branch = AIGit::Git.current_branch
|
|
47
|
+
|
|
48
|
+
print_header(provider, model_name, staged, branch)
|
|
49
|
+
|
|
50
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
51
|
+
|
|
52
|
+
AIGit::UI.info(AIGit::UI.bold("Generating commit message…"))
|
|
53
|
+
message = generate_commit_message(diff, model_name)
|
|
54
|
+
print_message(message)
|
|
55
|
+
|
|
56
|
+
AIGit::Git.commit_with_message(message)
|
|
57
|
+
AIGit::UI.success("Committed.")
|
|
58
|
+
|
|
59
|
+
AIGit::Git.push_current_branch
|
|
60
|
+
AIGit::UI.success("Pushed to origin/#{branch}.")
|
|
61
|
+
|
|
62
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
63
|
+
AIGit::UI.kv("Done in", format("%.1fs", elapsed))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def print_header(provider, model_name, staged, branch)
|
|
67
|
+
AIGit::UI.kv("AI Provider", provider)
|
|
68
|
+
AIGit::UI.kv("Model", model_name)
|
|
69
|
+
AIGit::UI.kv("Staged Files", staged.to_s.strip.gsub("\n", ", "))
|
|
70
|
+
AIGit::UI.kv("Branch", branch)
|
|
71
|
+
puts
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def print_message(message)
|
|
75
|
+
puts
|
|
76
|
+
puts AIGit::UI.bold("Commit message:")
|
|
77
|
+
puts
|
|
78
|
+
puts message
|
|
79
|
+
puts
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_prompt(diff)
|
|
83
|
+
standard_prompt(diff)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def standard_prompt(diff)
|
|
87
|
+
<<~PROMPT
|
|
88
|
+
You are an expert Git commit message writer. Output ONLY the commit message — no explanations, no markdown, no backticks, no preamble.
|
|
89
|
+
|
|
90
|
+
Here are the changes:
|
|
91
|
+
#{diff}
|
|
92
|
+
|
|
93
|
+
STRICT OUTPUT FORMAT (follow exactly):
|
|
94
|
+
|
|
95
|
+
<short imperative title, max 72 chars>
|
|
96
|
+
|
|
97
|
+
<blank line>
|
|
98
|
+
|
|
99
|
+
## Summary
|
|
100
|
+
<2–4 bullet points covering the most important changes. Each bullet starts with a verb.>
|
|
101
|
+
|
|
102
|
+
## Why
|
|
103
|
+
<1–3 sentences explaining the motivation or context behind the change. Omit if the reason is obvious.>
|
|
104
|
+
|
|
105
|
+
RULES:
|
|
106
|
+
- Title line: short, specific, imperative mood (e.g. "Add JWT login with refresh token support"). Avoid vague titles like "Update stuff" or "Fix bug".
|
|
107
|
+
- Summary bullets: describe WHAT changed, not HOW the code looks. Focus on behaviour and impact.
|
|
108
|
+
- Why section: explain the problem being solved or the goal being achieved. Skip if it adds no value.
|
|
109
|
+
- No filler phrases ("this commit", "this PR", "as per discussion").
|
|
110
|
+
- No line should exceed 72 characters.
|
|
111
|
+
|
|
112
|
+
EXAMPLES OF GOOD OUTPUT:
|
|
113
|
+
|
|
114
|
+
Add JWT-based login with refresh token support
|
|
115
|
+
|
|
116
|
+
## Summary
|
|
117
|
+
- Implement login endpoint with access and refresh token issuance
|
|
118
|
+
- Add token refresh route with rotation and expiry validation
|
|
119
|
+
- Protect private routes via middleware that verifies access tokens
|
|
120
|
+
- Store refresh tokens using encrypted HTTP-only cookies
|
|
121
|
+
|
|
122
|
+
## Why
|
|
123
|
+
Users were being logged out on every page reload. Refresh tokens allow
|
|
124
|
+
sessions to persist securely without requiring re-authentication.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
Prevent nil crash when user preferences are missing
|
|
129
|
+
|
|
130
|
+
## Summary
|
|
131
|
+
- Add nil guard in ReportGenerator#process before accessing preferences
|
|
132
|
+
- Fall back to system defaults when preferences object is absent
|
|
133
|
+
|
|
134
|
+
## Why
|
|
135
|
+
Reports were raising NoMethodError in production for users created
|
|
136
|
+
before the preferences feature shipped.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
Now generate the commit message:
|
|
141
|
+
PROMPT
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AIGit
|
|
4
|
+
module Config
|
|
5
|
+
PROVIDER = "llama_cpp"
|
|
6
|
+
DEFAULT_MODEL = "ggml-org/gemma-4-E4B-it-GGUF:Q8_0"
|
|
7
|
+
DEFAULT_BASE_URL = "http://127.0.0.1:8080"
|
|
8
|
+
ENDPOINT = "/v1/chat/completions"
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def provider
|
|
13
|
+
PROVIDER
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def model_name
|
|
17
|
+
ENV["AI_GIT_MODEL_NAME"] || DEFAULT_MODEL
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def base_url
|
|
21
|
+
ENV["AI_GIT_BASE_URL"] || DEFAULT_BASE_URL
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def endpoint
|
|
25
|
+
ENDPOINT
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/ai_git/git.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "open3"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
4
6
|
module AIGit
|
|
5
7
|
module Git
|
|
6
8
|
module_function
|
|
@@ -14,13 +16,41 @@ module AIGit
|
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def current_branch
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
`git rev-parse --abbrev-ref HEAD`.chomp
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Run a command with arguments as an array — no shell, so values are not
|
|
23
|
+
# interpolated or word-split. Accepts either:
|
|
24
|
+
# run_command("git", "status") (single string of args)
|
|
25
|
+
# run_command("git", "commit", "-m", message) (variadic args, preferred)
|
|
26
|
+
def run_command(cmd, *args)
|
|
27
|
+
argv =
|
|
28
|
+
if args.length == 1 && args.first.is_a?(String)
|
|
29
|
+
args.first.split
|
|
30
|
+
else
|
|
31
|
+
args.map(&:to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
_stdout, stderr, status = Open3.capture3(cmd, *argv)
|
|
35
|
+
|
|
36
|
+
return if status.success?
|
|
37
|
+
|
|
38
|
+
raise "Command failed: #{cmd} #{argv.join(' ')} (exit #{status.exitstatus})#{stderr.empty? ? '' : "\n#{stderr}"}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Commit using a temp file so the message can contain anything (quotes,
|
|
42
|
+
# backticks, dollar signs) without shell-escaping concerns.
|
|
43
|
+
def commit_with_message(message)
|
|
44
|
+
Tempfile.create("ai_git_commit_msg") do |file|
|
|
45
|
+
file.write(message)
|
|
46
|
+
file.flush
|
|
47
|
+
|
|
48
|
+
run_command("git", "commit", "-F", file.path)
|
|
49
|
+
end
|
|
19
50
|
end
|
|
20
51
|
|
|
21
|
-
def
|
|
22
|
-
|
|
23
|
-
raise "Command failed: #{cmd} #{args}" if $CHILD_STATUS.exitstatus != 0
|
|
52
|
+
def push_current_branch
|
|
53
|
+
run_command("git", "push", "-u", "origin", "HEAD")
|
|
24
54
|
end
|
|
25
55
|
end
|
|
26
56
|
end
|
data/lib/ai_git/ui.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AIGit
|
|
4
|
+
# Terminal output helpers: ANSI colors (auto-disabled when output is not a
|
|
5
|
+
# TTY or NO_COLOR is set) and key/value lines.
|
|
6
|
+
module UI
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
CODES = {
|
|
10
|
+
bold: 1, dim: 2, red: 31, green: 32, yellow: 33, blue: 34, cyan: 36, gray: 90
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
# Colors are on only for an interactive terminal and when the user has not
|
|
14
|
+
# opted out via NO_COLOR (https://no-color.org) or AI_GIT_NO_COLOR.
|
|
15
|
+
def color?
|
|
16
|
+
return false if ENV["NO_COLOR"] && !ENV["NO_COLOR"].empty?
|
|
17
|
+
return false if ENV["AI_GIT_NO_COLOR"] && !ENV["AI_GIT_NO_COLOR"].empty?
|
|
18
|
+
|
|
19
|
+
$stdout.tty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def paint(text, *styles)
|
|
23
|
+
return text.to_s unless color?
|
|
24
|
+
|
|
25
|
+
codes = styles.map { |style| CODES[style] }.compact
|
|
26
|
+
return text.to_s if codes.empty?
|
|
27
|
+
|
|
28
|
+
"\e[#{codes.join(';')}m#{text}\e[0m"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def bold(text)
|
|
32
|
+
paint(text, :bold)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def dim(text)
|
|
36
|
+
paint(text, :dim)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def kv(key, value)
|
|
40
|
+
puts "#{paint("#{key}:", :bold)} #{value}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def heading(text)
|
|
44
|
+
puts paint(text, :bold, :cyan)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def info(text)
|
|
48
|
+
puts text
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def success(text)
|
|
52
|
+
puts paint(text, :green)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def error(text)
|
|
56
|
+
$stderr.puts paint(text, :red) # rubocop:disable Style/StderrPuts
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/ai_git/version.rb
CHANGED
data/lib/ai_git.rb
CHANGED
|
@@ -1,23 +1,58 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "ai_git/version"
|
|
4
|
+
require_relative "ai_git/config"
|
|
5
|
+
require_relative "ai_git/ui"
|
|
6
|
+
require_relative "ai_git/ai_client"
|
|
4
7
|
require_relative "ai_git/git"
|
|
5
|
-
require_relative "ai_git/
|
|
6
|
-
require_relative "ai_git/
|
|
8
|
+
require_relative "ai_git/commands/default"
|
|
9
|
+
require_relative "ai_git/commands/config"
|
|
7
10
|
|
|
8
11
|
module AIGit
|
|
9
12
|
module_function
|
|
10
13
|
|
|
11
14
|
SUBCOMMANDS = {
|
|
12
|
-
"
|
|
13
|
-
"default" => AIGit::Default
|
|
15
|
+
"config" => AIGit::Commands::Config,
|
|
16
|
+
"default" => AIGit::Commands::Default
|
|
14
17
|
}.freeze
|
|
15
18
|
|
|
19
|
+
HELP_FLAGS = %w[-h --help help].freeze
|
|
20
|
+
VERSION_FLAGS = %w[-v --version].freeze
|
|
21
|
+
|
|
22
|
+
USAGE = <<~USAGE
|
|
23
|
+
Usage: ai_git [subcommand]
|
|
24
|
+
|
|
25
|
+
Subcommands:
|
|
26
|
+
(none) Generate a commit message, commit, and push staged files
|
|
27
|
+
config Show the resolved provider configuration
|
|
28
|
+
|
|
29
|
+
Flags:
|
|
30
|
+
-h, --help Show this message
|
|
31
|
+
-v, --version Print version
|
|
32
|
+
|
|
33
|
+
Environment variables:
|
|
34
|
+
AI_GIT_MODEL_NAME Override the default model
|
|
35
|
+
AI_GIT_BASE_URL Override the default base URL
|
|
36
|
+
NO_COLOR Disable colored output
|
|
37
|
+
USAGE
|
|
38
|
+
|
|
16
39
|
def start(args)
|
|
17
|
-
|
|
40
|
+
args = args.dup
|
|
41
|
+
first = args.first
|
|
42
|
+
|
|
43
|
+
return puts(USAGE) if first && HELP_FLAGS.include?(first)
|
|
44
|
+
return puts(VERSION) if first && VERSION_FLAGS.include?(first)
|
|
18
45
|
|
|
19
|
-
|
|
46
|
+
command = "default"
|
|
47
|
+
if first && !first.start_with?("-")
|
|
48
|
+
unless SUBCOMMANDS.key?(first)
|
|
49
|
+
warn "Unknown subcommand: #{first}"
|
|
50
|
+
warn USAGE
|
|
51
|
+
exit 1
|
|
52
|
+
end
|
|
53
|
+
command = args.shift
|
|
54
|
+
end
|
|
20
55
|
|
|
21
|
-
SUBCOMMANDS[command].call
|
|
56
|
+
SUBCOMMANDS[command].call(args)
|
|
22
57
|
end
|
|
23
58
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ai_git
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaíque Kandy Koga
|
|
@@ -9,22 +9,31 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
|
-
description:
|
|
12
|
+
description: Generate Git commit messages from staged changes via a local llama.cpp
|
|
13
|
+
server, then commit and push.
|
|
13
14
|
executables:
|
|
14
15
|
- ai_git
|
|
15
16
|
extensions: []
|
|
16
17
|
extra_rdoc_files: []
|
|
17
18
|
files:
|
|
19
|
+
- LICENSE
|
|
20
|
+
- README.md
|
|
18
21
|
- bin/ai_git
|
|
19
22
|
- lib/ai_git.rb
|
|
20
|
-
- lib/ai_git/
|
|
23
|
+
- lib/ai_git/ai_client.rb
|
|
24
|
+
- lib/ai_git/commands/config.rb
|
|
25
|
+
- lib/ai_git/commands/default.rb
|
|
26
|
+
- lib/ai_git/config.rb
|
|
21
27
|
- lib/ai_git/git.rb
|
|
22
|
-
- lib/ai_git/
|
|
28
|
+
- lib/ai_git/ui.rb
|
|
23
29
|
- lib/ai_git/version.rb
|
|
24
30
|
homepage: https://github.com/kaiquekandykoga/ai_git
|
|
25
31
|
licenses:
|
|
26
32
|
- BSD-3-Clause
|
|
27
|
-
metadata:
|
|
33
|
+
metadata:
|
|
34
|
+
source_code_uri: https://github.com/kaiquekandykoga/ai_git
|
|
35
|
+
bug_tracker_uri: https://github.com/kaiquekandykoga/ai_git/issues
|
|
36
|
+
rubygems_mfa_required: 'true'
|
|
28
37
|
rdoc_options: []
|
|
29
38
|
require_paths:
|
|
30
39
|
- lib
|
|
@@ -32,14 +41,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
32
41
|
requirements:
|
|
33
42
|
- - ">="
|
|
34
43
|
- !ruby/object:Gem::Version
|
|
35
|
-
version: '
|
|
44
|
+
version: '0'
|
|
36
45
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
37
46
|
requirements:
|
|
38
47
|
- - ">="
|
|
39
48
|
- !ruby/object:Gem::Version
|
|
40
49
|
version: '0'
|
|
41
50
|
requirements: []
|
|
42
|
-
rubygems_version: 4.0.
|
|
51
|
+
rubygems_version: 4.0.10
|
|
43
52
|
specification_version: 4
|
|
44
|
-
summary: AI
|
|
53
|
+
summary: AI-powered Git commit messages using a local LLM
|
|
45
54
|
test_files: []
|
data/lib/ai_git/default.rb
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "benchmark"
|
|
4
|
-
require "net/http"
|
|
5
|
-
require "uri"
|
|
6
|
-
require "json"
|
|
7
|
-
require_relative "version"
|
|
8
|
-
require_relative "git"
|
|
9
|
-
|
|
10
|
-
module AIGit
|
|
11
|
-
module Default
|
|
12
|
-
module_function
|
|
13
|
-
|
|
14
|
-
def escape_json(string)
|
|
15
|
-
string.gsub("\\", "\\\\")
|
|
16
|
-
.gsub('"', '\"')
|
|
17
|
-
.gsub("\n", '\\n')
|
|
18
|
-
.gsub("\r", '\\r')
|
|
19
|
-
.gsub("\t", '\\t')
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def generate_commit_message(diff, model_name)
|
|
23
|
-
raise "No staged changes to generate commit message for" if diff.to_s.strip.empty?
|
|
24
|
-
|
|
25
|
-
prompt = <<~PROMPT
|
|
26
|
-
You are an expert Git commit message writer. Output ONLY the commit message — no explanations, no markdown, no backticks, no preamble.
|
|
27
|
-
|
|
28
|
-
Here are the changes:
|
|
29
|
-
#{diff}
|
|
30
|
-
|
|
31
|
-
STRICT OUTPUT FORMAT (follow exactly):
|
|
32
|
-
|
|
33
|
-
<short imperative title, max 72 chars>
|
|
34
|
-
|
|
35
|
-
<blank line>
|
|
36
|
-
|
|
37
|
-
## Summary
|
|
38
|
-
<2–4 bullet points covering the most important changes. Each bullet starts with a verb.>
|
|
39
|
-
|
|
40
|
-
## Why
|
|
41
|
-
<1–3 sentences explaining the motivation or context behind the change. Omit if the reason is obvious.>
|
|
42
|
-
|
|
43
|
-
RULES:
|
|
44
|
-
- Title line: short, specific, imperative mood (e.g. "Add JWT login with refresh token support"). Avoid vague titles like "Update stuff" or "Fix bug".
|
|
45
|
-
- Summary bullets: describe WHAT changed, not HOW the code looks. Focus on behaviour and impact.
|
|
46
|
-
- Why section: explain the problem being solved or the goal being achieved. Skip if it adds no value.
|
|
47
|
-
- No filler phrases ("this commit", "this PR", "as per discussion").
|
|
48
|
-
- No line should exceed 72 characters.
|
|
49
|
-
|
|
50
|
-
EXAMPLES OF GOOD OUTPUT:
|
|
51
|
-
|
|
52
|
-
Add JWT-based login with refresh token support
|
|
53
|
-
|
|
54
|
-
## Summary
|
|
55
|
-
- Implement login endpoint with access and refresh token issuance
|
|
56
|
-
- Add token refresh route with rotation and expiry validation
|
|
57
|
-
- Protect private routes via middleware that verifies access tokens
|
|
58
|
-
- Store refresh tokens using encrypted HTTP-only cookies
|
|
59
|
-
|
|
60
|
-
## Why
|
|
61
|
-
Users were being logged out on every page reload. Refresh tokens allow
|
|
62
|
-
sessions to persist securely without requiring re-authentication.
|
|
63
|
-
|
|
64
|
-
---
|
|
65
|
-
|
|
66
|
-
Prevent nil crash when user preferences are missing
|
|
67
|
-
|
|
68
|
-
## Summary
|
|
69
|
-
- Add nil guard in ReportGenerator#process before accessing preferences
|
|
70
|
-
- Fall back to system defaults when preferences object is absent
|
|
71
|
-
|
|
72
|
-
## Why
|
|
73
|
-
Reports were raising NoMethodError in production for users created
|
|
74
|
-
before the preferences feature shipped.
|
|
75
|
-
|
|
76
|
-
---
|
|
77
|
-
|
|
78
|
-
Now generate the commit message:
|
|
79
|
-
PROMPT
|
|
80
|
-
|
|
81
|
-
json_body = {
|
|
82
|
-
model: model_name,
|
|
83
|
-
prompt: prompt,
|
|
84
|
-
stream: false,
|
|
85
|
-
temperature: 0.3,
|
|
86
|
-
top_p: 0.9,
|
|
87
|
-
stop: ["\n\n\n", "```", "Here is", "The commit message"],
|
|
88
|
-
num_predict: 400
|
|
89
|
-
}.to_json
|
|
90
|
-
|
|
91
|
-
uri = URI("http://localhost:11434/api/generate")
|
|
92
|
-
request = Net::HTTP::Post.new(uri)
|
|
93
|
-
request["Content-Type"] = "application/json"
|
|
94
|
-
request.body = json_body
|
|
95
|
-
|
|
96
|
-
response = Net::HTTP.start(uri.host, uri.port, read_timeout: 120) do |http|
|
|
97
|
-
http.request(request)
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
raise "Failed to connect to Ollama. Is it running?" unless response.is_a?(Net::HTTPSuccess)
|
|
101
|
-
|
|
102
|
-
data = JSON.parse(response.body)
|
|
103
|
-
message = data["response"].to_s.strip
|
|
104
|
-
|
|
105
|
-
message = message.gsub(/^(Here is|The commit message is|```|json|markdown)/i, "")
|
|
106
|
-
.gsub(/^>\s*/, "")
|
|
107
|
-
.gsub(/\\n/, "\n")
|
|
108
|
-
.strip
|
|
109
|
-
|
|
110
|
-
lines = message.lines.map(&:strip)
|
|
111
|
-
lines.reject! { |line| line.match?(/^(Here|Output|Generated|Based on|The changes)/i) }
|
|
112
|
-
|
|
113
|
-
message = lines.join("\n").strip
|
|
114
|
-
|
|
115
|
-
message = "chore: update code" if message.lines.count < 1 || message.strip.empty?
|
|
116
|
-
|
|
117
|
-
message
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def call
|
|
121
|
-
model_name = ENV["AI_GIT_MODEL_NAME"] || "phi4:14b"
|
|
122
|
-
|
|
123
|
-
staged = AIGit::Git.staged_files
|
|
124
|
-
abort "Error: No staged files. Use `git add` first." if staged.to_s.strip.empty?
|
|
125
|
-
|
|
126
|
-
diff = AIGit::Git.diff
|
|
127
|
-
branch = AIGit::Git.current_branch
|
|
128
|
-
|
|
129
|
-
puts "\e[1mModel Name:\e[0m #{model_name}"
|
|
130
|
-
puts "\e[1mStaged Files:\e[0m #{staged}"
|
|
131
|
-
puts "\e[1mBranch:\e[0m #{branch}"
|
|
132
|
-
puts "\e[1mAI Generating Commit Message\e[0m"
|
|
133
|
-
|
|
134
|
-
result = Benchmark.measure do
|
|
135
|
-
message = generate_commit_message(diff, model_name)
|
|
136
|
-
message = message.gsub(/\n{2,}/, "\n")
|
|
137
|
-
|
|
138
|
-
puts "\e[1mCommit Message:\e[0m\n\n#{message}\n"
|
|
139
|
-
|
|
140
|
-
escaped_msg = message.gsub(/[\\"`$]/) { |c| "\\#{c}" }
|
|
141
|
-
AIGit::Git.run_command("git", "commit -m \"#{escaped_msg}\"")
|
|
142
|
-
puts "\e[1mGit Commited\e[0m"
|
|
143
|
-
|
|
144
|
-
AIGit::Git.run_command("git", "push")
|
|
145
|
-
puts "\e[1mGit Pushed\e[0m"
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
puts "\e[1mBenchmark\e[0m"
|
|
149
|
-
puts result
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
end
|
data/lib/ai_git/review.rb
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "benchmark"
|
|
4
|
-
require "net/http"
|
|
5
|
-
require "uri"
|
|
6
|
-
require "json"
|
|
7
|
-
require_relative "version"
|
|
8
|
-
require_relative "git"
|
|
9
|
-
|
|
10
|
-
module AIGit
|
|
11
|
-
module Review
|
|
12
|
-
module_function
|
|
13
|
-
|
|
14
|
-
def generate_review(diff, model_name)
|
|
15
|
-
raise "No staged changes to review" if diff.to_s.strip.empty?
|
|
16
|
-
|
|
17
|
-
prompt = <<~PROMPT
|
|
18
|
-
You are a senior software engineer conducting a thorough code review. Output ONLY the review — no explanations, no markdown preamble, no backticks, no preamble.
|
|
19
|
-
|
|
20
|
-
Here are the staged changes:
|
|
21
|
-
#{diff}
|
|
22
|
-
|
|
23
|
-
STRICT OUTPUT FORMAT (follow exactly):
|
|
24
|
-
|
|
25
|
-
## Summary
|
|
26
|
-
<2–5 bullet points describing what this change does at a high level. Each bullet starts with a verb.>
|
|
27
|
-
|
|
28
|
-
## Suggestions
|
|
29
|
-
<For each issue or improvement, use this format:>
|
|
30
|
-
|
|
31
|
-
**<File and location, e.g. "app/models/user.rb">**
|
|
32
|
-
- <Concise description of the issue or suggestion. Start with a verb. Be specific.>
|
|
33
|
-
- <Another suggestion for the same file, if applicable.>
|
|
34
|
-
|
|
35
|
-
<Repeat the file block for each file with suggestions. Omit files that look clean.>
|
|
36
|
-
|
|
37
|
-
## Verdict
|
|
38
|
-
<One of: ✅ Looks good | ⚠️ Minor issues | 🚨 Needs attention>
|
|
39
|
-
<One sentence explaining the verdict.>
|
|
40
|
-
|
|
41
|
-
RULES:
|
|
42
|
-
- Summary: describe WHAT the change does, not HOW the code looks. Focus on behaviour and intent.
|
|
43
|
-
- Suggestions: flag real issues — bugs, edge cases, security problems, missing error handling, naming confusion, performance concerns, missing tests. Do NOT invent problems. If a file is clean, omit it entirely.
|
|
44
|
-
- Be direct and constructive. No filler like "great job" or "consider possibly maybe".
|
|
45
|
-
- No line should exceed 72 characters.
|
|
46
|
-
- If the diff is a minor or trivial change (e.g. version bump, typo fix), keep the review brief.
|
|
47
|
-
|
|
48
|
-
EXAMPLES OF GOOD OUTPUT:
|
|
49
|
-
|
|
50
|
-
## Summary
|
|
51
|
-
- Add password reset flow with tokenised email verification
|
|
52
|
-
- Introduce PasswordResetMailer with expiry-aware token links
|
|
53
|
-
- Add DB migration for reset_token and reset_token_expires_at columns
|
|
54
|
-
|
|
55
|
-
## Suggestions
|
|
56
|
-
|
|
57
|
-
**app/models/user.rb**
|
|
58
|
-
- Ensure reset_token is invalidated after successful use to
|
|
59
|
-
prevent token reuse attacks.
|
|
60
|
-
- Add an index on reset_token column for fast lookup queries.
|
|
61
|
-
|
|
62
|
-
**app/controllers/passwords_controller.rb**
|
|
63
|
-
- Handle the case where the token has expired with a user-facing
|
|
64
|
-
error message rather than a silent redirect.
|
|
65
|
-
|
|
66
|
-
## Verdict
|
|
67
|
-
⚠️ Minor issues
|
|
68
|
-
Core logic is solid but token invalidation and expiry feedback
|
|
69
|
-
need addressing before merge.
|
|
70
|
-
|
|
71
|
-
---
|
|
72
|
-
|
|
73
|
-
## Summary
|
|
74
|
-
- Fix nil guard in ReportGenerator when preferences are absent
|
|
75
|
-
|
|
76
|
-
## Suggestions
|
|
77
|
-
|
|
78
|
-
## Verdict
|
|
79
|
-
✅ Looks good
|
|
80
|
-
Safe defensive fix with no side effects.
|
|
81
|
-
|
|
82
|
-
---
|
|
83
|
-
|
|
84
|
-
Now review the staged changes:
|
|
85
|
-
PROMPT
|
|
86
|
-
|
|
87
|
-
json_body = {
|
|
88
|
-
model: model_name,
|
|
89
|
-
prompt: prompt,
|
|
90
|
-
stream: false,
|
|
91
|
-
temperature: 0.2,
|
|
92
|
-
top_p: 0.9,
|
|
93
|
-
stop: ["\n\n\n", "```", "Here is", "The review"],
|
|
94
|
-
num_predict: 600
|
|
95
|
-
}.to_json
|
|
96
|
-
|
|
97
|
-
uri = URI("http://localhost:11434/api/generate")
|
|
98
|
-
request = Net::HTTP::Post.new(uri)
|
|
99
|
-
request["Content-Type"] = "application/json"
|
|
100
|
-
request.body = json_body
|
|
101
|
-
|
|
102
|
-
response = Net::HTTP.start(uri.host, uri.port, read_timeout: 120) do |http|
|
|
103
|
-
http.request(request)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
raise "Failed to connect to Ollama. Is it running?" unless response.is_a?(Net::HTTPSuccess)
|
|
107
|
-
|
|
108
|
-
data = JSON.parse(response.body)
|
|
109
|
-
review = data["response"].to_s.strip
|
|
110
|
-
|
|
111
|
-
review = review.gsub(/^(Here is|The review is|```|json|markdown)/i, "")
|
|
112
|
-
.gsub(/^>\s*/, "")
|
|
113
|
-
.gsub(/\\n/, "\n")
|
|
114
|
-
.strip
|
|
115
|
-
|
|
116
|
-
lines = review.lines.map(&:strip)
|
|
117
|
-
lines.reject! { |line| line.match?(/^(Here|Output|Generated|Based on|The changes)/i) }
|
|
118
|
-
|
|
119
|
-
review = lines.join("\n").strip
|
|
120
|
-
review = "No review generated." if review.empty?
|
|
121
|
-
|
|
122
|
-
review
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def call
|
|
126
|
-
model_name = ENV["AI_GIT_MODEL_NAME"] || "phi4:14b"
|
|
127
|
-
|
|
128
|
-
staged = AIGit::Git.staged_files
|
|
129
|
-
abort "Error: No staged files. Use `git add` first." if staged.to_s.strip.empty?
|
|
130
|
-
|
|
131
|
-
diff = AIGit::Git.diff
|
|
132
|
-
branch = AIGit::Git.current_branch
|
|
133
|
-
|
|
134
|
-
puts "\e[1mModel Name:\e[0m #{model_name}"
|
|
135
|
-
puts "\e[1mStaged Files:\e[0m #{staged}"
|
|
136
|
-
puts "\e[1mBranch:\e[0m #{branch}"
|
|
137
|
-
puts "\e[1mAI Reviewing Changes\e[0m"
|
|
138
|
-
|
|
139
|
-
result = Benchmark.measure do
|
|
140
|
-
review = generate_review(diff, model_name)
|
|
141
|
-
puts "\e[1mCode Review:\e[0m\n\n#{review}\n"
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
puts "\e[1mBenchmark\e[0m"
|
|
145
|
-
puts result
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
end
|