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.
Files changed (61) hide show
  1. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/PKG-INFO +1 -1
  2. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/gitpr_cli.egg-info/PKG-INFO +1 -1
  3. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/gitpr_cli.egg-info/SOURCES.txt +5 -0
  4. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ai_providers.py +46 -21
  5. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/blame_engine.py +23 -9
  6. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/config.py +31 -0
  7. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/core.py +4 -2
  8. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/i18n.py +16 -12
  9. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/issue_engine.py +42 -10
  10. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/linter_engine.py +116 -27
  11. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/main.py +2 -2
  12. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/mcp_server.py +23 -6
  13. gitpr_cli-0.0.38/src/net.py +74 -0
  14. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/updater.py +2 -2
  15. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_blame_metrics.py +86 -0
  16. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_chat_backend.py +18 -7
  17. gitpr_cli-0.0.38/tests/test_external_linters.py +488 -0
  18. gitpr_cli-0.0.38/tests/test_github_api.py +384 -0
  19. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_i18n.py +154 -6
  20. gitpr_cli-0.0.38/tests/test_issue_engine.py +89 -0
  21. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_mcp_server.py +14 -0
  22. gitpr_cli-0.0.38/tests/test_net_timeouts.py +167 -0
  23. gitpr_cli-0.0.38/tests/test_pr_publish_app.py +528 -0
  24. gitpr_cli-0.0.37/tests/test_external_linters.py +0 -209
  25. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/LICENSE +0 -0
  26. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/README.md +0 -0
  27. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/gitpr_cli.egg-info/dependency_links.txt +0 -0
  28. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/gitpr_cli.egg-info/entry_points.txt +0 -0
  29. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/gitpr_cli.egg-info/requires.txt +0 -0
  30. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/gitpr_cli.egg-info/top_level.txt +0 -0
  31. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/pyproject.toml +0 -0
  32. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/setup.cfg +0 -0
  33. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/__init__.py +0 -0
  34. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/cache.py +0 -0
  35. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/chat_memory.py +0 -0
  36. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/github_api.py +0 -0
  37. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/linter_wizard.py +0 -0
  38. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/metrics.py +0 -0
  39. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/security.py +0 -0
  40. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/spinner.py +0 -0
  41. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/tui_issue.py +0 -0
  42. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ui/__init__.py +0 -0
  43. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ui/chat_app.py +0 -0
  44. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ui/help_screen.py +0 -0
  45. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ui/issue_app.py +0 -0
  46. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ui/linter_app.py +0 -0
  47. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ui/metrics_app.py +0 -0
  48. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ui/pr_publish_app.py +0 -0
  49. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/src/ui/pr_publish_help.py +0 -0
  50. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_core.py +0 -0
  51. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_install_wizard.py +0 -0
  52. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_linter_metrics.py +0 -0
  53. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_mcp_prompts.py +0 -0
  54. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_mcp_server_e2e.py +0 -0
  55. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_metrics.py +0 -0
  56. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_plugins.py +0 -0
  57. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_pr_publish_linter_modal.py +0 -0
  58. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_pre_save.py +0 -0
  59. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_skill_command.py +0 -0
  60. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_smart_excludes.py +0 -0
  61. {gitpr_cli-0.0.37 → gitpr_cli-0.0.38}/tests/test_thinking_words.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitpr-cli
3
- Version: 0.0.37
3
+ Version: 0.0.38
4
4
  Summary: AI-powered PR automation, commit messages, and code review (Gemini, DeepSeek, and Ollama)
5
5
  Author-email: Natan Fiuza <contato@natanfiuza.dev.br>
6
6
  Requires-Python: >=3.10
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitpr-cli
3
- Version: 0.0.37
3
+ Version: 0.0.38
4
4
  Summary: AI-powered PR automation, commit messages, and code review (Gemini, DeepSeek, and Ollama)
5
5
  Author-email: Natan Fiuza <contato@natanfiuza.dev.br>
6
6
  Requires-Python: >=3.10
@@ -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 = genai.Client(api_key=api_key)
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
- base_url = (
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
- req = urllib.request.Request(url, headers={"User-Agent": "GitPR-Chat"})
250
- with urllib.request.urlopen(req, timeout=5) as response:
251
- data = json.loads(response.read().decode())
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
- # Safety fallback if the user is offline
257
- return {
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 = genai.Client(api_key=api_key)
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
- base_url = (
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
- skill_path = resolve_skill_path(".gitpr.blame.md")
98
- if os.path.exists(skill_path):
99
- with open(skill_path, "r", encoding="utf-8") as f:
100
- sys_inst = f.read()
101
- else:
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 or Review)."""
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
- with urllib.request.urlopen(remote_url, timeout=3) as response:
59
- content = response.read().decode("utf-8")
60
-
61
- with open(local_file, "w", encoding="utf-8") as f:
62
- f.write(content)
63
-
64
- # Update .env with the new version after successful download
65
- set_key(env_path, "LANG_VERSION", __lang_version__)
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
- # In case of failure (e.g.: offline), if the old file exists, use it
68
- if not local_file.exists():
69
- return {}
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, resolve_skill_path
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 estimate_token_count, split_diff_into_chunks, get_doc_url
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
- skill_path = resolve_skill_path(".gitpr.issue.md")
60
- sys_inst = ""
61
-
62
- if os.path.exists(skill_path):
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 _run_external_linter(command, file_path):
73
- """Executes an external linter command and returns its stdout (Checkstyle XML)."""
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
- # Resolve the command by injecting the target file
76
- full_command = f'{command} "{file_path}"'
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
- full_command,
80
- shell=True,
108
+ argv,
81
109
  capture_output=True,
82
110
  stdin=subprocess.DEVNULL,
83
- timeout=120,
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
- for ext_linter in external_linters:
208
- if f_ext not in ext_linter.get("extensions", []):
209
- continue
210
-
211
- command = ext_linter.get("command")
212
- if not command:
213
- continue
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("chat-interativo.md"),
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("metricas_analytics_dashboard.md"),
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
  },