gitpr-cli 0.0.24__py3-none-any.whl → 0.0.26__py3-none-any.whl
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.
- {gitpr_cli-0.0.24.dist-info → gitpr_cli-0.0.26.dist-info}/METADATA +34 -16
- gitpr_cli-0.0.26.dist-info/RECORD +25 -0
- src/ai_providers.py +119 -2
- src/blame_engine.py +2 -2
- src/chat_memory.py +218 -0
- src/config.py +48 -1
- src/core.py +29 -22
- src/i18n.py +13 -0
- src/issue_engine.py +2 -2
- src/main.py +77 -3
- src/spinner.py +21 -3
- src/ui/chat_app.py +587 -0
- src/updater.py +2 -2
- gitpr_cli-0.0.24.dist-info/RECORD +0 -23
- {gitpr_cli-0.0.24.dist-info → gitpr_cli-0.0.26.dist-info}/WHEEL +0 -0
- {gitpr_cli-0.0.24.dist-info → gitpr_cli-0.0.26.dist-info}/entry_points.txt +0 -0
- {gitpr_cli-0.0.24.dist-info → gitpr_cli-0.0.26.dist-info}/licenses/LICENSE +0 -0
- {gitpr_cli-0.0.24.dist-info → gitpr_cli-0.0.26.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitpr-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.26
|
|
4
4
|
Summary: Automação de PRs, Commits e Code Review com IA (Gemini e DeepSeek)
|
|
5
5
|
Author-email: Natan Fiuza <contato@natanfiuza.dev.br>
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -126,7 +126,9 @@ You can pass the following *flags* for specific actions:
|
|
|
126
126
|
* `-r` or `--review`: Performs a detailed **Code Review** of local changes.
|
|
127
127
|
* `-f` or `--fullreview`: Performs a **Full Code Review** analyzing all changes since the remote branch.
|
|
128
128
|
* `-i <file>` or `--input <file>`: **Full File Audit.** Must be used together with `-r` or `-f`; it ignores git history and does a Code Review of the entire file. Excellent for acting as a consultant on legacy code refactoring.
|
|
129
|
-
* `--provider <gemini|deepseek>`: Forces the use of a specific AI only for this execution, ignoring your default saved in `.env`.
|
|
129
|
+
* `--provider <gemini|deepseek|ollama>`: Forces the use of a specific AI only for this execution, ignoring your default saved in `.env`.
|
|
130
|
+
* `--lang <code>`: Forces the interface language for this execution (e.g.: `en_us`, `pt_br`). Overrides `GITPR_LANG` in `.env` without persisting the change.
|
|
131
|
+
* `-ch` or `--chat`: Opens the **Interactive Pair Programming Chat** — a TUI terminal where the AI sees your current diff and maintains a contextual conversation. Features memory per branch, slash commands (`/explain`, `/tests`, `/optimize`, `/clear`), auto-patching (F5), diff refresh (F2), and session export (F6).
|
|
130
132
|
* `-l` or `--linter`: Runs **only the local static linter** (no AI calls). Ideal for use in CI/CD pipelines to block non-compliant code.
|
|
131
133
|
* `-ih` or `--installhooks`: Automatically installs **local Git Hooks** (`pre-commit` and `prepare-commit-msg`) in your repository.
|
|
132
134
|
* `-s` or `--skill`: Creates the AI context template files (`.gitpr.commit.md`, `.gitpr.pr.md`, `.gitpr.review.md`, `.gitpr.filereview.md`, `.gitpr.issue.md`, `.gitpr.blame.md`) and the Linter (`.gitpr.linter.yml`) at the project root.
|
|
@@ -165,6 +167,7 @@ The Linter analyzes only the **added lines** in your `git diff`, ensuring a focu
|
|
|
165
167
|
GitPR is not tied to a single Artificial Intelligence. During initial setup, the user can choose their default engine. We currently support:
|
|
166
168
|
* **Google Gemini** (Default: `gemini-2.5-flash`)
|
|
167
169
|
* **DeepSeek** (Default: `deepseek-chat`)
|
|
170
|
+
* **Ollama** (Local) — run models locally without internet, fully compatible with the OpenAI API format
|
|
168
171
|
|
|
169
172
|
You can dynamically switch models by configuring the `GEMINI_API_MODEL` or `DEEPSEEK_API_MODEL` variables in your `~/.gitpr/.env` file, or switch in real-time using the `--provider` flag.
|
|
170
173
|
|
|
@@ -197,20 +200,35 @@ To force a specific language, set `GITPR_LANG=pt_br` or `GITPR_LANG=en` in `~/.g
|
|
|
197
200
|
|
|
198
201
|
To keep this README concise, we detail the most advanced **DevOps** and **Continuous Integration** focused implementations in separate documents.
|
|
199
202
|
|
|
200
|
-
If you want to implement GitPR as an automated quality barrier in your team, check out the guides below
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
* [
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
* [**
|
|
211
|
-
* [**
|
|
212
|
-
* [**CI/CD Integration (GitHub Actions)**](docs/github-ci-linter.md)
|
|
213
|
-
|
|
203
|
+
If you want to implement GitPR as an automated quality barrier in your team, check out the guides below.
|
|
204
|
+
|
|
205
|
+
> 🌐 Each guide is available in **5 languages** — add `.pt_br`, `.pt_pt`, `.fr_fr`, or `.es_es` before the `.md` extension for translated versions (e.g., `docs/understanding_chat_functionality.pt_br.md`). English is the default with no suffix.
|
|
206
|
+
|
|
207
|
+
### Chat & Interactive Features
|
|
208
|
+
|
|
209
|
+
* [**🧠 Interactive Chat (Pair Programming)**](https://github.com/natanfiuza/gitpr/blob/main/docs/understanding_chat_functionality.md) — How to use the AI chat with memory, slash commands, auto-patch, and session export.
|
|
210
|
+
|
|
211
|
+
### DevOps & CI/CD
|
|
212
|
+
|
|
213
|
+
* [**Local Git Hooks (Shift-Left)**](https://github.com/natanfiuza/gitpr/blob/main/docs/git-hooks-locais.md) — How to use `gitpr --installhooks` to create guardrails on the developer's machine and use AI to automatically write commit messages.
|
|
214
|
+
* [**Customizable Static Linter**](https://github.com/natanfiuza/gitpr/blob/main/docs/linter-regras-customizadas.md) — How to create validation rules in `.gitpr.linter.yml` for CI/CD and pre-commit hooks.
|
|
215
|
+
* [**CI/CD Integration (GitHub Actions)**](https://github.com/natanfiuza/gitpr/blob/main/docs/github-ci-linter.md) — How to run GitPR in the pipeline to block "Merge" of PRs with violations.
|
|
216
|
+
|
|
217
|
+
### Core Features
|
|
218
|
+
|
|
219
|
+
* [**Pull Request (Default Mode)**](https://github.com/natanfiuza/gitpr/blob/main/docs/pr-descricao-padrao.md) — Complete flow for generating PR descriptions without flags.
|
|
220
|
+
* [**AI Code Review**](https://github.com/natanfiuza/gitpr/blob/main/docs/code-review-ia.md) — Guide to review modes (`--review`, `--fullreview`) and file auditing (`--input`).
|
|
221
|
+
* [**AI Commit Messages**](https://github.com/natanfiuza/gitpr/blob/main/docs/commit-message-ia.md) — How to generate messages in the Conventional Commits standard and integrate with Git Hooks.
|
|
222
|
+
* [**Issue Generation and TUI Interface**](https://github.com/natanfiuza/gitpr/blob/main/docs/issue-tui-help.md) — How to use the terminal graphical interface (TUI) and the 3 context engines to manage structured Issues.
|
|
223
|
+
* [**Code Archaeologist (Git Blame)**](https://github.com/natanfiuza/gitpr/blob/main/docs/blame-arqueologo.md) — How to trace the origin of business rules with `git blame` and AI.
|
|
224
|
+
* [**Skills and Templates System**](https://github.com/natanfiuza/gitpr/blob/main/docs/skill-template.md) — How to customize AI behavior with `.gitpr.*.md` files.
|
|
225
|
+
|
|
226
|
+
### Configuration & Infrastructure
|
|
227
|
+
|
|
228
|
+
* [**AI Providers**](https://github.com/natanfiuza/gitpr/blob/main/docs/providers-ia.md) — Configuration and selection between Google Gemini, DeepSeek, and Ollama.
|
|
229
|
+
* [**Auto-Updater**](https://github.com/natanfiuza/gitpr/blob/main/docs/auto-update.md) — How GitPR's automatic update (hot-swap) works.
|
|
230
|
+
* [**GitHub Token (PAT) Integration and Security**](https://github.com/natanfiuza/gitpr/blob/main/docs/github-pat-integration.md) — Understand how GitPR creates issues directly in the repository with authentication.
|
|
231
|
+
* [**Internationalization (i18n)**](https://github.com/natanfiuza/gitpr/blob/main/docs/i18n_explanation.md) — Architecture, usage patterns, and how to add new languages.
|
|
214
232
|
|
|
215
233
|
## ⚡ Local Cache System (Quota Savings)
|
|
216
234
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
gitpr_cli-0.0.26.dist-info/licenses/LICENSE,sha256=UQzLC6mwqb-ZNDkBBAqOxC-SMQcEID0gaM6oFcu6u48,27030
|
|
2
|
+
src/__init__.py,sha256=U6ksarernNcryHO20aj4rNG7w0thT7xdMs5KzBJyPvE,28
|
|
3
|
+
src/ai_providers.py,sha256=gjWhl38AAVtCVDd4E_YOBlC_gKvtRBSoOj0b_kJ6N1I,8316
|
|
4
|
+
src/blame_engine.py,sha256=Ci7t08xTcC1kBWAjQ_ulP-Zraau_PC35MDfAngc4AYk,10396
|
|
5
|
+
src/cache.py,sha256=XggMTy00GlZfeF1VoLEYCdxL5h0eTg11TJfPIRPx7fs,3201
|
|
6
|
+
src/chat_memory.py,sha256=vdYuElIQb6-flFm4SfzapnQZGRIwgbLECzIcsAc0Oo8,8724
|
|
7
|
+
src/config.py,sha256=VJ9ekDlTVG5ETWDuO1kZWlgrncXbrvduxa5Zg7wN_ZY,9064
|
|
8
|
+
src/core.py,sha256=TGtSA_7wyNoa-B6ytFhunCpEmpW9miQzvlizUQcyhis,18259
|
|
9
|
+
src/i18n.py,sha256=QED7hTSdWCC1lSNJ-l6VuOba18OrWgFTMQcZ7cQmXbY,3599
|
|
10
|
+
src/issue_engine.py,sha256=435kPPgdsFMcY3nvutxdIfJAnLP0U4FMCPdbok6C2sc,3626
|
|
11
|
+
src/linter_engine.py,sha256=D04wvOgRa-vZEYknm-z-Cg7V_3gnoQz_64PMorWnISo,4414
|
|
12
|
+
src/main.py,sha256=QryicYBsYiRaNCUTs9eJbM-x-S3Z4-jWvjGR3SwkmJ8,31412
|
|
13
|
+
src/security.py,sha256=Li0zM8ZURTK2nKRHIt49TWLO4DIddBEHzXPrVycv0Rs,1343
|
|
14
|
+
src/spinner.py,sha256=nnnoz91qt2NMEA3aMIT27XH7KImSMNVt_SyRAjNS8rk,6922
|
|
15
|
+
src/tui_issue.py,sha256=gcAJCIfMcdeMjrFJUWQQvdgBv7fnOBtxQxwdBk2Sk40,1789
|
|
16
|
+
src/updater.py,sha256=BDBRFpL9ek4MvduIKa3A_UH1KM-YCOCaXwPvN-eVREw,5886
|
|
17
|
+
src/ui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
src/ui/chat_app.py,sha256=y-py5UpisQbPLqqDON8AIkyA7X1IM5LAn1GbeYr2p_U,25598
|
|
19
|
+
src/ui/help_screen.py,sha256=tjbokXhwyzLnkOC0S56NbpX-78hcFQnvsQMpNU1wt0c,1862
|
|
20
|
+
src/ui/issue_app.py,sha256=8MdBcOVdCHPFx6EUhs80CiZ2G-29KulskkCz9qMZ_Lg,4737
|
|
21
|
+
gitpr_cli-0.0.26.dist-info/METADATA,sha256=RReNTAFsjM1rMKH3aordrfMn90pZygCt0jn-tTo3X-M,17450
|
|
22
|
+
gitpr_cli-0.0.26.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
23
|
+
gitpr_cli-0.0.26.dist-info/entry_points.txt,sha256=mh_4F_idM4cj0XhfRGBDQb5Nnku93YJ-dyKh5SufwlE,39
|
|
24
|
+
gitpr_cli-0.0.26.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
|
|
25
|
+
gitpr_cli-0.0.26.dist-info/RECORD,,
|
src/ai_providers.py
CHANGED
|
@@ -3,8 +3,12 @@ import time
|
|
|
3
3
|
import click
|
|
4
4
|
from google import genai
|
|
5
5
|
from openai import OpenAI
|
|
6
|
+
import urllib.request
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
6
9
|
from src.spinner import Spinner
|
|
7
|
-
from src.i18n import __
|
|
10
|
+
from src.i18n import __,CURRENT_LANG
|
|
11
|
+
|
|
8
12
|
|
|
9
13
|
def call_ai_model(provider, api_key, api_model, prompt, system_instruction, quiet=False):
|
|
10
14
|
"""
|
|
@@ -77,4 +81,117 @@ def call_ai_model(provider, api_key, api_model, prompt, system_instruction, quie
|
|
|
77
81
|
click.secho(__("\r❌ Critical error contacting {provider} API after {max_retries} attempts: {error}", provider=provider.capitalize(), max_retries=max_retries, error=str(e)), fg="red", bold=True)
|
|
78
82
|
return None
|
|
79
83
|
finally:
|
|
80
|
-
spinner.stop()
|
|
84
|
+
spinner.stop()
|
|
85
|
+
|
|
86
|
+
def load_chat_commands():
|
|
87
|
+
"""Download and cache the translated chat commands."""
|
|
88
|
+
lang_suffix = "" if CURRENT_LANG.startswith("en") else f".{CURRENT_LANG}"
|
|
89
|
+
url = f"https://raw.githubusercontent.com/natanfiuza/gitpr/main/templates/chat_commands{lang_suffix}.json"
|
|
90
|
+
|
|
91
|
+
cache_dir = Path.home() / ".gitpr" / "cache"
|
|
92
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
cache_file = cache_dir / f"chat_commands{lang_suffix}.json"
|
|
94
|
+
|
|
95
|
+
# Try loading from the local cache first to avoid slowing down the terminal
|
|
96
|
+
if cache_file.exists():
|
|
97
|
+
try:
|
|
98
|
+
with open(cache_file, "r", encoding="utf-8") as f:
|
|
99
|
+
return json.load(f)
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
# If it is not in the cache, download it from the remote repository
|
|
104
|
+
try:
|
|
105
|
+
req = urllib.request.Request(url, headers={'User-Agent': 'GitPR-Chat'})
|
|
106
|
+
with urllib.request.urlopen(req, timeout=5) as response:
|
|
107
|
+
data = json.loads(response.read().decode())
|
|
108
|
+
with open(cache_file, "w", encoding="utf-8") as f:
|
|
109
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
110
|
+
return data
|
|
111
|
+
except Exception:
|
|
112
|
+
# Safety fallback if the user is offline
|
|
113
|
+
return {
|
|
114
|
+
"/explain": "Explains the diff line by line.",
|
|
115
|
+
"/tests": "Generates unit tests for the changed functions.",
|
|
116
|
+
"/optimize": "Analyzes cyclomatic complexity and performance.",
|
|
117
|
+
"/clear": "Clears conversation and creates a new chat session for the current diff."
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
def process_chat_command(message):
|
|
121
|
+
"""
|
|
122
|
+
Check whether the message is a command (e.g., /explain).
|
|
123
|
+
Returns a tuple: (is_command, is_clear_command, processed_message)
|
|
124
|
+
"""
|
|
125
|
+
msg_trimmed = message.strip().lower()
|
|
126
|
+
if not msg_trimmed.startswith("/"):
|
|
127
|
+
return False, False, message
|
|
128
|
+
|
|
129
|
+
commands = load_chat_commands()
|
|
130
|
+
|
|
131
|
+
for cmd, prompt in commands.items():
|
|
132
|
+
if msg_trimmed == cmd.lower():
|
|
133
|
+
# Special flag so the UI knows whether to reset the session without calling the AI
|
|
134
|
+
is_clear = (cmd.lower() in ["/clear", "/limpar", "/limpiar", "/effacer"])
|
|
135
|
+
return True, is_clear, prompt
|
|
136
|
+
|
|
137
|
+
return False, False, message
|
|
138
|
+
|
|
139
|
+
def call_ai_chat(provider, api_key, api_model, system_instruction, chat_history, new_message, quiet=False):
|
|
140
|
+
"""
|
|
141
|
+
Dedicated engine for the Interactive Chat.
|
|
142
|
+
Keeps the historical context and returns free Markdown (does not force JSON).
|
|
143
|
+
"""
|
|
144
|
+
spinner = Spinner(quiet=quiet)
|
|
145
|
+
spinner.start()
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
if provider == "gemini":
|
|
149
|
+
client = genai.Client(api_key=api_key)
|
|
150
|
+
|
|
151
|
+
# Format the history into the Gemini SDK format
|
|
152
|
+
formatted_contents = []
|
|
153
|
+
for msg in chat_history:
|
|
154
|
+
role = "model" if msg["role"] == "assistant" else "user"
|
|
155
|
+
formatted_contents.append({"role": role, "parts": [{"text": msg["content"]}]})
|
|
156
|
+
|
|
157
|
+
formatted_contents.append({"role": "user", "parts": [{"text": new_message}]})
|
|
158
|
+
|
|
159
|
+
response = client.models.generate_content(
|
|
160
|
+
model=api_model,
|
|
161
|
+
contents=formatted_contents,
|
|
162
|
+
config={
|
|
163
|
+
"system_instruction": system_instruction,
|
|
164
|
+
"temperature": 0.3 # Slightly higher so the chat feels more natural
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
result_text = response.text
|
|
168
|
+
|
|
169
|
+
elif provider in ["deepseek", "ollama"]:
|
|
170
|
+
base_url = "https://api.deepseek.com" if provider == "deepseek" else "http://localhost:11434/v1"
|
|
171
|
+
client = OpenAI(api_key=api_key, base_url=base_url)
|
|
172
|
+
|
|
173
|
+
messages = [{"role": "system", "content": system_instruction}]
|
|
174
|
+
for msg in chat_history:
|
|
175
|
+
messages.append({"role": msg["role"], "content": msg["content"]})
|
|
176
|
+
messages.append({"role": "user", "content": new_message})
|
|
177
|
+
|
|
178
|
+
response = client.chat.completions.create(
|
|
179
|
+
model=api_model,
|
|
180
|
+
messages=messages,
|
|
181
|
+
temperature=0.3
|
|
182
|
+
)
|
|
183
|
+
result_text = response.choices[0].message.content
|
|
184
|
+
else:
|
|
185
|
+
spinner.stop()
|
|
186
|
+
click.secho(__("❌ Unknown AI provider: {provider}", provider=provider), fg="red")
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
spinner.stop()
|
|
190
|
+
return result_text
|
|
191
|
+
|
|
192
|
+
except Exception as e:
|
|
193
|
+
spinner.stop()
|
|
194
|
+
click.secho(__("\r❌ Critical error in Chat API ({provider}): {error}", provider=provider.capitalize(), error=str(e)), fg="red", bold=True)
|
|
195
|
+
return None
|
|
196
|
+
finally:
|
|
197
|
+
spinner.stop()
|
src/blame_engine.py
CHANGED
|
@@ -4,7 +4,7 @@ import re
|
|
|
4
4
|
import os
|
|
5
5
|
from datetime import datetime
|
|
6
6
|
from src.core import get_current_branch
|
|
7
|
-
from src.config import get_api_key, get_api_model, get_ai_provider
|
|
7
|
+
from src.config import get_api_key, get_api_model, get_ai_provider, resolve_skill_path
|
|
8
8
|
from src.ai_providers import call_ai_model
|
|
9
9
|
from src.i18n import __
|
|
10
10
|
|
|
@@ -69,7 +69,7 @@ def analyze_commit_with_ai(commit_hash, file_path):
|
|
|
69
69
|
# Use the 'simple' model (Flash/Lite) to save money in the loop
|
|
70
70
|
api_model = get_api_model(provider, task_complexity="simple")
|
|
71
71
|
|
|
72
|
-
skill_path =
|
|
72
|
+
skill_path = resolve_skill_path(".gitpr.blame.md")
|
|
73
73
|
if os.path.exists(skill_path):
|
|
74
74
|
with open(skill_path, "r", encoding="utf-8") as f:
|
|
75
75
|
sys_inst = f.read()
|
src/chat_memory.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import hashlib
|
|
4
|
+
import random
|
|
5
|
+
import string
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
def gerar_uuid_base_15():
|
|
10
|
+
"""
|
|
11
|
+
Generate a UUID with a base of 15 characters (group 4-5-4)
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
str: UUID in the format XXXX-XXXXX-XXXX
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def gerar_grupo(tamanho):
|
|
18
|
+
"""
|
|
19
|
+
Helper function to generate a group of characters with at least one number
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
tamanho (int): Size of the group to be generated
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
str: Group of characters with at least one number
|
|
26
|
+
"""
|
|
27
|
+
letras = string.ascii_letters # 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
28
|
+
numeros = string.digits # '0123456789'
|
|
29
|
+
todos_caracteres = letras + numeros
|
|
30
|
+
|
|
31
|
+
# Ensures that at least one number will be present in the group
|
|
32
|
+
grupo = []
|
|
33
|
+
numero_inserido = False
|
|
34
|
+
|
|
35
|
+
for i in range(tamanho):
|
|
36
|
+
if i == tamanho - 1 and not numero_inserido:
|
|
37
|
+
# If it is the last character and we have not inserted a number yet
|
|
38
|
+
grupo.append(random.choice(numeros))
|
|
39
|
+
else:
|
|
40
|
+
caractere = random.choice(todos_caracteres)
|
|
41
|
+
if caractere.isdigit():
|
|
42
|
+
numero_inserido = True
|
|
43
|
+
grupo.append(caractere)
|
|
44
|
+
|
|
45
|
+
return ''.join(grupo)
|
|
46
|
+
|
|
47
|
+
# Generate the three groups of characters
|
|
48
|
+
grupo1 = gerar_grupo(4)
|
|
49
|
+
grupo2 = gerar_grupo(5)
|
|
50
|
+
grupo3 = gerar_grupo(4)
|
|
51
|
+
|
|
52
|
+
# Combine the groups into the desired format
|
|
53
|
+
return f"{grupo1}-{grupo2}-{grupo3}"
|
|
54
|
+
|
|
55
|
+
class ChatMemoryManager:
|
|
56
|
+
"""
|
|
57
|
+
State and Memory Manager for GitPR's Hybrid Chat sessions.
|
|
58
|
+
Keeps the message history and tracks code changes (diff) during the session.
|
|
59
|
+
"""
|
|
60
|
+
def __init__(self, repo_name, branch_name, current_diff, git_user, git_email):
|
|
61
|
+
self.repo_name = repo_name
|
|
62
|
+
self.branch_name = branch_name
|
|
63
|
+
self.git_user = git_user
|
|
64
|
+
self.git_email = git_email
|
|
65
|
+
|
|
66
|
+
self.base_dir = Path.home() / ".gitpr" / "cache" / "chat"
|
|
67
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
|
|
69
|
+
self.session_uuid = None
|
|
70
|
+
self.session_dir = None
|
|
71
|
+
self.config_file = None
|
|
72
|
+
self.conversation_file = None
|
|
73
|
+
|
|
74
|
+
self._initialize_session(current_diff)
|
|
75
|
+
|
|
76
|
+
def _generate_md5(self, text):
|
|
77
|
+
return hashlib.md5(text.encode('utf-8')).hexdigest()
|
|
78
|
+
|
|
79
|
+
def _initialize_session(self, current_diff):
|
|
80
|
+
"""Look for an open session for the current branch or create a new one."""
|
|
81
|
+
diff_md5 = self._generate_md5(current_diff)
|
|
82
|
+
latest_session = None
|
|
83
|
+
latest_time = None
|
|
84
|
+
|
|
85
|
+
# Look for the most recent session for this repository and branch
|
|
86
|
+
for session_folder in self.base_dir.iterdir():
|
|
87
|
+
if session_folder.is_dir():
|
|
88
|
+
uuid_str = session_folder.name
|
|
89
|
+
cfg_path = session_folder / f"chat-config_{uuid_str}.json"
|
|
90
|
+
|
|
91
|
+
if cfg_path.exists():
|
|
92
|
+
try:
|
|
93
|
+
with open(cfg_path, "r", encoding="utf-8") as f:
|
|
94
|
+
cfg = json.load(f)
|
|
95
|
+
|
|
96
|
+
if cfg.get("repo") == self.repo_name and cfg.get("branch") == self.branch_name:
|
|
97
|
+
# Compare the modification time to pick the most recent chat for this branch
|
|
98
|
+
mtime = cfg_path.stat().st_mtime
|
|
99
|
+
if latest_time is None or mtime > latest_time:
|
|
100
|
+
latest_time = mtime
|
|
101
|
+
latest_session = (uuid_str, session_folder, cfg_path, cfg)
|
|
102
|
+
except Exception:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
if latest_session:
|
|
106
|
+
# Reopen the existing session
|
|
107
|
+
self.session_uuid, self.session_dir, self.config_file, cfg_data = latest_session
|
|
108
|
+
self.conversation_file = self.session_dir / f"conversation_{self.session_uuid}.json"
|
|
109
|
+
|
|
110
|
+
# Check whether the code (diff) has changed since last time
|
|
111
|
+
diff_history = cfg_data.get("diff_history", [])
|
|
112
|
+
last_diff_md5 = diff_history[-1]["md5"] if diff_history else None
|
|
113
|
+
|
|
114
|
+
if last_diff_md5 != diff_md5:
|
|
115
|
+
self._append_diff_to_history(cfg_data, current_diff, diff_md5)
|
|
116
|
+
else:
|
|
117
|
+
# No session found for this branch, create a new one from scratch
|
|
118
|
+
self._create_new_session(current_diff, diff_md5)
|
|
119
|
+
|
|
120
|
+
def _append_diff_to_history(self, cfg_data, diff_text, diff_md5):
|
|
121
|
+
"""Store the new diff in the configuration file."""
|
|
122
|
+
new_entry = {
|
|
123
|
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
124
|
+
"md5": diff_md5,
|
|
125
|
+
"diff": diff_text
|
|
126
|
+
}
|
|
127
|
+
cfg_data.setdefault("diff_history", []).append(new_entry)
|
|
128
|
+
|
|
129
|
+
with open(self.config_file, "w", encoding="utf-8") as f:
|
|
130
|
+
json.dump(cfg_data, f, indent=2, ensure_ascii=False)
|
|
131
|
+
|
|
132
|
+
def _create_new_session(self, current_diff, diff_md5):
|
|
133
|
+
"""Set up a new base-15 UUID folder with the initial JSON files."""
|
|
134
|
+
self.session_uuid = gerar_uuid_base_15()
|
|
135
|
+
self.session_dir = self.base_dir / self.session_uuid
|
|
136
|
+
self.session_dir.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
|
|
138
|
+
self.config_file = self.session_dir / f"chat-config_{self.session_uuid}.json"
|
|
139
|
+
self.conversation_file = self.session_dir / f"conversation_{self.session_uuid}.json"
|
|
140
|
+
|
|
141
|
+
initial_config = {
|
|
142
|
+
"session_uuid": self.session_uuid,
|
|
143
|
+
"folder_name": self.session_uuid,
|
|
144
|
+
"repo": self.repo_name,
|
|
145
|
+
"branch": self.branch_name,
|
|
146
|
+
"git_user": self.git_user,
|
|
147
|
+
"git_email": self.git_email,
|
|
148
|
+
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
149
|
+
"diff_history": [
|
|
150
|
+
{
|
|
151
|
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
152
|
+
"md5": diff_md5,
|
|
153
|
+
"diff": current_diff
|
|
154
|
+
}
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# Write the metadata
|
|
159
|
+
with open(self.config_file, "w", encoding="utf-8") as f:
|
|
160
|
+
json.dump(initial_config, f, indent=2, ensure_ascii=False)
|
|
161
|
+
|
|
162
|
+
# Create the empty chat memory
|
|
163
|
+
with open(self.conversation_file, "w", encoding="utf-8") as f:
|
|
164
|
+
json.dump([], f, indent=2, ensure_ascii=False)
|
|
165
|
+
|
|
166
|
+
def get_history(self):
|
|
167
|
+
"""Return the conversation history from the JSON file."""
|
|
168
|
+
if self.conversation_file.exists():
|
|
169
|
+
try:
|
|
170
|
+
with open(self.conversation_file, "r", encoding="utf-8") as f:
|
|
171
|
+
return json.load(f)
|
|
172
|
+
except Exception:
|
|
173
|
+
return []
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
def save_message(self, role, content):
|
|
177
|
+
"""
|
|
178
|
+
Save a new message to the conversation.
|
|
179
|
+
'role' must be 'user', 'assistant' or 'system'.
|
|
180
|
+
"""
|
|
181
|
+
history = self.get_history()
|
|
182
|
+
history.append({
|
|
183
|
+
"role": role,
|
|
184
|
+
"content": content,
|
|
185
|
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
186
|
+
})
|
|
187
|
+
with open(self.conversation_file, "w", encoding="utf-8") as f:
|
|
188
|
+
json.dump(history, f, indent=2, ensure_ascii=False)
|
|
189
|
+
|
|
190
|
+
def get_latest_diff(self):
|
|
191
|
+
"""Retrieve the most recent current diff from the session configuration."""
|
|
192
|
+
try:
|
|
193
|
+
with open(self.config_file, "r", encoding="utf-8") as f:
|
|
194
|
+
cfg = json.load(f)
|
|
195
|
+
return cfg.get("diff_history", [])[-1]["diff"]
|
|
196
|
+
except Exception:
|
|
197
|
+
return ""
|
|
198
|
+
|
|
199
|
+
def update_diff_if_changed(self, new_diff):
|
|
200
|
+
"""
|
|
201
|
+
Silently check whether the code has changed.
|
|
202
|
+
Used by the F2 key to update the chat context in real time.
|
|
203
|
+
Returns True if the diff was updated, False otherwise.
|
|
204
|
+
"""
|
|
205
|
+
new_md5 = self._generate_md5(new_diff)
|
|
206
|
+
try:
|
|
207
|
+
with open(self.config_file, "r", encoding="utf-8") as f:
|
|
208
|
+
cfg = json.load(f)
|
|
209
|
+
|
|
210
|
+
diff_history = cfg.get("diff_history", [])
|
|
211
|
+
last_diff_md5 = diff_history[-1]["md5"] if diff_history else None
|
|
212
|
+
|
|
213
|
+
if last_diff_md5 != new_md5:
|
|
214
|
+
self._append_diff_to_history(cfg, new_diff, new_md5)
|
|
215
|
+
return True
|
|
216
|
+
return False
|
|
217
|
+
except Exception:
|
|
218
|
+
return False
|
src/config.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import sys
|
|
3
3
|
import socket
|
|
4
|
+
import shutil
|
|
4
5
|
import click
|
|
5
6
|
import yaml
|
|
6
7
|
from pathlib import Path
|
|
@@ -28,6 +29,41 @@ DEFAULT_CONFIG = {
|
|
|
28
29
|
"OUTPUT_FILE_NAME_ISSUE": "{branch}_{datetime}_ISSUE.md"
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
def get_skill_dir():
|
|
33
|
+
"""Returns the absolute path to the project's skill folder (.gitpr/skill)."""
|
|
34
|
+
return os.path.join(os.getcwd(), ".gitpr", "skill")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_skill_path(filename):
|
|
38
|
+
"""
|
|
39
|
+
Resolves the path of a skill/config file (e.g.: .gitpr.commit.md).
|
|
40
|
+
|
|
41
|
+
The canonical location is the '.gitpr/skill/' folder inside the project.
|
|
42
|
+
For backward compatibility, if the file still lives in the project root,
|
|
43
|
+
it is transparently migrated (moved) into '.gitpr/skill/'.
|
|
44
|
+
|
|
45
|
+
Always returns the path inside '.gitpr/skill/' (whether the file exists or
|
|
46
|
+
not), unless a migration failed — in that case it falls back to the legacy
|
|
47
|
+
root path so the tool keeps working.
|
|
48
|
+
"""
|
|
49
|
+
skill_dir = get_skill_dir()
|
|
50
|
+
target_path = os.path.join(skill_dir, filename)
|
|
51
|
+
legacy_path = os.path.join(os.getcwd(), filename)
|
|
52
|
+
|
|
53
|
+
# Migrate a legacy root file into the skill folder (only if not already there)
|
|
54
|
+
if os.path.exists(legacy_path) and not os.path.exists(target_path):
|
|
55
|
+
try:
|
|
56
|
+
os.makedirs(skill_dir, exist_ok=True)
|
|
57
|
+
shutil.move(legacy_path, target_path)
|
|
58
|
+
click.secho(__("📦 Skill file {filename} moved to .gitpr/skill/", filename=filename), fg="cyan", dim=True)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
# If moving fails, fall back to the legacy location so the tool keeps working
|
|
61
|
+
click.secho(__("⚠️ Warning: Could not move {filename} to .gitpr/skill/ ({error})", filename=filename, error=str(e)), fg="yellow")
|
|
62
|
+
return legacy_path
|
|
63
|
+
|
|
64
|
+
return target_path
|
|
65
|
+
|
|
66
|
+
|
|
31
67
|
def get_ai_provider():
|
|
32
68
|
"""Returns the configured default AI provider, or 'gemini' as fallback."""
|
|
33
69
|
load_dotenv(ENV_FILE)
|
|
@@ -37,6 +73,11 @@ def get_api_key(provider):
|
|
|
37
73
|
"""Reads and decrypts the API key corresponding to the chosen provider."""
|
|
38
74
|
load_dotenv(ENV_FILE)
|
|
39
75
|
|
|
76
|
+
# Suporte a CI/CD: Tenta ler a chave raw primeiro (ex: injetada via GitHub Secrets)
|
|
77
|
+
raw_key = os.getenv(f"{provider.upper()}_API_KEY")
|
|
78
|
+
if raw_key:
|
|
79
|
+
return raw_key
|
|
80
|
+
|
|
40
81
|
if provider == "gemini":
|
|
41
82
|
encrypted_key = os.getenv("GEMINI_API_KEY_ENCRYPTED")
|
|
42
83
|
elif provider == "deepseek":
|
|
@@ -99,6 +140,12 @@ def setup_environment():
|
|
|
99
140
|
# Check if the chosen provider's key exists
|
|
100
141
|
api_key = get_api_key(provider)
|
|
101
142
|
if not api_key:
|
|
143
|
+
# 🛡️ Escudo de CI/CD: Impede que o prompt trave a pipeline do GitHub Actions
|
|
144
|
+
if os.getenv("CI") or os.getenv("GITHUB_ACTIONS"):
|
|
145
|
+
click.secho(__("❌ Error: API Key not configured for provider '{provider}' in the CI/CD environment.", provider=provider), fg="red")
|
|
146
|
+
click.secho(__("💡 Tip: Pass the key as an environment variable (e.g., GEMINI_API_KEY)."), fg="yellow")
|
|
147
|
+
sys.exit(1)
|
|
148
|
+
|
|
102
149
|
click.secho(__("🔑 API Key for {provider} not found.", provider=provider.capitalize()), fg="yellow")
|
|
103
150
|
raw_key = click.prompt(__("Paste your {provider} API key here", provider=provider.capitalize()), hide_input=True)
|
|
104
151
|
|
|
@@ -136,7 +183,7 @@ def load_linter_rules():
|
|
|
136
183
|
Loads the static linter rules from the .gitpr.linter.yml file.
|
|
137
184
|
Returns a list of rules or an empty list if the file does not exist.
|
|
138
185
|
"""
|
|
139
|
-
file_path =
|
|
186
|
+
file_path = resolve_skill_path(".gitpr.linter.yml")
|
|
140
187
|
|
|
141
188
|
# If the file does not exist in the project, it's not an error. There are simply no rules to apply.
|
|
142
189
|
if not os.path.exists(file_path):
|