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.
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/PKG-INFO +3 -2
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/README.md +1 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/gitpr_cli.egg-info/PKG-INFO +3 -2
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/gitpr_cli.egg-info/SOURCES.txt +2 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/pyproject.toml +2 -2
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/ai_providers.py +18 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/cache.py +17 -1
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/core.py +38 -7
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/main.py +70 -6
- gitpr_cli-0.0.30/src/metrics.py +266 -0
- gitpr_cli-0.0.30/src/ui/metrics_app.py +168 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/updater.py +2 -2
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/LICENSE +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/gitpr_cli.egg-info/dependency_links.txt +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/gitpr_cli.egg-info/entry_points.txt +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/gitpr_cli.egg-info/requires.txt +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/gitpr_cli.egg-info/top_level.txt +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/setup.cfg +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/__init__.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/blame_engine.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/chat_memory.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/config.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/i18n.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/issue_engine.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/linter_engine.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/mcp_server.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/security.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/spinner.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/tui_issue.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/ui/__init__.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/ui/chat_app.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/ui/help_screen.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/src/ui/issue_app.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/tests/test_chat_backend.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/tests/test_core.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/tests/test_install_wizard.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/tests/test_mcp_prompts.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/tests/test_mcp_server.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/tests/test_pre_save.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/tests/test_skill_command.py +0 -0
- {gitpr_cli-0.0.29 → gitpr_cli-0.0.30}/tests/test_smart_excludes.py +0 -0
- {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.
|
|
4
|
-
Summary:
|
|
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.
|
|
4
|
-
Summary:
|
|
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.
|
|
8
|
-
description = "
|
|
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
|
|
337
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
327
|
+
# Warning processing (best-practice advisories only)
|
|
273
328
|
if has_warnings:
|
|
274
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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.
|
|
11
|
-
__lang_version__ = "v0.0.
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|