gitpr-cli 0.0.29__tar.gz → 0.0.30__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 (42) hide show
  1. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/PKG-INFO +3 -2
  2. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/README.md +1 -0
  3. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/gitpr_cli.egg-info/PKG-INFO +3 -2
  4. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/gitpr_cli.egg-info/SOURCES.txt +2 -0
  5. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/pyproject.toml +2 -2
  6. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/ai_providers.py +18 -0
  7. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/cache.py +17 -1
  8. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/core.py +38 -7
  9. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/main.py +70 -6
  10. gitpr_cli-0.0.30/src/metrics.py +266 -0
  11. gitpr_cli-0.0.30/src/ui/metrics_app.py +168 -0
  12. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/updater.py +2 -2
  13. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/LICENSE +0 -0
  14. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/gitpr_cli.egg-info/dependency_links.txt +0 -0
  15. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/gitpr_cli.egg-info/entry_points.txt +0 -0
  16. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/gitpr_cli.egg-info/requires.txt +0 -0
  17. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/gitpr_cli.egg-info/top_level.txt +0 -0
  18. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/setup.cfg +0 -0
  19. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/__init__.py +0 -0
  20. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/blame_engine.py +0 -0
  21. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/chat_memory.py +0 -0
  22. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/config.py +0 -0
  23. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/i18n.py +0 -0
  24. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/issue_engine.py +0 -0
  25. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/linter_engine.py +0 -0
  26. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/mcp_server.py +0 -0
  27. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/security.py +0 -0
  28. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/spinner.py +0 -0
  29. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/tui_issue.py +0 -0
  30. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/ui/__init__.py +0 -0
  31. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/ui/chat_app.py +0 -0
  32. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/ui/help_screen.py +0 -0
  33. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/ui/issue_app.py +0 -0
  34. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/tests/test_chat_backend.py +0 -0
  35. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/tests/test_core.py +0 -0
  36. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/tests/test_install_wizard.py +0 -0
  37. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/tests/test_mcp_prompts.py +0 -0
  38. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/tests/test_mcp_server.py +0 -0
  39. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/tests/test_pre_save.py +0 -0
  40. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/tests/test_skill_command.py +0 -0
  41. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/tests/test_smart_excludes.py +0 -0
  42. {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/tests/test_thinking_words.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitpr-cli
3
- Version: 0.0.29
4
- Summary: Automação de PRs, Commits e Code Review com IA (Gemini e DeepSeek)
3
+ Version: 0.0.30
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
7
7
  Description-Content-Type: text/markdown
@@ -311,6 +311,7 @@ If you want to implement GitPR as an automated quality barrier in your team, che
311
311
  * [**MCP Integration**](https://github.com/natanfiuza/gitpr/blob/main/docs/mcp-integration.md) — Connect GitPR to VS Code, Cursor, and Claude Desktop via Model Context Protocol.
312
312
  * [**MCP Prompts**](https://github.com/natanfiuza/gitpr/blob/main/docs/mcp-prompts.md) — Pre-built message templates (7 prompts, 35 language variants) for common GitPR workflows in your editor's AI chat.
313
313
  * [**MCP Tool Annotations**](https://github.com/natanfiuza/gitpr/blob/main/docs/mcp-annotations.md) — IDE integration hints (`readOnlyHint`, `destructiveHint`) for smarter UI behavior and safer tool execution.
314
+ * [**Metrics & Telemetry**](https://github.com/natanfiuza/gitpr/blob/main/docs/metricas-telemetria.md) — Local offline analytics for team usage metrics, exportable CSV reports, and interactive TUI dashboard.
314
315
 
315
316
  ## ⚡ Local Cache System (Quota Savings)
316
317
 
@@ -292,6 +292,7 @@ If you want to implement GitPR as an automated quality barrier in your team, che
292
292
  * [**MCP Integration**](https://github.com/natanfiuza/gitpr/blob/main/docs/mcp-integration.md) — Connect GitPR to VS Code, Cursor, and Claude Desktop via Model Context Protocol.
293
293
  * [**MCP Prompts**](https://github.com/natanfiuza/gitpr/blob/main/docs/mcp-prompts.md) — Pre-built message templates (7 prompts, 35 language variants) for common GitPR workflows in your editor's AI chat.
294
294
  * [**MCP Tool Annotations**](https://github.com/natanfiuza/gitpr/blob/main/docs/mcp-annotations.md) — IDE integration hints (`readOnlyHint`, `destructiveHint`) for smarter UI behavior and safer tool execution.
295
+ * [**Metrics & Telemetry**](https://github.com/natanfiuza/gitpr/blob/main/docs/metricas-telemetria.md) — Local offline analytics for team usage metrics, exportable CSV reports, and interactive TUI dashboard.
295
296
 
296
297
  ## ⚡ Local Cache System (Quota Savings)
297
298
 
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitpr-cli
3
- Version: 0.0.29
4
- Summary: Automação de PRs, Commits e Code Review com IA (Gemini e DeepSeek)
3
+ Version: 0.0.30
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
7
7
  Description-Content-Type: text/markdown
@@ -311,6 +311,7 @@ If you want to implement GitPR as an automated quality barrier in your team, che
311
311
  * [**MCP Integration**](https://github.com/natanfiuza/gitpr/blob/main/docs/mcp-integration.md) — Connect GitPR to VS Code, Cursor, and Claude Desktop via Model Context Protocol.
312
312
  * [**MCP Prompts**](https://github.com/natanfiuza/gitpr/blob/main/docs/mcp-prompts.md) — Pre-built message templates (7 prompts, 35 language variants) for common GitPR workflows in your editor's AI chat.
313
313
  * [**MCP Tool Annotations**](https://github.com/natanfiuza/gitpr/blob/main/docs/mcp-annotations.md) — IDE integration hints (`readOnlyHint`, `destructiveHint`) for smarter UI behavior and safer tool execution.
314
+ * [**Metrics & Telemetry**](https://github.com/natanfiuza/gitpr/blob/main/docs/metricas-telemetria.md) — Local offline analytics for team usage metrics, exportable CSV reports, and interactive TUI dashboard.
314
315
 
315
316
  ## ⚡ Local Cache System (Quota Savings)
316
317
 
@@ -19,6 +19,7 @@ src/issue_engine.py
19
19
  src/linter_engine.py
20
20
  src/main.py
21
21
  src/mcp_server.py
22
+ src/metrics.py
22
23
  src/security.py
23
24
  src/spinner.py
24
25
  src/tui_issue.py
@@ -27,6 +28,7 @@ src/ui/__init__.py
27
28
  src/ui/chat_app.py
28
29
  src/ui/help_screen.py
29
30
  src/ui/issue_app.py
31
+ src/ui/metrics_app.py
30
32
  tests/test_chat_backend.py
31
33
  tests/test_core.py
32
34
  tests/test_install_wizard.py
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitpr-cli"
7
- version = "0.0.29"
8
- description = "Automação de PRs, Commits e Code Review com IA (Gemini e DeepSeek)"
7
+ version = "0.0.30"
8
+ description = "AI-powered PR automation, commit messages, and code review (Gemini, DeepSeek, and Ollama)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  authors = [
@@ -76,6 +76,7 @@ def call_ai_model(provider, api_key, api_model, prompt, system_instruction, quie
76
76
  try:
77
77
  for attempt in range(1, max_retries + 1):
78
78
  try:
79
+ meta_raw = {}
79
80
  if provider == "gemini":
80
81
  client = genai.Client(api_key=api_key)
81
82
  response = client.models.generate_content(
@@ -90,6 +91,13 @@ def call_ai_model(provider, api_key, api_model, prompt, system_instruction, quie
90
91
  }
91
92
  )
92
93
  result_text = response.text
94
+
95
+ if hasattr(response, 'usage_metadata') and response.usage_metadata:
96
+ meta_raw = {
97
+ "prompt_tokens": getattr(response.usage_metadata, 'prompt_token_count', 0),
98
+ "completion_tokens": getattr(response.usage_metadata, 'candidates_token_count', 0),
99
+ "total_tokens": getattr(response.usage_metadata, 'total_token_count', 0)
100
+ }
93
101
 
94
102
  elif provider in ["deepseek", "ollama"]:
95
103
  # DeepSeek and Ollama are 100% compatible with the OpenAI library.
@@ -106,6 +114,13 @@ def call_ai_model(provider, api_key, api_model, prompt, system_instruction, quie
106
114
  temperature=0.0
107
115
  )
108
116
  result_text = response.choices[0].message.content
117
+
118
+ if hasattr(response, 'usage') and response.usage:
119
+ meta_raw = {
120
+ "prompt_tokens": getattr(response.usage, 'prompt_tokens', 0),
121
+ "completion_tokens": getattr(response.usage, 'completion_tokens', 0),
122
+ "total_tokens": getattr(response.usage, 'total_tokens', 0)
123
+ }
109
124
 
110
125
  else:
111
126
  spinner.stop()
@@ -119,6 +134,9 @@ def call_ai_model(provider, api_key, api_model, prompt, system_instruction, quie
119
134
  if isinstance(result_json, list):
120
135
  result_json = result_json[0] if result_json else {}
121
136
 
137
+ # Inject telemetry metadata silently into the response dictionary
138
+ result_json["_telemetry_meta"] = meta_raw
139
+
122
140
  spinner.stop()
123
141
  return result_json
124
142
 
@@ -1,6 +1,7 @@
1
1
  import os
2
2
  import json
3
3
  import hashlib
4
+ import subprocess
4
5
  from datetime import datetime
5
6
  from pathlib import Path
6
7
 
@@ -13,6 +14,15 @@ def generate_md5(text):
13
14
  """Generates the MD5 hash of a string."""
14
15
  return hashlib.md5(text.encode('utf-8')).hexdigest()
15
16
 
17
+ def get_git_user_info():
18
+ """Recupera o nome e email configurados no git local."""
19
+ try:
20
+ name = subprocess.run(["git", "config", "user.name"], capture_output=True, text=True, check=True).stdout.strip()
21
+ email = subprocess.run(["git", "config", "user.email"], capture_output=True, text=True, check=True).stdout.strip()
22
+ return name, email
23
+ except subprocess.CalledProcessError:
24
+ return "unknown", "unknown"
25
+
16
26
  def get_cached_response(action_folder, prompt_text):
17
27
  """Checks if a valid cache exists for the prompt and returns the content."""
18
28
  md5_hash = generate_md5(prompt_text)
@@ -27,7 +37,7 @@ def get_cached_response(action_folder, prompt_text):
27
37
  return None
28
38
  return None
29
39
 
30
- def save_cached_response(action_folder, action_type, prompt_text, response_dict):
40
+ def save_cached_response(action_folder, action_type, prompt_text, response_dict, meta_raw=None):
31
41
  """Saves the AI response to the local cache."""
32
42
  md5_hash = generate_md5(prompt_text)
33
43
  folder_path = get_cache_base_dir() / action_folder
@@ -37,11 +47,17 @@ def save_cached_response(action_folder, action_type, prompt_text, response_dict)
37
47
  from src.core import get_current_branch, get_repo_name
38
48
  current_branch = get_current_branch()
39
49
  repo_name = get_repo_name()
50
+ user_name, user_email = get_git_user_info()
51
+
52
+ if meta_raw is not None:
53
+ response_dict["meta_raw"] = meta_raw
40
54
 
41
55
  cache_data = {
42
56
  "md5": md5_hash,
43
57
  "repo": repo_name,
44
58
  "branch": current_branch,
59
+ "author_name": user_name,
60
+ "author_email": user_email,
45
61
  "datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
46
62
  "action_type": action_type,
47
63
  "prompt": prompt_text,
@@ -16,6 +16,7 @@ from src.config import get_api_key, get_api_model, get_skill_dir, resolve_skill_
16
16
  from src.ai_providers import call_ai_model
17
17
  from src.i18n import __, CURRENT_LANG
18
18
  from src.updater import __lang_version__
19
+ # Metrics are imported lazily inside generate_pr_content() to avoid circular imports
19
20
 
20
21
  # Smart Diff filter: files that consume AI tokens without adding semantic value.
21
22
  # The pattern list is managed remotely (templates/gitpr.smart-excludes.json) and
@@ -313,15 +314,26 @@ def generate_pr_content(action_folder, action_type, diff_text, provider="gemini"
313
314
  click.secho(__("❌ Error: Could not determine model for provider '{provider}'.", provider=provider), fg="red")
314
315
  return None
315
316
 
316
- # API CALL
317
317
  # API CALL
318
318
  click.secho(__("🤖 GitPR is analyzing your code using {provider} ({model})...\n", provider=provider.capitalize(), model=api_model), fg="cyan")
319
319
 
320
320
  chunks = split_diff_into_chunks(diff_text, max_tokens=90000)
321
+ total_meta = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
321
322
 
323
+ def _aggregate_meta(new_meta):
324
+ if new_meta:
325
+ total_meta["prompt_tokens"] += new_meta.get("prompt_tokens", 0)
326
+ total_meta["completion_tokens"] += new_meta.get("completion_tokens", 0)
327
+ total_meta["total_tokens"] += new_meta.get("total_tokens", 0)
328
+
322
329
  if len(chunks) == 1:
323
330
  result_json = call_ai_model(provider, api_key, api_model, prompt, instrucao_sistema, action=action_folder)
331
+ if result_json:
332
+ _aggregate_meta(result_json.pop("_telemetry_meta", None))
324
333
  else:
334
+ from src.metrics import log_local_metric
335
+ log_local_metric(command="map_reduce", status="triggered", map_reduce_triggered=True, chunks_count=len(chunks))
336
+
325
337
  click.secho(__("📦 Huge diff detected! Processing in {count} batches (Map-Reduce)...", count=len(chunks)), fg="yellow", bold=True)
326
338
  click.secho(f"📚 {__('Understand why:')} {get_doc_url('map-reduce-diff.md')}\n", fg="blue", underline=True)
327
339
  resumos_parciais = []
@@ -332,9 +344,11 @@ def generate_pr_content(action_folder, action_type, diff_text, provider="gemini"
332
344
  prompt_parcial = __("Generate ONLY a JSON object in the format {json_format} containing a technical summary of what was changed in this part ({idx}) of the diff:\n", json_format='{"resumo": "..."}', idx=i) + chunk
333
345
 
334
346
  resposta_parcial = call_ai_model(provider, api_key, api_model, prompt_parcial, instrucao_sistema, quiet=True, action=f"{action_folder}_chunk_{i}")
335
-
336
- if resposta_parcial and "resumo" in resposta_parcial:
337
- resumos_parciais.append(f"### Batch {i}\n{resposta_parcial['resumo']}")
347
+
348
+ if resposta_parcial:
349
+ _aggregate_meta(resposta_parcial.pop("_telemetry_meta", None))
350
+ if "resumo" in resposta_parcial:
351
+ resumos_parciais.append(f"### Batch {i}\n{resposta_parcial['resumo']}")
338
352
 
339
353
  time.sleep(1)
340
354
 
@@ -353,12 +367,26 @@ def generate_pr_content(action_folder, action_type, diff_text, provider="gemini"
353
367
  prompt = __("Unify these technical summaries and generate ONLY a JSON object in the format {json_format} describing the Pull Request:\n", json_format='{"commit_message": "...", "pr_description": "..."}') + diff_unificado
354
368
 
355
369
  result_json = call_ai_model(provider, api_key, api_model, prompt, instrucao_sistema, action=action_folder)
370
+ if result_json:
371
+ _aggregate_meta(result_json.pop("_telemetry_meta", None))
356
372
 
357
373
  # SAVE TO CACHE AND RETURN
358
374
  if result_json:
359
- save_cached_response(action_folder, action_type, prompt, result_json)
375
+ save_cached_response(action_folder, action_type, prompt, result_json, meta_raw=total_meta)
376
+ # Fire-and-forget metric for successful AI-powered command
377
+ from src.metrics import log_command_metric
378
+ log_command_metric(
379
+ command=action_type,
380
+ status="success",
381
+ provider=provider,
382
+ tokens_estimated=total_meta.get("total_tokens", 0),
383
+ map_reduce_triggered=(len(chunks) > 1),
384
+ )
360
385
  return result_json
361
-
386
+
387
+ # Command failed (AI returned None)
388
+ from src.metrics import log_command_metric
389
+ log_command_metric(command=action_type, status="error", provider=provider)
362
390
  return None
363
391
 
364
392
 
@@ -487,7 +515,10 @@ def install_git_hooks():
487
515
  # Mapping: Hook Name in Git -> Template Name on your GitHub
488
516
  hooks_to_install = {
489
517
  "pre-commit": "pre-commit-template.sh",
490
- "prepare-commit-msg": "prepare-commit-msg-template.sh"
518
+ "prepare-commit-msg": "prepare-commit-msg-template.sh",
519
+ "post-checkout": "post-checkout-template.sh",
520
+ "pre-push": "pre-push-template.sh",
521
+ "post-merge": "post-merge-template.sh",
491
522
  }
492
523
 
493
524
  base_url = "https://raw.githubusercontent.com/natanfiuza/gitpr/main/scripts/"
@@ -117,6 +117,11 @@ HELP_MAP: dict[str, dict[str, str]] = {
117
117
  'title': __('Language Override (--lang)'),
118
118
  'description': __('Forces the interface language for this execution (e.g.: en_us, pt_br). Overrides the GITPR_LANG environment variable and OS locale detection.'),
119
119
  },
120
+ 'metrics': {
121
+ 'url': get_doc_url('metricas_analytics_dashboard.md'),
122
+ 'title': __('Metrics & Analytics (--metrics)'),
123
+ 'description': __('Export or purge local telemetry data for team analytics.'),
124
+ },
120
125
  }
121
126
 
122
127
  # Priority for contextual help when multiple flags are used with -h
@@ -136,6 +141,7 @@ HELP_PRIORITY: dict[str, int] = {
136
141
  'history': 11,
137
142
  'provider': 12,
138
143
  'lang': 13,
144
+ 'metrics': 15,
139
145
  }
140
146
 
141
147
 
@@ -160,8 +166,13 @@ HELP_PRIORITY: dict[str, int] = {
160
166
  @click.option('-p', '--provider', type=click.Choice(['gemini', 'deepseek', 'ollama']), help=__("Forces the use of a specific AI provider for this execution."))
161
167
  @click.option('--lang', type=str, help=__("Forces the interface language for this execution (e.g.: en_us, pt_br)."))
162
168
  @click.option('--mcp', is_flag=True, hidden=True, help=__("Start the MCP server for integration with VS Code, Cursor, Claude Desktop, etc."))
169
+ @click.option('--metrics', is_flag=True, help=__("Shows local telemetry summary. Use --export to consolidate, --purge to clean."))
170
+ @click.option('--export', is_flag=True, help=__("Exports consolidated metrics to CSV and JSON in the current folder."))
171
+ @click.option('--purge', is_flag=True, help=__("Deletes all local metric files (~/.gitpr/metrics/). Requires confirmation."))
172
+ @click.option('--hook-event', type=str, hidden=True, help=__("Internal: logs a git hook event name."))
173
+ @click.option('--dashboard', 'show_dashboard', is_flag=True, help=__("Opens the interactive metrics dashboard (TUI)."))
163
174
  @click.option('-h', '--help', 'help_flag', is_flag=True, help=__("Shows this message and exits. Use with another flag for contextual help (e.g., -h --issue)."))
164
- def cli(commit, review, fullreview, linter, skill, update, installhooks, install, hook, quiet, pre_save, provider, input, blame, history, issue, chat, help_flag, lang, mcp):
175
+ def cli(commit, review, fullreview, linter, skill, update, installhooks, install, hook, quiet, pre_save, provider, input, blame, history, issue, chat, help_flag, lang, mcp, metrics, export, purge, hook_event, show_dashboard):
165
176
  """
166
177
  GitPR CLI - Intelligent PR Automation and AI Code Review.
167
178
 
@@ -208,7 +219,7 @@ def cli(commit, review, fullreview, linter, skill, update, installhooks, install
208
219
  fg="yellow", dim=True,
209
220
  )
210
221
 
211
- # Exibe a URL da documentacao principal do GitPR como rodape
222
+ # Display the main GitPR documentation URL as a footer
212
223
  click.secho(
213
224
  __(">> Repository: https://github.com/natanfiuza/gitpr"),
214
225
  fg="bright_black",
@@ -231,6 +242,50 @@ def cli(commit, review, fullreview, linter, skill, update, installhooks, install
231
242
  reload_thinking_words(lang)
232
243
 
233
244
  # MCP Server Mode — start stdio MCP server (handled before any interactive setup)
245
+ if hook_event:
246
+ # Hidden: fire-and-forget git hook event logging
247
+ from src.metrics import log_command_metric
248
+ log_command_metric(command=f"hook:{hook_event}", status="fired", provider="git")
249
+ return
250
+
251
+ if metrics and export:
252
+ from src.metrics import export_metrics
253
+ csv_path, json_path, count = export_metrics()
254
+ if count > 0:
255
+ click.secho(__("✅ Metrics exported: {count} events.", count=count), fg="green", bold=True)
256
+ if csv_path:
257
+ click.echo(f" CSV: {csv_path}")
258
+ if json_path:
259
+ click.echo(f" JSON: {json_path}")
260
+ else:
261
+ click.secho(__("No new metrics to export."), fg="yellow")
262
+ return
263
+
264
+ if metrics and purge:
265
+ from src.metrics import purge_metrics
266
+ if click.confirm(__("⚠ This will permanently delete all local metric files. Continue?")):
267
+ removed = purge_metrics()
268
+ click.secho(__("✅ Metrics purged ({count} files removed).", count=removed), fg="green")
269
+ else:
270
+ click.secho(__("Purge cancelled."), fg="yellow")
271
+ return
272
+
273
+ if metrics and show_dashboard:
274
+ from src.ui.metrics_app import launch_metrics_dashboard
275
+ launch_metrics_dashboard()
276
+ return
277
+
278
+ if metrics:
279
+ from src.metrics import show_metrics_summary
280
+ summary = show_metrics_summary()
281
+ click.secho(__("\n📊 Local Telemetry Summary"), fg="cyan", bold=True)
282
+ click.echo(f" {__('Path')}: {summary['path']}")
283
+ click.echo(f" {__('Files')}: {summary['total_files']}")
284
+ click.echo(f" {__('Disk usage')}: {summary['disk_usage']}")
285
+ click.echo()
286
+ click.echo(__("Use --metrics --export to consolidate, --metrics --purge to clean."))
287
+ return
288
+
234
289
  if mcp:
235
290
  from src.mcp_server import main as mcp_main
236
291
  mcp_main()
@@ -269,9 +324,9 @@ def cli(commit, review, fullreview, linter, skill, update, installhooks, install
269
324
  has_warnings = len(linter_results["warnings"]) > 0
270
325
  has_errors = len(linter_results["errors"]) > 0
271
326
 
272
- # Processamento de Warnings (Apenas Avisos)
327
+ # Warning processing (best-practice advisories only)
273
328
  if has_warnings:
274
- # Os avisos DEVEM aparecer sempre, mesmo no modo quiet
329
+ # Warnings MUST always appear, even in quiet mode
275
330
  click.secho(__("\n⚠️ The Linter generated {count} best practice warning(s):", count=len(linter_results['warnings'])), fg="yellow", bold=True)
276
331
  for alert in linter_results["warnings"]:
277
332
  click.echo(f" - {alert}")
@@ -291,6 +346,15 @@ def cli(commit, review, fullreview, linter, skill, update, installhooks, install
291
346
  click.secho(__("\n✅ Code approved with warnings. The commit will proceed."), fg="green")
292
347
  else:
293
348
  click.secho(__("\n✅ Clean code! No violations found by the local Linter."), fg="green", bold=True)
349
+
350
+ # Fire-and-forget linter metric
351
+ from src.metrics import log_command_metric
352
+ log_command_metric(
353
+ command="linter",
354
+ status="error" if has_errors else "success",
355
+ linter_errors=len(linter_results.get("errors", [])),
356
+ linter_warnings=len(linter_results.get("warnings", [])),
357
+ )
294
358
  return
295
359
 
296
360
  # Connection Guardian (Failing Fast)
@@ -511,7 +575,7 @@ def cli(commit, review, fullreview, linter, skill, update, installhooks, install
511
575
 
512
576
  system_instruction = __("You are a Senior Software Engineer acting as a Pair Programmer. Analyze the code provided and answer the user's questions clearly, objectively, and technically. Use Markdown to format code blocks. The current git diff is:\n\n{diff}", diff=diff_text)
513
577
 
514
- # Pega o modelo primário configurado no .env
578
+ # Use the primary model configured in .env
515
579
  env_model_key = f"{active_provider.upper()}_API_MODEL_PRIMARY"
516
580
  api_model = os.getenv(env_model_key)
517
581
  if not api_model:
@@ -632,7 +696,7 @@ def cli(commit, review, fullreview, linter, skill, update, installhooks, install
632
696
  )
633
697
  content = data.get('review', __('No analysis generated.'))
634
698
 
635
- # Chama o Linter. Se for "filereview", ativa o modo de arquivo completo.
699
+ # Run the Linter. If "filereview", enable full-file mode.
636
700
  if action_type == "filereview":
637
701
  linter_results = parse_diff_and_lint(diff_text, is_full_file=True, file_path=input)
638
702
  else:
@@ -0,0 +1,266 @@
1
+ import os
2
+ import json
3
+ import threading
4
+ import subprocess
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from src.chat_memory import gerar_uuid_base_15
8
+ from src.core import get_current_branch, get_repo_name
9
+
10
+ def _get_owner_name():
11
+ """Returns the repository owner or the local username as fallback."""
12
+ repo = get_repo_name()
13
+ if repo and repo != "unknown/repo":
14
+ return repo.split('/')[0]
15
+
16
+ try:
17
+ name = subprocess.run(["git", "config", "user.name"], capture_output=True, text=True, check=True).stdout.strip()
18
+ return name.replace(" ", "_") if name else "local_user"
19
+ except Exception:
20
+ return "local_user"
21
+
22
+ def _save_metric_async(payload):
23
+ """Saves the JSON payload to the correct directory silently and asynchronously."""
24
+ try:
25
+ owner = _get_owner_name()
26
+ branch = get_current_branch().replace("/", "-")
27
+
28
+ metrics_dir = Path.home() / ".gitpr" / "metrics" / owner / branch
29
+ metrics_dir.mkdir(parents=True, exist_ok=True)
30
+
31
+ uuid_str = gerar_uuid_base_15()
32
+ date_str = datetime.now().strftime("%Y%m%d")
33
+ file_name = f"{uuid_str}_{date_str}.json"
34
+
35
+ file_path = metrics_dir / file_name
36
+
37
+ with open(file_path, "w", encoding="utf-8") as f:
38
+ json.dump(payload, f, ensure_ascii=False, indent=2)
39
+ except Exception:
40
+ pass # Fire-and-forget: failures must never break the CLI
41
+
42
+ def log_local_metric(command, status, provider="local", tokens_estimated=0, duration_ms=0, **kwargs):
43
+ """Fires a local metric log in a separate daemon thread."""
44
+ payload = {
45
+ "timestamp": datetime.now().isoformat(),
46
+ "command": command,
47
+ "status": status,
48
+ "provider": provider,
49
+ "tokens_estimated": tokens_estimated,
50
+ "duration_ms": duration_ms,
51
+ "repo": get_repo_name(),
52
+ "branch": get_current_branch(),
53
+ **kwargs
54
+ }
55
+
56
+ thread = threading.Thread(target=_save_metric_async, args=(payload,))
57
+ thread.daemon = True
58
+ thread.start()
59
+
60
+
61
+ def log_command_metric(command, status="success", provider=None, tokens_estimated=0,
62
+ duration_ms=0, cache_hit=False, map_reduce_triggered=False,
63
+ **kwargs):
64
+ """High-level metric logger for CLI commands.
65
+
66
+ Args:
67
+ command: Command name ('commit', 'review', 'fullreview', 'linter', etc.)
68
+ status: 'success', 'error', 'triggered', 'no_changes'
69
+ provider: AI provider used (None = local/no AI)
70
+ tokens_estimated: Estimated token count from AI usage metadata
71
+ duration_ms: Command duration in milliseconds
72
+ cache_hit: True if result was served from cache
73
+ map_reduce_triggered: True if map-reduce chunking was activated
74
+ **kwargs: Extra fields (linter_errors, linter_warnings, chunks_count, etc.)
75
+ """
76
+ if provider is None:
77
+ # Auto-detect: try config, fallback to 'local'
78
+ try:
79
+ from src.config import get_ai_provider
80
+ provider = get_ai_provider()
81
+ except Exception:
82
+ provider = "local"
83
+
84
+ log_local_metric(
85
+ command=command,
86
+ status=status,
87
+ provider=provider,
88
+ tokens_estimated=tokens_estimated,
89
+ duration_ms=duration_ms,
90
+ cache_hit=cache_hit,
91
+ map_reduce_triggered=map_reduce_triggered,
92
+ **kwargs
93
+ )
94
+
95
+
96
+ def get_metrics_dir():
97
+ """Returns the path to the local metrics directory (~/.gitpr/metrics/)."""
98
+ return os.path.join(Path.home(), ".gitpr", "metrics")
99
+
100
+
101
+ def get_metrics_state_file():
102
+ """Returns the path to the metrics state file (~/.gitpr/metrics/config.json)."""
103
+ return os.path.join(get_metrics_dir(), "config.json")
104
+
105
+
106
+ def export_metrics(output_dir=None):
107
+ """Scans ~/.gitpr/metrics/, consolidates all JSON files into a CSV + JSON report.
108
+
109
+ Skips files already processed (tracked via config.json UUID list).
110
+ Returns (csv_path, json_path, event_count).
111
+ """
112
+ import click
113
+ from datetime import date
114
+
115
+ metrics_dir = get_metrics_dir()
116
+ state_file = get_metrics_state_file()
117
+
118
+ if not os.path.isdir(metrics_dir):
119
+ return None, None, 0
120
+
121
+ # Load already-exported UUIDs
122
+ exported_uuids = set()
123
+ if os.path.exists(state_file):
124
+ try:
125
+ with open(state_file, "r", encoding="utf-8") as f:
126
+ exported_uuids = set(json.load(f).get("exported", []))
127
+ except Exception:
128
+ pass
129
+
130
+ # Collect all unexported metric files
131
+ all_files = []
132
+ for root, dirs, files in os.walk(metrics_dir):
133
+ # Skip the export subdirectory
134
+ if "export" in root.replace(metrics_dir, "").split(os.sep):
135
+ continue
136
+ for fname in files:
137
+ if fname.endswith(".json") and not fname.startswith("config"):
138
+ fpath = os.path.join(root, fname)
139
+ # Check UUID (first part of filename before _YYYYMMDD)
140
+ uuid_part = fname.split("_")[0]
141
+ if uuid_part not in exported_uuids:
142
+ all_files.append(fpath)
143
+
144
+ if not all_files:
145
+ return None, None, 0
146
+
147
+ # Consolidate all payloads
148
+ events = []
149
+ with click.progressbar(all_files, label="Exporting metrics") as bar:
150
+ for fpath in bar:
151
+ try:
152
+ with open(fpath, "r", encoding="utf-8") as f:
153
+ events.append(json.load(f))
154
+ except Exception:
155
+ pass
156
+
157
+ if not events:
158
+ return None, None, 0
159
+
160
+ # Determine output directory
161
+ if output_dir is None:
162
+ output_dir = os.path.join(metrics_dir, "export")
163
+ os.makedirs(output_dir, exist_ok=True)
164
+
165
+ today_str = date.today().strftime("%Y-%m-%d")
166
+ csv_path = os.path.join(output_dir, f"gitpr_metrics_{today_str}.csv")
167
+ json_path = os.path.join(output_dir, f"gitpr_metrics_{today_str}.json")
168
+
169
+ # Write CSV
170
+ csv_columns = ["timestamp", "command", "status", "provider",
171
+ "tokens_estimated", "duration_ms", "repo", "branch"]
172
+ with open(csv_path, "w", encoding="utf-8", newline="") as f:
173
+ f.write(",".join(csv_columns) + "\n")
174
+ for evt in events:
175
+ row = [str(evt.get(col, "")) for col in csv_columns]
176
+ f.write(",".join(f'"{v}"' for v in row) + "\n")
177
+
178
+ # Write JSON
179
+ with open(json_path, "w", encoding="utf-8") as f:
180
+ json.dump(events, f, ensure_ascii=False, indent=2)
181
+
182
+ # Update state file with newly exported UUIDs
183
+ new_uuids = set()
184
+ for fpath in all_files:
185
+ uuid_part = os.path.basename(fpath).split("_")[0]
186
+ new_uuids.add(uuid_part)
187
+ exported_uuids.update(new_uuids)
188
+
189
+ with open(state_file, "w", encoding="utf-8") as f:
190
+ json.dump({
191
+ "exported": sorted(exported_uuids),
192
+ "last_export": datetime.now().isoformat(),
193
+ "total_events": len(events) + len(exported_uuids)
194
+ }, f, ensure_ascii=False, indent=2)
195
+
196
+ return csv_path, json_path, len(events)
197
+
198
+
199
+ def purge_metrics():
200
+ """Deletes all metric JSON files in ~/.gitpr/metrics/ (preserves config.json).
201
+
202
+ Returns the number of files removed.
203
+ """
204
+ import click
205
+
206
+ metrics_dir = get_metrics_dir()
207
+ if not os.path.isdir(metrics_dir):
208
+ return 0
209
+
210
+ removed = 0
211
+ for root, dirs, files in os.walk(metrics_dir):
212
+ for fname in files:
213
+ if fname.endswith(".json") and fname != "config.json":
214
+ fpath = os.path.join(root, fname)
215
+ try:
216
+ os.remove(fpath)
217
+ removed += 1
218
+ except Exception:
219
+ pass
220
+
221
+ return removed
222
+
223
+
224
+ def show_metrics_summary():
225
+ """Prints a summary of the local metrics directory."""
226
+ metrics_dir = get_metrics_dir()
227
+ state_file = get_metrics_state_file()
228
+
229
+ if not os.path.isdir(metrics_dir):
230
+ return {"total_files": 0, "total_events": 0, "disk_usage": "0 KB"}
231
+
232
+ total_files = 0
233
+ exported_count = 0
234
+ disk_bytes = 0
235
+
236
+ if os.path.exists(state_file):
237
+ try:
238
+ with open(state_file, "r", encoding="utf-8") as f:
239
+ state = json.load(f)
240
+ exported_count = len(state.get("exported", []))
241
+ except Exception:
242
+ pass
243
+
244
+ for root, dirs, files in os.walk(metrics_dir):
245
+ for fname in files:
246
+ if fname.endswith(".json"):
247
+ total_files += 1
248
+ fpath = os.path.join(root, fname)
249
+ try:
250
+ disk_bytes += os.path.getsize(fpath)
251
+ except Exception:
252
+ pass
253
+
254
+ if disk_bytes < 1024:
255
+ disk_usage = f"{disk_bytes} B"
256
+ elif disk_bytes < 1024 * 1024:
257
+ disk_usage = f"{disk_bytes / 1024:.1f} KB"
258
+ else:
259
+ disk_usage = f"{disk_bytes / (1024 * 1024):.1f} MB"
260
+
261
+ return {
262
+ "total_files": total_files,
263
+ "total_events": exported_count,
264
+ "disk_usage": disk_usage,
265
+ "path": metrics_dir
266
+ }
@@ -0,0 +1,168 @@
1
+ """
2
+ Metrics Dashboard TUI - displays telemetry data in an interactive terminal UI.
3
+
4
+ Usage: gitpr --metrics --dashboard
5
+ """
6
+ from textual.app import App, ComposeResult
7
+ from textual.widgets import Header, Footer, Static, DataTable
8
+ from textual.containers import Vertical, Horizontal
9
+ from textual.binding import Binding
10
+ import json
11
+ import os
12
+ from datetime import datetime
13
+ from collections import Counter
14
+
15
+ from src.i18n import __
16
+
17
+
18
+ class MetricsApp(App):
19
+ """Interactive terminal dashboard for local telemetry data."""
20
+
21
+ TITLE = __("GitPR - Metrics Dashboard")
22
+ ENABLE_COMMAND_PALETTE = False
23
+
24
+ CSS = """
25
+ #summary {
26
+ padding: 1 2;
27
+ background: $surface;
28
+ border: solid $primary;
29
+ margin: 1 2;
30
+ }
31
+ #table_container {
32
+ height: 1fr;
33
+ margin: 0 2;
34
+ }
35
+ #status_bar {
36
+ padding: 0 2;
37
+ color: $text-muted;
38
+ }
39
+ """
40
+
41
+ BINDINGS = [
42
+ Binding("f5", "refresh", __("Refresh")),
43
+ Binding("escape", "quit", __("Exit")),
44
+ ]
45
+
46
+ def __init__(self, metrics_dir=None, **kwargs):
47
+ super().__init__(**kwargs)
48
+ self.metrics_dir = metrics_dir or os.path.join(
49
+ os.path.expanduser("~"), ".gitpr", "metrics"
50
+ )
51
+ self.events = []
52
+
53
+ def compose(self) -> ComposeResult:
54
+ yield Header(show_clock=True)
55
+
56
+ yield Static("", id="summary")
57
+
58
+ with Vertical(id="table_container"):
59
+ yield DataTable(id="events_table")
60
+
61
+ yield Static("", id="status_bar")
62
+ yield Footer()
63
+
64
+ def on_mount(self) -> None:
65
+ """Load data and populate the table."""
66
+ self._load_metrics()
67
+ self._populate_table()
68
+ self._update_summary()
69
+
70
+ def _load_metrics(self) -> None:
71
+ """Scan the metrics directory for all event JSON files."""
72
+ self.events = []
73
+ if not os.path.isdir(self.metrics_dir):
74
+ return
75
+
76
+ for root, dirs, files in os.walk(self.metrics_dir):
77
+ for fname in files:
78
+ if fname.endswith(".json") and not fname.startswith("config"):
79
+ fpath = os.path.join(root, fname)
80
+ try:
81
+ with open(fpath, "r", encoding="utf-8") as f:
82
+ self.events.append(json.load(f))
83
+ except Exception:
84
+ pass
85
+
86
+ # Sort by timestamp, newest first
87
+ self.events.sort(
88
+ key=lambda e: e.get("timestamp", ""),
89
+ reverse=True,
90
+ )
91
+
92
+ def _populate_table(self) -> None:
93
+ """Fill the DataTable with the loaded events."""
94
+ table = self.query_one("#events_table", DataTable)
95
+ table.clear()
96
+
97
+ if not self.events:
98
+ table.add_column(__("No metrics data found."))
99
+ return
100
+
101
+ columns = [
102
+ __("Timestamp"),
103
+ __("Command"),
104
+ __("Status"),
105
+ __("Provider"),
106
+ __("Tokens"),
107
+ __("Duration (ms)"),
108
+ ]
109
+ for col in columns:
110
+ table.add_column(col)
111
+
112
+ for evt in self.events[:100]: # Show last 100 events
113
+ ts = evt.get("timestamp", "")[:19].replace("T", " ")
114
+ table.add_row(
115
+ ts,
116
+ evt.get("command", ""),
117
+ evt.get("status", ""),
118
+ evt.get("provider", ""),
119
+ str(evt.get("tokens_estimated", 0)),
120
+ str(evt.get("duration_ms", 0)),
121
+ )
122
+
123
+ def _update_summary(self) -> None:
124
+ """Update the summary bar with aggregate stats."""
125
+ summary = self.query_one("#summary", Static)
126
+ status = self.query_one("#status_bar", Static)
127
+
128
+ if not self.events:
129
+ summary.update(__("No metrics data found in ~/.gitpr/metrics/"))
130
+ status.update(
131
+ __("Use 'gitpr --metrics --export' to consolidate data for dashboards.")
132
+ )
133
+ return
134
+
135
+ total = len(self.events)
136
+ commands = Counter(e.get("command", "?") for e in self.events)
137
+ providers = Counter(e.get("provider", "?") for e in self.events)
138
+ total_tokens = sum(e.get("tokens_estimated", 0) for e in self.events)
139
+ errors = sum(1 for e in self.events if e.get("status") == "error")
140
+
141
+ top_cmds = ", ".join(f"{cmd}({n})" for cmd, n in commands.most_common(3))
142
+ top_prov = ", ".join(f"{p}({n})" for p, n in providers.most_common(2))
143
+
144
+ lines = [
145
+ f"{__('Total events')}: {total} | {__('Errors')}: {errors} | {__('Total tokens')}: {total_tokens:,}",
146
+ f"{__('Top commands')}: {top_cmds}",
147
+ f"{__('Providers')}: {top_prov}",
148
+ ]
149
+ summary.update("\n".join(lines))
150
+
151
+ # Show the newest event timestamp
152
+ newest = self.events[0].get("timestamp", "")[:19].replace("T", " ")
153
+ oldest = self.events[-1].get("timestamp", "")[:19].replace("T", " ")
154
+ status.update(
155
+ f"{__('Range')}: {oldest} → {newest} | {__('Showing')} {min(total, 100)} {__('of')} {total} {__('events')}"
156
+ )
157
+
158
+ def action_refresh(self) -> None:
159
+ """Reload data from disk."""
160
+ self._load_metrics()
161
+ self._populate_table()
162
+ self._update_summary()
163
+
164
+
165
+ def launch_metrics_dashboard(metrics_dir=None):
166
+ """Entry point: launches the metrics TUI."""
167
+ app = MetricsApp(metrics_dir=metrics_dir)
168
+ app.run()
@@ -7,8 +7,8 @@ import click
7
7
  from datetime import datetime
8
8
 
9
9
  # Current version of your local executable (Update this on every new build!)
10
- __version__ = "0.0.29" # GitPR current version
11
- __lang_version__ = "v0.0.7" # Language dictionary version control
10
+ __version__ = "0.0.30" # GitPR current version
11
+ __lang_version__ = "v0.0.8" # Language dictionary version control
12
12
  GITHUB_API_URL = "https://api.github.com/repos/natanfiuza/gitpr/releases/latest"
13
13
  PYPI_API_URL = "https://pypi.org/pypi/gitpr-cli/json"
14
14
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes