gitpr-cli 0.0.37__tar.gz → 0.0.38__tar.gz
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.37 → gitpr_cli-0.0.38}/PKG-INFO +1 -1
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/gitpr_cli.egg-info/PKG-INFO +1 -1
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/gitpr_cli.egg-info/SOURCES.txt +5 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ai_providers.py +46 -21
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/blame_engine.py +23 -9
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/config.py +31 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/core.py +4 -2
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/i18n.py +16 -12
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/issue_engine.py +42 -10
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/linter_engine.py +116 -27
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/main.py +2 -2
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/mcp_server.py +23 -6
- gitpr_cli-0.0.38/src/net.py +74 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/updater.py +2 -2
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_blame_metrics.py +86 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_chat_backend.py +18 -7
- gitpr_cli-0.0.38/tests/test_external_linters.py +488 -0
- gitpr_cli-0.0.38/tests/test_github_api.py +384 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_i18n.py +154 -6
- gitpr_cli-0.0.38/tests/test_issue_engine.py +89 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_mcp_server.py +14 -0
- gitpr_cli-0.0.38/tests/test_net_timeouts.py +167 -0
- gitpr_cli-0.0.38/tests/test_pr_publish_app.py +528 -0
- gitpr_cli-0.0.37/tests/test_external_linters.py +0 -209
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/LICENSE +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/README.md +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/gitpr_cli.egg-info/dependency_links.txt +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/gitpr_cli.egg-info/entry_points.txt +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/gitpr_cli.egg-info/requires.txt +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/gitpr_cli.egg-info/top_level.txt +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/pyproject.toml +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/setup.cfg +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/__init__.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/cache.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/chat_memory.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/github_api.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/linter_wizard.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/metrics.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/security.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/spinner.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/tui_issue.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ui/__init__.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ui/chat_app.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ui/help_screen.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ui/issue_app.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ui/linter_app.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ui/metrics_app.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ui/pr_publish_app.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ui/pr_publish_help.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_core.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_install_wizard.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_linter_metrics.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_mcp_prompts.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_mcp_server_e2e.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_metrics.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_plugins.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_pr_publish_linter_modal.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_pre_save.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_skill_command.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_smart_excludes.py +0 -0
- {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_thinking_words.py +0 -0
|
@@ -22,6 +22,7 @@ src/linter_wizard.py
|
|
|
22
22
|
src/main.py
|
|
23
23
|
src/mcp_server.py
|
|
24
24
|
src/metrics.py
|
|
25
|
+
src/net.py
|
|
25
26
|
src/security.py
|
|
26
27
|
src/spinner.py
|
|
27
28
|
src/tui_issue.py
|
|
@@ -38,14 +39,18 @@ tests/test_blame_metrics.py
|
|
|
38
39
|
tests/test_chat_backend.py
|
|
39
40
|
tests/test_core.py
|
|
40
41
|
tests/test_external_linters.py
|
|
42
|
+
tests/test_github_api.py
|
|
41
43
|
tests/test_i18n.py
|
|
42
44
|
tests/test_install_wizard.py
|
|
45
|
+
tests/test_issue_engine.py
|
|
43
46
|
tests/test_linter_metrics.py
|
|
44
47
|
tests/test_mcp_prompts.py
|
|
45
48
|
tests/test_mcp_server.py
|
|
46
49
|
tests/test_mcp_server_e2e.py
|
|
47
50
|
tests/test_metrics.py
|
|
51
|
+
tests/test_net_timeouts.py
|
|
48
52
|
tests/test_plugins.py
|
|
53
|
+
tests/test_pr_publish_app.py
|
|
49
54
|
tests/test_pr_publish_linter_modal.py
|
|
50
55
|
tests/test_pre_save.py
|
|
51
56
|
tests/test_skill_command.py
|
|
@@ -4,13 +4,36 @@ from datetime import datetime
|
|
|
4
4
|
import click
|
|
5
5
|
from google import genai
|
|
6
6
|
from openai import OpenAI
|
|
7
|
-
import urllib.request
|
|
8
7
|
from pathlib import Path
|
|
9
8
|
|
|
9
|
+
from src.net import bounded_resolve, bounded_urlopen
|
|
10
10
|
from src.spinner import Spinner
|
|
11
11
|
from src.i18n import __, CURRENT_LANG
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def _make_gemini_client(api_key, timeout_s):
|
|
15
|
+
"""Builds a Gemini client with an explicit request timeout.
|
|
16
|
+
|
|
17
|
+
google-genai expresses http_options.timeout in MILLISECONDS.
|
|
18
|
+
"""
|
|
19
|
+
bounded_resolve("generativelanguage.googleapis.com")
|
|
20
|
+
return genai.Client(
|
|
21
|
+
api_key=api_key, http_options={"timeout": int(timeout_s * 1000)}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _make_openai_client(api_key, provider, timeout_s):
|
|
26
|
+
"""Builds an OpenAI-compatible client (DeepSeek/Ollama) with a timeout in seconds."""
|
|
27
|
+
base_url = (
|
|
28
|
+
"https://api.deepseek.com"
|
|
29
|
+
if provider == "deepseek"
|
|
30
|
+
else "http://localhost:11434/v1"
|
|
31
|
+
)
|
|
32
|
+
if provider == "deepseek":
|
|
33
|
+
bounded_resolve("api.deepseek.com")
|
|
34
|
+
return OpenAI(api_key=api_key, base_url=base_url, timeout=timeout_s)
|
|
35
|
+
|
|
36
|
+
|
|
14
37
|
# Hidden --pre-save debug flag: when enabled, every AI payload is dumped
|
|
15
38
|
# to a JSON file in the current directory before being sent to the model.
|
|
16
39
|
PRE_SAVE_ENABLED = False
|
|
@@ -88,8 +111,11 @@ def call_ai_model(
|
|
|
88
111
|
Unified engine for AI calls.
|
|
89
112
|
Supports 'gemini' and 'deepseek'.
|
|
90
113
|
"""
|
|
114
|
+
from src.config import get_ai_timeout
|
|
115
|
+
|
|
91
116
|
max_retries = 3
|
|
92
117
|
retry_delay = 2
|
|
118
|
+
timeout_s = get_ai_timeout()
|
|
93
119
|
|
|
94
120
|
if PRE_SAVE_ENABLED:
|
|
95
121
|
saved_file = _save_pre_save_payload(
|
|
@@ -112,7 +138,7 @@ def call_ai_model(
|
|
|
112
138
|
try:
|
|
113
139
|
meta_raw = {}
|
|
114
140
|
if provider == "gemini":
|
|
115
|
-
client =
|
|
141
|
+
client = _make_gemini_client(api_key, timeout_s)
|
|
116
142
|
response = client.models.generate_content(
|
|
117
143
|
model=api_model,
|
|
118
144
|
contents=prompt,
|
|
@@ -141,12 +167,7 @@ def call_ai_model(
|
|
|
141
167
|
|
|
142
168
|
elif provider in ["deepseek", "ollama"]:
|
|
143
169
|
# DeepSeek and Ollama are 100% compatible with the OpenAI library.
|
|
144
|
-
|
|
145
|
-
"https://api.deepseek.com"
|
|
146
|
-
if provider == "deepseek"
|
|
147
|
-
else "http://localhost:11434/v1"
|
|
148
|
-
)
|
|
149
|
-
client = OpenAI(api_key=api_key, base_url=base_url)
|
|
170
|
+
client = _make_openai_client(api_key, provider, timeout_s)
|
|
150
171
|
|
|
151
172
|
response = client.chat.completions.create(
|
|
152
173
|
model=api_model,
|
|
@@ -244,17 +265,22 @@ def load_chat_commands():
|
|
|
244
265
|
except Exception:
|
|
245
266
|
pass
|
|
246
267
|
|
|
247
|
-
# If it is not in the cache, download it from the remote repository
|
|
268
|
+
# If it is not in the cache, download it from the remote repository.
|
|
269
|
+
# bounded_urlopen also bounds DNS resolution, which urllib's timeout does not.
|
|
248
270
|
try:
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
271
|
+
raw = bounded_urlopen(
|
|
272
|
+
url, timeout=5, headers={"User-Agent": "GitPR-Chat"}
|
|
273
|
+
)
|
|
274
|
+
if raw is not None:
|
|
275
|
+
data = json.loads(raw.decode())
|
|
252
276
|
with open(cache_file, "w", encoding="utf-8") as f:
|
|
253
277
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
254
278
|
return data
|
|
255
279
|
except Exception:
|
|
256
|
-
#
|
|
257
|
-
|
|
280
|
+
pass # Fall through to the offline defaults below
|
|
281
|
+
|
|
282
|
+
# Safety fallback if the user is offline
|
|
283
|
+
return {
|
|
258
284
|
"/explain": "Explains the diff line by line.",
|
|
259
285
|
"/tests": "Generates unit tests for the changed functions.",
|
|
260
286
|
"/optimize": "Analyzes cyclomatic complexity and performance.",
|
|
@@ -295,6 +321,10 @@ def call_ai_chat(
|
|
|
295
321
|
Dedicated engine for the Interactive Chat.
|
|
296
322
|
Keeps the historical context and returns free Markdown (does not force JSON).
|
|
297
323
|
"""
|
|
324
|
+
from src.config import get_ai_timeout
|
|
325
|
+
|
|
326
|
+
timeout_s = get_ai_timeout()
|
|
327
|
+
|
|
298
328
|
if PRE_SAVE_ENABLED:
|
|
299
329
|
saved_file = _save_pre_save_payload(
|
|
300
330
|
"chat",
|
|
@@ -316,7 +346,7 @@ def call_ai_chat(
|
|
|
316
346
|
|
|
317
347
|
try:
|
|
318
348
|
if provider == "gemini":
|
|
319
|
-
client =
|
|
349
|
+
client = _make_gemini_client(api_key, timeout_s)
|
|
320
350
|
|
|
321
351
|
# Format the history into the Gemini SDK format
|
|
322
352
|
formatted_contents = []
|
|
@@ -341,12 +371,7 @@ def call_ai_chat(
|
|
|
341
371
|
result_text = response.text
|
|
342
372
|
|
|
343
373
|
elif provider in ["deepseek", "ollama"]:
|
|
344
|
-
|
|
345
|
-
"https://api.deepseek.com"
|
|
346
|
-
if provider == "deepseek"
|
|
347
|
-
else "http://localhost:11434/v1"
|
|
348
|
-
)
|
|
349
|
-
client = OpenAI(api_key=api_key, base_url=base_url)
|
|
374
|
+
client = _make_openai_client(api_key, provider, timeout_s)
|
|
350
375
|
|
|
351
376
|
messages = [{"role": "system", "content": system_instruction}]
|
|
352
377
|
for msg in chat_history:
|
|
@@ -3,7 +3,7 @@ import click
|
|
|
3
3
|
import re
|
|
4
4
|
import os
|
|
5
5
|
from datetime import datetime
|
|
6
|
-
from src.core import get_current_branch, resolve_output_path
|
|
6
|
+
from src.core import get_current_branch, get_skill_context, resolve_output_path
|
|
7
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 __
|
|
@@ -77,8 +77,13 @@ def get_commit_info(commit_hash):
|
|
|
77
77
|
return {"author": __("Unknown"), "date": __("Unknown"), "message": __("No message")}
|
|
78
78
|
|
|
79
79
|
|
|
80
|
-
def analyze_commit_with_ai(commit_hash, file_path):
|
|
81
|
-
"""Uses AI to read the diff and classify as ORIGIN or REFACTORING.
|
|
80
|
+
def analyze_commit_with_ai(commit_hash, file_path, sys_inst=None):
|
|
81
|
+
"""Uses AI to read the diff and classify as ORIGIN or REFACTORING.
|
|
82
|
+
|
|
83
|
+
sys_inst: skill context pre-loaded once by run_blame_analysis() (avoids
|
|
84
|
+
repeating the confirmation message for every commit). When None, the
|
|
85
|
+
skill file is loaded silently (return_data/silent modes).
|
|
86
|
+
"""
|
|
82
87
|
diff = execute_git_show(commit_hash, file_path)
|
|
83
88
|
if not diff:
|
|
84
89
|
return {
|
|
@@ -94,11 +99,14 @@ def analyze_commit_with_ai(commit_hash, file_path):
|
|
|
94
99
|
# Use the 'simple' model (Flash/Lite) to save money in the loop
|
|
95
100
|
api_model = get_api_model(provider, task_complexity="simple")
|
|
96
101
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
if sys_inst is None:
|
|
103
|
+
# Silent modes: load the skill file without the confirmation message
|
|
104
|
+
skill_path = resolve_skill_path(".gitpr.blame.md")
|
|
105
|
+
if os.path.exists(skill_path):
|
|
106
|
+
with open(skill_path, "r", encoding="utf-8") as f:
|
|
107
|
+
sys_inst = f.read()
|
|
108
|
+
|
|
109
|
+
if not sys_inst:
|
|
102
110
|
sys_inst = __(
|
|
103
111
|
'You are a Software Architect. Analyze the diff and determine if it is the ORIGIN of the rule (new logic) or REFACTORING. Respond ONLY with JSON: {"status": "ORIGIN", "reason": "Explain what was introduced"} or {"status": "REFACTORING", "reason": "Explain what was changed"}'
|
|
104
112
|
)
|
|
@@ -153,6 +161,12 @@ def run_blame_analysis(file_path, start_line, end_line, return_data=False):
|
|
|
153
161
|
click.secho(__("⚠️ No traceable commits found in these lines."), fg="yellow")
|
|
154
162
|
return [] if return_data else None
|
|
155
163
|
|
|
164
|
+
# Load the blame skill ONCE per run — passing it by parameter avoids the
|
|
165
|
+
# "found and loaded" message repeating for every commit analyzed.
|
|
166
|
+
# Silent modes (return_data) stay quiet and fall back to the internal
|
|
167
|
+
# silent load inside analyze_commit_with_ai.
|
|
168
|
+
skill_context = get_skill_context("blame") if not return_data else None
|
|
169
|
+
|
|
156
170
|
if not return_data:
|
|
157
171
|
click.secho(
|
|
158
172
|
__(
|
|
@@ -177,7 +191,7 @@ def run_blame_analysis(file_path, start_line, end_line, return_data=False):
|
|
|
177
191
|
|
|
178
192
|
seen_hashes.add(current_commit)
|
|
179
193
|
info = get_commit_info(current_commit)
|
|
180
|
-
ai_analysis = analyze_commit_with_ai(current_commit, file_path)
|
|
194
|
+
ai_analysis = analyze_commit_with_ai(current_commit, file_path, skill_context)
|
|
181
195
|
|
|
182
196
|
status = str(ai_analysis.get("status", "ORIGIN")).upper()
|
|
183
197
|
reason = str(ai_analysis.get("reason", ""))
|
|
@@ -36,8 +36,14 @@ DEFAULT_CONFIG = {
|
|
|
36
36
|
"PR_PUBLISH_LOG": "true",
|
|
37
37
|
"GITPR_AUTO_MERGE": "false",
|
|
38
38
|
"OUTPUT_FILE_NAME_LINTER": "{branch}_{datetime}_LINTER.md",
|
|
39
|
+
"GITPR_AI_TIMEOUT": "180",
|
|
40
|
+
"GITPR_LINTER_TIMEOUT": "120",
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
# Fallbacks used when the .env value is missing or not a positive number.
|
|
44
|
+
_DEFAULT_AI_TIMEOUT = 180.0
|
|
45
|
+
_DEFAULT_LINTER_TIMEOUT = 120.0
|
|
46
|
+
|
|
41
47
|
|
|
42
48
|
def get_skill_dir():
|
|
43
49
|
"""Returns the absolute path to the project's skill folder (.gitpr/skill)."""
|
|
@@ -93,6 +99,31 @@ def get_ai_provider():
|
|
|
93
99
|
return os.getenv("DEFAULT_AI_PROVIDER", "gemini").lower()
|
|
94
100
|
|
|
95
101
|
|
|
102
|
+
def _positive_float(raw, fallback):
|
|
103
|
+
"""Parses *raw* as a positive float, falling back on junk or non-positive values."""
|
|
104
|
+
try:
|
|
105
|
+
value = float(raw)
|
|
106
|
+
except (TypeError, ValueError):
|
|
107
|
+
return fallback
|
|
108
|
+
return value if value > 0 else fallback
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_ai_timeout():
|
|
112
|
+
"""Returns the AI SDK request timeout in seconds (GITPR_AI_TIMEOUT, default 600).
|
|
113
|
+
|
|
114
|
+
Bounds a single model call so a hung provider can never freeze the CLI
|
|
115
|
+
indefinitely. Invalid or non-positive values fall back to the default.
|
|
116
|
+
"""
|
|
117
|
+
load_dotenv(ENV_FILE)
|
|
118
|
+
return _positive_float(os.getenv("GITPR_AI_TIMEOUT"), _DEFAULT_AI_TIMEOUT)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_linter_timeout():
|
|
122
|
+
"""Returns the external linter subprocess timeout in seconds (default 120)."""
|
|
123
|
+
load_dotenv(ENV_FILE)
|
|
124
|
+
return _positive_float(os.getenv("GITPR_LINTER_TIMEOUT"), _DEFAULT_LINTER_TIMEOUT)
|
|
125
|
+
|
|
126
|
+
|
|
96
127
|
def coauthor_enabled():
|
|
97
128
|
"""Returns True if the Gitpr-cli co-author trailer should be appended (default True).
|
|
98
129
|
|
|
@@ -617,7 +617,7 @@ def get_repo_name():
|
|
|
617
617
|
|
|
618
618
|
|
|
619
619
|
def get_skill_context(action_type="pr"):
|
|
620
|
-
"""Reads the correct context file based on the action (PR/Commit
|
|
620
|
+
"""Reads the correct context file based on the action (PR/Commit/Review/Issue/Blame)."""
|
|
621
621
|
|
|
622
622
|
# Define which file to look for
|
|
623
623
|
if action_type == "commit":
|
|
@@ -628,6 +628,8 @@ def get_skill_context(action_type="pr"):
|
|
|
628
628
|
target_file = ".gitpr.filereview.md"
|
|
629
629
|
elif action_type == "issue":
|
|
630
630
|
target_file = ".gitpr.issue.md"
|
|
631
|
+
elif action_type == "blame":
|
|
632
|
+
target_file = ".gitpr.blame.md"
|
|
631
633
|
else: # review or fullreview
|
|
632
634
|
target_file = ".gitpr.review.md"
|
|
633
635
|
|
|
@@ -644,10 +646,10 @@ def get_skill_context(action_type="pr"):
|
|
|
644
646
|
)
|
|
645
647
|
|
|
646
648
|
if file_to_load:
|
|
649
|
+
nome_arquivo = os.path.basename(file_to_load)
|
|
647
650
|
try:
|
|
648
651
|
with open(file_to_load, "r", encoding="utf-8") as f:
|
|
649
652
|
conteudo = f.read()
|
|
650
|
-
nome_arquivo = os.path.basename(file_to_load)
|
|
651
653
|
click.secho(
|
|
652
654
|
__(
|
|
653
655
|
"🧠 File {file_name} (Skill) found and loaded!",
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import json
|
|
3
3
|
import locale
|
|
4
|
-
import urllib.request
|
|
5
4
|
from pathlib import Path
|
|
6
5
|
from dotenv import load_dotenv, set_key
|
|
7
6
|
|
|
7
|
+
from src.net import bounded_urlopen
|
|
8
|
+
|
|
8
9
|
# Global path to the .env file
|
|
9
10
|
env_path = Path.home() / ".gitpr" / ".env"
|
|
10
11
|
load_dotenv(env_path)
|
|
@@ -55,18 +56,21 @@ def get_translations(lang_code):
|
|
|
55
56
|
if not local_file.exists() or needs_update:
|
|
56
57
|
remote_url = f"https://raw.githubusercontent.com/natanfiuza/gitpr/main/langs/{lang_code}.json"
|
|
57
58
|
try:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
59
|
+
# bounded_urlopen also bounds DNS resolution, which urllib's
|
|
60
|
+
# timeout does not — a stalled resolver must never block startup.
|
|
61
|
+
raw = bounded_urlopen(remote_url, timeout=3)
|
|
62
|
+
if raw is not None:
|
|
63
|
+
with open(local_file, "w", encoding="utf-8") as f:
|
|
64
|
+
f.write(raw.decode("utf-8"))
|
|
65
|
+
|
|
66
|
+
# Update .env with the new version after successful download
|
|
67
|
+
set_key(env_path, "LANG_VERSION", __lang_version__)
|
|
66
68
|
except Exception:
|
|
67
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
pass # Fall through: the stale local file below is the fallback
|
|
70
|
+
|
|
71
|
+
# In case of failure (e.g.: offline), if the old file exists, use it
|
|
72
|
+
if not local_file.exists():
|
|
73
|
+
return {}
|
|
70
74
|
|
|
71
75
|
if local_file.exists():
|
|
72
76
|
try:
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import subprocess
|
|
2
2
|
import re
|
|
3
|
-
import os
|
|
4
3
|
import time
|
|
5
4
|
import click
|
|
6
5
|
from src.ai_providers import call_ai_model
|
|
7
6
|
from src.cache import get_cached_response, save_cached_response
|
|
8
|
-
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
|
|
9
8
|
from src.ai_providers import call_ai_model
|
|
10
9
|
from src.i18n import __
|
|
11
|
-
from src.core import
|
|
10
|
+
from src.core import (
|
|
11
|
+
estimate_token_count,
|
|
12
|
+
split_diff_into_chunks,
|
|
13
|
+
get_doc_url,
|
|
14
|
+
get_skill_context,
|
|
15
|
+
get_changed_docs_list,
|
|
16
|
+
)
|
|
12
17
|
|
|
13
18
|
|
|
14
19
|
def get_github_repo_info():
|
|
@@ -56,17 +61,44 @@ def generate_issue_content(context_text, context_type="diff"):
|
|
|
56
61
|
# Use the advanced model to ensure Issue structure quality
|
|
57
62
|
api_model = get_api_model(provider, task_complexity="advanced")
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if
|
|
63
|
-
with open(skill_path, "r", encoding="utf-8") as f:
|
|
64
|
-
sys_inst = f.read()
|
|
65
|
-
else:
|
|
64
|
+
# Load the issue skill with visual feedback (parity with the PR flow);
|
|
65
|
+
# the default Software Architect persona remains the fallback.
|
|
66
|
+
sys_inst = get_skill_context("issue")
|
|
67
|
+
if not sys_inst:
|
|
66
68
|
sys_inst = __(
|
|
67
69
|
"You are a Software Architect. Follow the What / Why / Where / How format to document the Issue."
|
|
68
70
|
)
|
|
69
71
|
|
|
72
|
+
# ── Inject changed documentation file list as metadata (no content) ──
|
|
73
|
+
# Parity with the PR flow: the diff excludes docs via Smart Excludes
|
|
74
|
+
# pathspec, so the AI must know which docs were touched without their
|
|
75
|
+
# full prose/markup content (excluded from the diff).
|
|
76
|
+
if context_type == "diff":
|
|
77
|
+
try:
|
|
78
|
+
changed_docs = get_changed_docs_list()
|
|
79
|
+
if changed_docs:
|
|
80
|
+
docs_section = __(
|
|
81
|
+
"Changed documentation (content excluded from diff):\n"
|
|
82
|
+
)
|
|
83
|
+
for doc in changed_docs:
|
|
84
|
+
docs_section += f"- {doc}\n"
|
|
85
|
+
sys_inst = docs_section + "\n" + sys_inst
|
|
86
|
+
click.secho(
|
|
87
|
+
__(
|
|
88
|
+
"📄 {count} documentation file(s) excluded from diff (Smart Excludes).",
|
|
89
|
+
count=len(changed_docs),
|
|
90
|
+
),
|
|
91
|
+
fg="blue",
|
|
92
|
+
dim=True,
|
|
93
|
+
)
|
|
94
|
+
click.secho(
|
|
95
|
+
f"📚 {__('Learn more:')} {get_doc_url('smart-excludes.md')}",
|
|
96
|
+
fg="blue",
|
|
97
|
+
underline=True,
|
|
98
|
+
)
|
|
99
|
+
except Exception:
|
|
100
|
+
pass # Non-critical — never block the main flow for this metadata
|
|
101
|
+
|
|
70
102
|
# Adaptive Brain (Dynamic Prompt)
|
|
71
103
|
if context_type == "blame":
|
|
72
104
|
target_action = __(
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import re
|
|
2
2
|
import fnmatch
|
|
3
|
+
import shlex
|
|
4
|
+
import shutil
|
|
3
5
|
import subprocess
|
|
4
6
|
import os
|
|
5
7
|
import xml.etree.ElementTree as ET
|
|
6
|
-
from src.config import load_linter_rules, load_external_linters
|
|
8
|
+
from src.config import load_linter_rules, load_external_linters, get_linter_timeout
|
|
7
9
|
from src.i18n import __
|
|
8
10
|
from src.metrics import log_local_metric
|
|
9
11
|
|
|
@@ -69,18 +71,44 @@ def _apply_rule(rule, code_line, line_number, current_file, alerts):
|
|
|
69
71
|
)
|
|
70
72
|
|
|
71
73
|
|
|
72
|
-
def
|
|
73
|
-
"""
|
|
74
|
+
def _split_command(command):
|
|
75
|
+
"""Splits a configured linter command string into an argv list.
|
|
76
|
+
|
|
77
|
+
Uses POSIX quoting rules (so "--report=checkstyle" loses its quotes and
|
|
78
|
+
quoted paths stay whole) but with backslash escaping DISABLED, otherwise
|
|
79
|
+
Windows paths like C:\\tools\\lint.exe would be mangled into C:toolslint.exe.
|
|
80
|
+
"""
|
|
81
|
+
lexer = shlex.shlex(command, posix=True)
|
|
82
|
+
lexer.whitespace_split = True
|
|
83
|
+
lexer.escape = ""
|
|
84
|
+
return list(lexer)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _run_external_linter(command, file_path, timeout=None):
|
|
88
|
+
"""Executes an external linter command and returns its stdout (Checkstyle XML).
|
|
89
|
+
|
|
90
|
+
The command runs as an argv list WITHOUT a shell, so metacharacters in the
|
|
91
|
+
configured command or in the target path are never interpreted (a file named
|
|
92
|
+
'a; rm -rf ~' is passed as one literal argument). Because CreateProcess only
|
|
93
|
+
auto-appends .exe, the executable is resolved through shutil.which() so
|
|
94
|
+
PATHEXT shims such as npx.cmd keep working on Windows without shell=True.
|
|
95
|
+
"""
|
|
74
96
|
try:
|
|
75
|
-
|
|
76
|
-
|
|
97
|
+
argv = _split_command(command)
|
|
98
|
+
if not argv:
|
|
99
|
+
return ""
|
|
100
|
+
|
|
101
|
+
resolved = shutil.which(argv[0])
|
|
102
|
+
if resolved:
|
|
103
|
+
argv[0] = resolved
|
|
104
|
+
|
|
105
|
+
argv.append(file_path)
|
|
77
106
|
|
|
78
107
|
result = subprocess.run(
|
|
79
|
-
|
|
80
|
-
shell=True,
|
|
108
|
+
argv,
|
|
81
109
|
capture_output=True,
|
|
82
110
|
stdin=subprocess.DEVNULL,
|
|
83
|
-
timeout=
|
|
111
|
+
timeout=timeout if timeout is not None else get_linter_timeout(),
|
|
84
112
|
text=True,
|
|
85
113
|
encoding="utf-8",
|
|
86
114
|
errors="replace",
|
|
@@ -92,7 +120,11 @@ def _run_external_linter(command, file_path):
|
|
|
92
120
|
|
|
93
121
|
|
|
94
122
|
def _parse_checkstyle_xml(xml_content):
|
|
95
|
-
"""Extracts errors from Checkstyle XML into a dictionary list.
|
|
123
|
+
"""Extracts errors from Checkstyle XML into a dictionary list.
|
|
124
|
+
|
|
125
|
+
Each entry carries the owning <file name=...> so callers can attribute a
|
|
126
|
+
violation to its source file instead of matching on line number alone.
|
|
127
|
+
"""
|
|
96
128
|
results = []
|
|
97
129
|
if not xml_content or not xml_content.strip():
|
|
98
130
|
return results
|
|
@@ -100,6 +132,7 @@ def _parse_checkstyle_xml(xml_content):
|
|
|
100
132
|
try:
|
|
101
133
|
root = ET.fromstring(xml_content)
|
|
102
134
|
for file_node in root.findall("file"):
|
|
135
|
+
source_file = file_node.get("name", "")
|
|
103
136
|
for error_node in file_node.findall("error"):
|
|
104
137
|
try:
|
|
105
138
|
line = int(error_node.get("line", 0))
|
|
@@ -107,6 +140,7 @@ def _parse_checkstyle_xml(xml_content):
|
|
|
107
140
|
continue
|
|
108
141
|
results.append(
|
|
109
142
|
{
|
|
143
|
+
"file": source_file,
|
|
110
144
|
"line": line,
|
|
111
145
|
"severity": error_node.get("severity", "error").lower(),
|
|
112
146
|
"message": error_node.get("message", ""),
|
|
@@ -117,6 +151,61 @@ def _parse_checkstyle_xml(xml_content):
|
|
|
117
151
|
return results
|
|
118
152
|
|
|
119
153
|
|
|
154
|
+
def _checkstyle_file_matches(reported_path, target_path):
|
|
155
|
+
"""True when a Checkstyle <file name=...> refers to *target_path*.
|
|
156
|
+
|
|
157
|
+
Checkstyle reports absolute paths while the diff yields repo-relative ones,
|
|
158
|
+
so the comparison is suffix-based over normalized separators. A report with
|
|
159
|
+
no name attribute cannot be discriminated and is accepted, so a linter that
|
|
160
|
+
omits it never silently loses every violation.
|
|
161
|
+
"""
|
|
162
|
+
if not reported_path:
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
reported = os.path.normpath(reported_path).replace("\\", "/").lower()
|
|
166
|
+
target = os.path.normpath(target_path).replace("\\", "/").lower()
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
reported == target
|
|
170
|
+
or reported.endswith("/" + target)
|
|
171
|
+
or target.endswith("/" + reported)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _collect_external_alerts(
|
|
176
|
+
external_linters, file_path, file_extension, alerts, allowed_lines=None
|
|
177
|
+
):
|
|
178
|
+
"""Runs every external linter matching *file_extension* against *file_path*.
|
|
179
|
+
|
|
180
|
+
Violations are attributed by file AND line: the file guard drops results a
|
|
181
|
+
linter reports for OTHER files (project-wide configs, followed imports),
|
|
182
|
+
which previously leaked in whenever their line number happened to collide
|
|
183
|
+
with an added line. When *allowed_lines* is None (full-file mode) every
|
|
184
|
+
line of the target file counts; otherwise only the diff's added lines do.
|
|
185
|
+
"""
|
|
186
|
+
for ext_linter in external_linters:
|
|
187
|
+
if file_extension not in ext_linter.get("extensions", []):
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
command = ext_linter.get("command")
|
|
191
|
+
if not command:
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
xml_output = _run_external_linter(command, file_path)
|
|
195
|
+
|
|
196
|
+
for err in _parse_checkstyle_xml(xml_output):
|
|
197
|
+
if not _checkstyle_file_matches(err["file"], file_path):
|
|
198
|
+
continue
|
|
199
|
+
if allowed_lines is not None and err["line"] not in allowed_lines:
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
msg = f"🚨 [{ext_linter.get('name', 'External linter')}] {err['message']} ({file_path}, Line {err['line']})"
|
|
203
|
+
if err["severity"] == "warning":
|
|
204
|
+
alerts["warnings"].append(msg)
|
|
205
|
+
else:
|
|
206
|
+
alerts["errors"].append(msg)
|
|
207
|
+
|
|
208
|
+
|
|
120
209
|
def parse_diff_and_lint(diff_text, is_full_file=False, file_path=None):
|
|
121
210
|
"""
|
|
122
211
|
Analyzes the git diff OR a full file and applies the rules defined in .gitpr.linter.yml.
|
|
@@ -153,6 +242,17 @@ def parse_diff_and_lint(diff_text, is_full_file=False, file_path=None):
|
|
|
153
242
|
continue
|
|
154
243
|
_apply_rule(rule, code_line, i, current_file, alerts)
|
|
155
244
|
|
|
245
|
+
# External linters audit the whole file here: with no diff to intersect,
|
|
246
|
+
# every violation the linter reports for this file is in scope.
|
|
247
|
+
if external_linters:
|
|
248
|
+
_collect_external_alerts(
|
|
249
|
+
external_linters,
|
|
250
|
+
current_file,
|
|
251
|
+
file_extension,
|
|
252
|
+
alerts,
|
|
253
|
+
allowed_lines=None,
|
|
254
|
+
)
|
|
255
|
+
|
|
156
256
|
log_local_metric(
|
|
157
257
|
command="linter",
|
|
158
258
|
status="success",
|
|
@@ -204,24 +304,13 @@ def parse_diff_and_lint(diff_text, is_full_file=False, file_path=None):
|
|
|
204
304
|
for f_path, modified_lines in modified_files.items():
|
|
205
305
|
f_ext = f_path.split(".")[-1] if "." in f_path else ""
|
|
206
306
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
xml_output = _run_external_linter(command, f_path)
|
|
216
|
-
ext_errors = _parse_checkstyle_xml(xml_output)
|
|
217
|
-
|
|
218
|
-
for err in ext_errors:
|
|
219
|
-
if err["line"] in modified_lines:
|
|
220
|
-
msg = f"🚨 [{ext_linter.get('name', 'External linter')}] {err['message']} ({f_path}, Line {err['line']})"
|
|
221
|
-
if err["severity"] == "warning":
|
|
222
|
-
alerts["warnings"].append(msg)
|
|
223
|
-
else:
|
|
224
|
-
alerts["errors"].append(msg)
|
|
307
|
+
_collect_external_alerts(
|
|
308
|
+
external_linters,
|
|
309
|
+
f_path,
|
|
310
|
+
f_ext,
|
|
311
|
+
alerts,
|
|
312
|
+
allowed_lines=set(modified_lines),
|
|
313
|
+
)
|
|
225
314
|
|
|
226
315
|
log_local_metric(
|
|
227
316
|
command="linter",
|
|
@@ -158,7 +158,7 @@ HELP_MAP: dict[str, dict[str, str]] = {
|
|
|
158
158
|
),
|
|
159
159
|
},
|
|
160
160
|
"chat": {
|
|
161
|
-
"url": get_doc_url("
|
|
161
|
+
"url": get_doc_url("understanding_chat_functionality.md"),
|
|
162
162
|
"title": __("Interactive Pair Programming Chat (--chat)"),
|
|
163
163
|
"description": __(
|
|
164
164
|
"Opens an interactive terminal (TUI) to chat with the AI about the current uncommitted changes. Features memory, auto-patching (F5), and live diff refresh (F2)."
|
|
@@ -172,7 +172,7 @@ HELP_MAP: dict[str, dict[str, str]] = {
|
|
|
172
172
|
),
|
|
173
173
|
},
|
|
174
174
|
"metrics": {
|
|
175
|
-
"url": get_doc_url("
|
|
175
|
+
"url": get_doc_url("metricas-telemetria.md"),
|
|
176
176
|
"title": __("Metrics & Analytics (--metrics)"),
|
|
177
177
|
"description": __("Export or purge local telemetry data for team analytics."),
|
|
178
178
|
},
|