gitpr-cli 1.6.0__py3-none-any.whl → 1.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gitpr_cli-1.6.0.dist-info → gitpr_cli-1.8.0.dist-info}/METADATA +2 -2
- {gitpr_cli-1.6.0.dist-info → gitpr_cli-1.8.0.dist-info}/RECORD +17 -16
- src/ascii_chart.py +248 -0
- src/config.py +55 -0
- src/config_schema.py +176 -0
- src/core.py +201 -9
- src/infrastructure/scm/factory.py +9 -6
- src/issue_engine.py +6 -1
- src/review/remote_pr.py +7 -1
- src/scm_settings.py +12 -0
- src/ui/config_app.py +1 -0
- src/ui/metrics_app.py +118 -21
- src/updater.py +2 -2
- {gitpr_cli-1.6.0.dist-info → gitpr_cli-1.8.0.dist-info}/WHEEL +0 -0
- {gitpr_cli-1.6.0.dist-info → gitpr_cli-1.8.0.dist-info}/entry_points.txt +0 -0
- {gitpr_cli-1.6.0.dist-info → gitpr_cli-1.8.0.dist-info}/licenses/LICENSE +0 -0
- {gitpr_cli-1.6.0.dist-info → gitpr_cli-1.8.0.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitpr-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.0
|
|
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
|
|
@@ -499,7 +499,7 @@ This is useful for scripting, CI/CD pipelines, and one-off queries where you don
|
|
|
499
499
|
|
|
500
500
|
📖 **Full documentation:** [gitpr.natanfiuza.dev.br/docs/mcp-integration](https://gitpr.natanfiuza.dev.br/docs/mcp-integration) — available in 5 languages (EN, PT-BR, PT-PT, ES, FR).
|
|
501
501
|
|
|
502
|
-
> 🧩 **Official VS Code & Cursor Extension** — Prefer native editor commands, SCM toolbar buttons, and inline `Problems` diagnostics?
|
|
502
|
+
> 🧩 **Official VS Code & Cursor Extension** — Prefer native editor commands, SCM toolbar buttons, and inline `Problems` diagnostics? Download the ready-to-use `.vsix` from [GitHub Releases](https://github.com/natanfiuza/gitpr/releases) or see the [Editor Extension Guide](https://gitpr.natanfiuza.dev.br/docs/editor-extension) for 1-click install in VS Code and Cursor. Maintainers can run `scripts/marketplace-setup-wizard.sh` to configure and publish to Visual Studio Marketplace and Open VSX Registry.
|
|
503
503
|
|
|
504
504
|
> 💬 **MCP Prompts** — GitPR also exposes 7 pre-defined message templates (prompts) for common flows like "Review PR", "Generate Commit Message", and "Create Issue from Diff". See the [MCP Prompts guide](https://gitpr.natanfiuza.dev.br/docs/mcp-prompts) for the full list.
|
|
505
505
|
|
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
gitpr_cli-1.
|
|
1
|
+
gitpr_cli-1.8.0.dist-info/licenses/LICENSE,sha256=UQzLC6mwqb-ZNDkBBAqOxC-SMQcEID0gaM6oFcu6u48,27030
|
|
2
2
|
src/__init__.py,sha256=ZHymXsocMDazI_fjOIgAQsRQvFPN2tV8FOuv0qptI_o,30
|
|
3
3
|
src/ai_providers.py,sha256=QyFEOsXyA59lZziO5bl-4nlYFJpxBcxU9aLhcTBnkWE,14633
|
|
4
|
+
src/ascii_chart.py,sha256=WFQclaxm5W6bPBBNVlImyrnrJYCSj5gRgEpldYzI7wk,8279
|
|
4
5
|
src/blame_engine.py,sha256=Zov1hxrt7Dcmf56ovJKz3QmHIfzDKtSub2iItXeg9-A,17104
|
|
5
6
|
src/cache.py,sha256=frROc69NyTn3TdJIOPctvuH9z_sh2pdKxL4ekMtyh6k,6572
|
|
6
7
|
src/changelog_builder.py,sha256=Ea0coS6LCqsdxTWrKW8SZyVsD02HuNNYzCoggI0loi8,9922
|
|
7
8
|
src/chat_memory.py,sha256=PcohnyspAfIwwwbsp9U2Wk_1AylJMFEMoWqRSJe_w30,8949
|
|
8
9
|
src/commit_classifier.py,sha256=HQHxPbCfgW_GD1_4mbO2No16jh_Ci5RTSoLbTU4IWYI,6920
|
|
9
|
-
src/config.py,sha256=
|
|
10
|
-
src/config_schema.py,sha256=
|
|
11
|
-
src/core.py,sha256=
|
|
10
|
+
src/config.py,sha256=1U9dZyfk_OsMzoIcYIbvh_N1H9ZO34PikbjjCHzg7PI,48895
|
|
11
|
+
src/config_schema.py,sha256=qClFT_qoa_oV3yyPENiT79MB4AFsuXvsSlo4an-DIcE,60375
|
|
12
|
+
src/core.py,sha256=9IuJPJecTyATUzI4qCXWv67PnfzQMulrKOOb24CdN3U,106050
|
|
12
13
|
src/diff_parser.py,sha256=JHeaTJflNOEbR9SYK_Ljr2hctrrABUIb31IFHAbyIDo,15405
|
|
13
14
|
src/doc_links.py,sha256=GdANhE6XbEnVDsbpCf22eUZnqFXor_2SU1O4S-pteIE,2028
|
|
14
15
|
src/github_api.py,sha256=i1o58oKWWmMCerP-g-hOQHGCwPWd01ooKntTKjpFhIA,3948
|
|
15
16
|
src/i18n.py,sha256=Xp9c1dNGmTHbo8SyXa-LQuO71nWx3RP-Us7jJ7ARtUY,4804
|
|
16
|
-
src/issue_engine.py,sha256=
|
|
17
|
+
src/issue_engine.py,sha256=fgf1SO0ENJmHTKP4NFlHaT2jdOWgWAVdU0yRoQq2-hc,11171
|
|
17
18
|
src/ledger.py,sha256=3p5nX9gPGHgANQxBxEywrKo3TNb5KdWDeTI2mslYopo,43787
|
|
18
19
|
src/linter_engine.py,sha256=hIxht8M09Fnm6NiJZOqjI6dyqnFoHUDVA8Ve4dWZV4s,25214
|
|
19
20
|
src/linter_wizard.py,sha256=mo8aSTZmVLluCOskWv3nji74WPJh_6c122zwADN9aCc,7634
|
|
@@ -25,14 +26,14 @@ src/project_config.py,sha256=r-gzjKeFuTYvUzOflQlpbijn6uLXtPEUBzA-1PzUR_k,16749
|
|
|
25
26
|
src/release_engine.py,sha256=S0kgO-a7RSC07AzvRWRlGo32jx38PAQGIzgvYXOfthk,41274
|
|
26
27
|
src/reviewer_resolution.py,sha256=bFRdf4GVWIpz4kk7cyF7EYWV43aP7V8sAlNGqKRPd7A,5913
|
|
27
28
|
src/reviewer_suggestion.py,sha256=YeXJLjJjn1YS69FChWZBF4qHUuIwvv_n-GPiymLN8LQ,8904
|
|
28
|
-
src/scm_settings.py,sha256=
|
|
29
|
+
src/scm_settings.py,sha256=ZNMgr893qj-B3DjbXkuINGnJYvJU_WvHvi535ZjdVow,14698
|
|
29
30
|
src/security.py,sha256=fkWy-r4tdISH0xOyWZyR8ZCFyLuYqu8fn9sKsIeNAso,1351
|
|
30
31
|
src/security_ruleset.py,sha256=4mqmqtWLHuhuCcjjaoKUM_YFzil4cWZ_ubjlOLgbM_4,4874
|
|
31
32
|
src/skill_frontmatter.py,sha256=K-HsjwSYPHUsDRjIiugBE3koxVdGf38L5d4l8eYVqEo,2167
|
|
32
33
|
src/spinner.py,sha256=JGOO_taTXbrQK0KnNxUQ40PcpS802bYYBosWWm_NG9o,9012
|
|
33
34
|
src/suggest_reviewers.py,sha256=LTOtwDIjaBS_FGDU6bjN6oGy-YD112hIPugYytNKD_A,5821
|
|
34
35
|
src/tui_issue.py,sha256=yaNpj9SSXiWWjMJ7a9FABcNWSffCkA1tjUSnJ1hN-MQ,11477
|
|
35
|
-
src/updater.py,sha256=
|
|
36
|
+
src/updater.py,sha256=ECdRPKQ-_sKDi4LFn2MlMXOjTPFx7QdJApjmv5pY_lE,9560
|
|
36
37
|
src/usage_log.py,sha256=CBCNlUm_xftXsBaGRHvsEjTENvZfE-lW70HGLMkisSg,4508
|
|
37
38
|
src/version_bump.py,sha256=Dw1LZ_c6VzaO_s9cDeYSevoWtXVr_oP70eS_mkrOm8s,3041
|
|
38
39
|
src/application/__init__.py,sha256=3YXFcmxJh0f7BwryKXxUrOhGrLnZLsagV-VbBIk0eGk,31
|
|
@@ -127,7 +128,7 @@ src/infrastructure/scm/__init__.py,sha256=CbW2p9DsYpwwUqy4xdtgoPugsLijopfNwLT-IA
|
|
|
127
128
|
src/infrastructure/scm/azure_devops_provider.py,sha256=9IpgB-Vqzgj0VDFXaYs4PFRiecsQGU9svW5m3OpefsI,18790
|
|
128
129
|
src/infrastructure/scm/base.py,sha256=9tN2IivEngHoo1pDK3PivWHvHSkXokuyQQUJ680gsXU,17198
|
|
129
130
|
src/infrastructure/scm/bitbucket_provider.py,sha256=A58dMDa60lLUynENrinWENFz5dSA_lb9VJSg22ptbkI,16895
|
|
130
|
-
src/infrastructure/scm/factory.py,sha256=
|
|
131
|
+
src/infrastructure/scm/factory.py,sha256=olcDDs0tsa8Ae0vKhhW5DnS408xRhn5fxv_0DskFCgY,4665
|
|
131
132
|
src/infrastructure/scm/github_provider.py,sha256=vdJDTTYd-CYkAQos-6IZ2hFOJkXG09SrkIcZzHbuuIc,21316
|
|
132
133
|
src/infrastructure/scm/gitlab_provider.py,sha256=VAYjdg5gU43FDamXUNMdFzs9MwIGyNRqMzEnLTacRE8,17951
|
|
133
134
|
src/infrastructure/scm/web_links.py,sha256=-7oF7oUwK_UvKVJZWDPbTnKBi9JEQu2dMDYtgqH10iM,3724
|
|
@@ -152,7 +153,7 @@ src/policy_packs/vue-quality/policy.yml,sha256=FyVL17Fl_bgVjxZU5lO7MME5w04vjKV3U
|
|
|
152
153
|
src/review/__init__.py,sha256=ux2rkwebfnzuQpFc57eEceOC4CZkojPOtfp951Ux5Gc,700
|
|
153
154
|
src/review/diff_normalizer.py,sha256=8KnFbauGK-fuLGSSL_ox4kAwCE048MuTmorPwBrhlEU,3675
|
|
154
155
|
src/review/diff_source.py,sha256=V6fMtgxNJbMBL_qRifkLGfzFwtaTCbFFKFvcutHZjLk,2733
|
|
155
|
-
src/review/remote_pr.py,sha256=
|
|
156
|
+
src/review/remote_pr.py,sha256=f9gkvK4uw--Np_la4lRuSkhmEplUccI5ePCllJFj-Lo,13884
|
|
156
157
|
src/review/render.py,sha256=l63vQ3WiGxPSzCk4JxyQoFNfIyG0AdHgMobAjMeaOcY,3194
|
|
157
158
|
src/split/__init__.py,sha256=8XZn4jwPhQ0pJyNEMTBfgbGTMJkQUvePNp5ZncWCJ-Q,929
|
|
158
159
|
src/split/apply_split_plan.py,sha256=ZKoACWLCaBndLzvRl8wHyAyPlalTrjrPJ2ep_AjpWCE,10929
|
|
@@ -163,11 +164,11 @@ src/split/split_plan.py,sha256=Y0Ah3LbzixKgAy74dh7XiQImsw6-f2dxwfJSkQHreCw,6572
|
|
|
163
164
|
src/ui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
164
165
|
src/ui/chat_app.py,sha256=eOhGsaC2EweYNJe9kdCbN9AMYcN_LI2BCj_tmOYZ7Bs,26789
|
|
165
166
|
src/ui/commit_app.py,sha256=mggIsdHkg3EIvQoSc11XL7tpkSGJ25WWszHdgmJnMjc,8304
|
|
166
|
-
src/ui/config_app.py,sha256=
|
|
167
|
+
src/ui/config_app.py,sha256=thlCw2E8DungQf2W8DoATT2RFioq_vmsyS_IM2nsybo,94671
|
|
167
168
|
src/ui/help_screen.py,sha256=PxdLLey13DycZpIY0nj2Zd0DSF1uV02Gcrq3Ny88CuU,1723
|
|
168
169
|
src/ui/issue_app.py,sha256=ea8RYK0QcS9izoJ9e60TTTznOX8QVhMREW_GQEyi_xM,10192
|
|
169
170
|
src/ui/linter_app.py,sha256=xVMMZJhVQAo6XKI_MrRQvnGXNoDjELRs7k8rNaF4yws,1602
|
|
170
|
-
src/ui/metrics_app.py,sha256=
|
|
171
|
+
src/ui/metrics_app.py,sha256=8zbCVwTMN8k6uDHpbXlzBxd1EFIFaDTPDyH4h-0bdXY,20618
|
|
171
172
|
src/ui/metrics_migration_app.py,sha256=U6l-ORUk1NlC2aIQSLvFjahMVM1dat-wFjWlct0fRYQ,6096
|
|
172
173
|
src/ui/pr_base_app.py,sha256=OvPcQVeyBrGBH8lhlVy0Ks1kVdvqcYHl7Zc3P2tXPGI,3857
|
|
173
174
|
src/ui/pr_publish_app.py,sha256=TU5IrSB3bkQEhl2N0W8KCwHfRGGacijqziMD9I7By04,71802
|
|
@@ -176,8 +177,8 @@ src/ui/demo/__init__.py,sha256=aOdUN0izuwW2FfLAHG9Qga-DJG5NBVqP0gYNO8OiKls,59
|
|
|
176
177
|
src/ui/demo/demo_app.py,sha256=4t_Mct-BJXpe5tlt1XmtRyN1IT0OL6U3WSmGqOJqMG4,3165
|
|
177
178
|
src/ui/demo/demo_help_screen.py,sha256=I_LELswhPtbHjfMWGVUqLtOwxGbzSYJsVWXjqZrLoLA,2108
|
|
178
179
|
src/ui/demo/demo_screens.py,sha256=GXaQFTgTn9awSP-EYiBXsCZqB583WrSQcuNYoBK12g8,1997
|
|
179
|
-
gitpr_cli-1.
|
|
180
|
-
gitpr_cli-1.
|
|
181
|
-
gitpr_cli-1.
|
|
182
|
-
gitpr_cli-1.
|
|
183
|
-
gitpr_cli-1.
|
|
180
|
+
gitpr_cli-1.8.0.dist-info/METADATA,sha256=6tgDZ4ZC09Mkw_S35xuA2v_ciLAMqFoM7b5buvBxdNM,54248
|
|
181
|
+
gitpr_cli-1.8.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
182
|
+
gitpr_cli-1.8.0.dist-info/entry_points.txt,sha256=TstGnMUzdOI5qNmpM97EPDBPuMqOZxoo6EoLxHSOMSQ,71
|
|
183
|
+
gitpr_cli-1.8.0.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
|
|
184
|
+
gitpr_cli-1.8.0.dist-info/RECORD,,
|
src/ascii_chart.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ASCII / Unicode 2D Chart Rendering Engine for GitPR Metrics.
|
|
3
|
+
|
|
4
|
+
Provides pure functions to render 2D terminal charts (vertical histograms,
|
|
5
|
+
time-series charts) without external dependencies or UI framework coupling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _fmt_tokens(val: float) -> str:
|
|
13
|
+
"""Format token count in human-readable compact form (e.g. 1.2M, 45.0k, 500)."""
|
|
14
|
+
if val >= 1_000_000:
|
|
15
|
+
return f"{val / 1_000_000:.1f}M"
|
|
16
|
+
if val >= 1_000:
|
|
17
|
+
return f"{val / 1_000:.1f}k"
|
|
18
|
+
return str(int(round(val)))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _duration_ms_label(ms: Optional[float]) -> str:
|
|
22
|
+
"""Format duration in milliseconds into ms, s, or min."""
|
|
23
|
+
if ms is None:
|
|
24
|
+
return "0 ms"
|
|
25
|
+
if ms < 1000:
|
|
26
|
+
return f"{ms:.0f} ms"
|
|
27
|
+
seconds = ms / 1000.0
|
|
28
|
+
if seconds < 60:
|
|
29
|
+
return f"{seconds:.1f} s"
|
|
30
|
+
return f"{seconds / 60.0:.1f} min"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render_empty_card(title: str, message: str, width: int = 60) -> str:
|
|
34
|
+
"""Render a framed empty-state banner with informative text."""
|
|
35
|
+
w = max(40, width)
|
|
36
|
+
inner_w = w - 4
|
|
37
|
+
top_border = f"┌{'─' * (w - 2)}┐"
|
|
38
|
+
bottom_border = f"└{'─' * (w - 2)}┘"
|
|
39
|
+
empty_line = f"│ {' ' * inner_w} │"
|
|
40
|
+
|
|
41
|
+
title_line = f"│ [bold cyan]{title:<{inner_w}}[/bold cyan] │"
|
|
42
|
+
msg_line = f"│ [yellow]{message:<{inner_w}}[/yellow] │"
|
|
43
|
+
|
|
44
|
+
return "\n".join([top_border, title_line, empty_line, msg_line, bottom_border])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render_vertical_histogram(
|
|
48
|
+
latency: Optional[Dict[str, Any]],
|
|
49
|
+
height: int = 7,
|
|
50
|
+
col_width: int = 7,
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Render a 2D vertical ASCII histogram for latency distributions.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
latency: Dict from latency_report() containing 'count', 'average_ms',
|
|
56
|
+
'median_ms', 'p95_ms', and 'buckets'.
|
|
57
|
+
height: Height in lines for the plotting area.
|
|
58
|
+
col_width: Character width of each column bar.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Formatted multi-line string containing the 2D chart.
|
|
62
|
+
"""
|
|
63
|
+
if not latency or not latency.get("count") or not latency.get("buckets"):
|
|
64
|
+
return render_empty_card(
|
|
65
|
+
"📊 Latency Distribution",
|
|
66
|
+
"No timed executions recorded in the selected window.",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
buckets: List[Dict[str, Any]] = latency["buckets"]
|
|
70
|
+
counts = [b["count"] for b in buckets]
|
|
71
|
+
max_count = max(counts) if counts else 0
|
|
72
|
+
if max_count <= 0:
|
|
73
|
+
max_count = 1
|
|
74
|
+
|
|
75
|
+
count_total = latency.get("count", 0)
|
|
76
|
+
avg_lbl = _duration_ms_label(latency.get("average_ms"))
|
|
77
|
+
med_lbl = _duration_ms_label(latency.get("median_ms"))
|
|
78
|
+
p95_lbl = _duration_ms_label(latency.get("p95_ms"))
|
|
79
|
+
|
|
80
|
+
lines: List[str] = []
|
|
81
|
+
|
|
82
|
+
# Title & Summary header
|
|
83
|
+
lines.append("[bold cyan]📊 Latency Histogram[/bold cyan]")
|
|
84
|
+
lines.append(
|
|
85
|
+
f" Timed runs: [bold]{count_total}[/bold] │ "
|
|
86
|
+
f"Average: [cyan]{avg_lbl}[/cyan] │ "
|
|
87
|
+
f"Median (P50): [green]{med_lbl}[/green] │ "
|
|
88
|
+
f"P95: [yellow]{p95_lbl}[/yellow]"
|
|
89
|
+
)
|
|
90
|
+
lines.append("")
|
|
91
|
+
|
|
92
|
+
# Y-axis label formatting width
|
|
93
|
+
y_label_width = max(len(str(max_count)), 3)
|
|
94
|
+
|
|
95
|
+
# Plot rows from top (height) down to 1
|
|
96
|
+
for r in range(height, 0, -1):
|
|
97
|
+
# Y-axis tick mark and label
|
|
98
|
+
ratio = r / height
|
|
99
|
+
level_val = round(ratio * max_count)
|
|
100
|
+
if r == height:
|
|
101
|
+
y_tick = f"{max_count:>{y_label_width}} ┤"
|
|
102
|
+
elif r == math.ceil(height / 2):
|
|
103
|
+
half_val = round(max_count / 2)
|
|
104
|
+
y_tick = f"{half_val:>{y_label_width}} ┤"
|
|
105
|
+
else:
|
|
106
|
+
y_tick = f"{' ' * y_label_width} │"
|
|
107
|
+
|
|
108
|
+
# Build column cells
|
|
109
|
+
cells = []
|
|
110
|
+
for b_count in counts:
|
|
111
|
+
# Thresholds for full block and half block
|
|
112
|
+
val_ratio = b_count / max_count
|
|
113
|
+
if val_ratio >= (r / height):
|
|
114
|
+
# Full block
|
|
115
|
+
bar_chars = "█" * col_width
|
|
116
|
+
elif val_ratio >= ((r - 0.5) / height):
|
|
117
|
+
# Half block
|
|
118
|
+
bar_chars = "▄" * col_width
|
|
119
|
+
else:
|
|
120
|
+
bar_chars = " " * col_width
|
|
121
|
+
|
|
122
|
+
cells.append(bar_chars)
|
|
123
|
+
|
|
124
|
+
row_content = " ".join(cells)
|
|
125
|
+
lines.append(f" {y_tick} {row_content}")
|
|
126
|
+
|
|
127
|
+
# X-axis Baseline
|
|
128
|
+
base_col = "─" * col_width
|
|
129
|
+
x_axis_line = "───".join([base_col] * len(buckets))
|
|
130
|
+
lines.append(f" {0:>{y_label_width}} ┴─{x_axis_line}─")
|
|
131
|
+
|
|
132
|
+
# Bucket Labels
|
|
133
|
+
label_cells = []
|
|
134
|
+
for b in buckets:
|
|
135
|
+
lbl = b.get("label", "")
|
|
136
|
+
label_cells.append(f"{lbl:^{col_width}}")
|
|
137
|
+
lines.append(f" {' ' * y_label_width} " + " ".join(label_cells))
|
|
138
|
+
|
|
139
|
+
# Counts and percentages below labels
|
|
140
|
+
stat_cells = []
|
|
141
|
+
for b in buckets:
|
|
142
|
+
c = b.get("count", 0)
|
|
143
|
+
rate = b.get("rate", 0.0)
|
|
144
|
+
txt = f"{c} ({rate:.0%})"
|
|
145
|
+
stat_cells.append(f"{txt:^{col_width}}")
|
|
146
|
+
lines.append(f" {' ' * y_label_width} " + " ".join(stat_cells))
|
|
147
|
+
|
|
148
|
+
return "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def render_token_timeseries(
|
|
152
|
+
trend: Optional[Dict[str, Any]],
|
|
153
|
+
height: int = 7,
|
|
154
|
+
col_width: int = 5,
|
|
155
|
+
) -> str:
|
|
156
|
+
"""Render a 2D continuous ASCII time-series chart for daily token trends.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
trend: Dict from token_trend_report() containing 'days', 'total_tokens',
|
|
160
|
+
'peak_tokens', 'average_tokens', and 'sparkline'.
|
|
161
|
+
height: Height in lines for the plotting area.
|
|
162
|
+
col_width: Character width allocated per day column.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Formatted multi-line string containing the 2D chart.
|
|
166
|
+
"""
|
|
167
|
+
if not trend or not trend.get("days") or not trend.get("total_tokens"):
|
|
168
|
+
return render_empty_card(
|
|
169
|
+
"📈 Token Trend & Consumption",
|
|
170
|
+
"No token consumption recorded in the selected window.",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
days: List[Dict[str, Any]] = trend["days"]
|
|
174
|
+
peak_tokens = trend.get("peak_tokens", 0)
|
|
175
|
+
total_tokens = trend.get("total_tokens", 0)
|
|
176
|
+
avg_tokens = trend.get("average_tokens", 0.0)
|
|
177
|
+
sparkline = trend.get("sparkline", "")
|
|
178
|
+
|
|
179
|
+
if peak_tokens <= 0:
|
|
180
|
+
peak_tokens = 1
|
|
181
|
+
|
|
182
|
+
lines: List[str] = []
|
|
183
|
+
|
|
184
|
+
# Title & Summary header
|
|
185
|
+
lines.append("[bold cyan]📈 Token Consumption Trend[/bold cyan]")
|
|
186
|
+
lines.append(
|
|
187
|
+
f" Total: [bold]{total_tokens:,}[/bold] │ "
|
|
188
|
+
f"Peak: [yellow]{peak_tokens:,}[/yellow] │ "
|
|
189
|
+
f"Daily Avg: [green]{avg_tokens:,.0f}[/green] │ "
|
|
190
|
+
f"Sparkline: {sparkline}"
|
|
191
|
+
)
|
|
192
|
+
lines.append("")
|
|
193
|
+
|
|
194
|
+
y_label_top = _fmt_tokens(peak_tokens)
|
|
195
|
+
y_label_mid = _fmt_tokens(peak_tokens / 2)
|
|
196
|
+
y_label_width = max(len(y_label_top), len(y_label_mid), 4)
|
|
197
|
+
|
|
198
|
+
# Plot matrix from top down
|
|
199
|
+
for r in range(height, 0, -1):
|
|
200
|
+
if r == height:
|
|
201
|
+
y_tick = f"{y_label_top:>{y_label_width}} ┼"
|
|
202
|
+
grid_char = "┈"
|
|
203
|
+
elif r == math.ceil(height / 2):
|
|
204
|
+
y_tick = f"{y_label_mid:>{y_label_width}} ┼"
|
|
205
|
+
grid_char = "┈"
|
|
206
|
+
else:
|
|
207
|
+
y_tick = f"{' ' * y_label_width} │"
|
|
208
|
+
grid_char = " "
|
|
209
|
+
|
|
210
|
+
day_cells = []
|
|
211
|
+
for d in days:
|
|
212
|
+
tokens = d.get("tokens", 0)
|
|
213
|
+
ratio = tokens / peak_tokens
|
|
214
|
+
if ratio >= (r / height):
|
|
215
|
+
cell_str = "█" * col_width
|
|
216
|
+
elif ratio >= ((r - 0.5) / height):
|
|
217
|
+
cell_str = "▄" * col_width
|
|
218
|
+
elif grid_char != " ":
|
|
219
|
+
cell_str = grid_char * col_width
|
|
220
|
+
else:
|
|
221
|
+
cell_str = " " * col_width
|
|
222
|
+
day_cells.append(cell_str)
|
|
223
|
+
|
|
224
|
+
lines.append(f" {y_tick} " + " ".join(day_cells))
|
|
225
|
+
|
|
226
|
+
# X-axis Baseline
|
|
227
|
+
base_col = "─" * col_width
|
|
228
|
+
x_axis_line = "─".join([base_col] * len(days))
|
|
229
|
+
lines.append(f" {'0':>{y_label_width}} ┴─{x_axis_line}─")
|
|
230
|
+
|
|
231
|
+
# Date labels (MM-DD)
|
|
232
|
+
date_cells = []
|
|
233
|
+
for d in days:
|
|
234
|
+
raw_day = d.get("day", "")
|
|
235
|
+
# Format YYYY-MM-DD -> MM-DD
|
|
236
|
+
short_date = raw_day[5:] if len(raw_day) >= 10 else raw_day
|
|
237
|
+
date_cells.append(f"{short_date:^{col_width}}")
|
|
238
|
+
lines.append(f" {' ' * y_label_width} " + " ".join(date_cells))
|
|
239
|
+
|
|
240
|
+
# Compact token labels below dates
|
|
241
|
+
tok_cells = []
|
|
242
|
+
for d in days:
|
|
243
|
+
t = d.get("tokens", 0)
|
|
244
|
+
tok_str = _fmt_tokens(t) if t > 0 else "·"
|
|
245
|
+
tok_cells.append(f"{tok_str:^{col_width}}")
|
|
246
|
+
lines.append(f" {' ' * y_label_width} " + " ".join(tok_cells))
|
|
247
|
+
|
|
248
|
+
return "\n".join(lines)
|
src/config.py
CHANGED
|
@@ -249,6 +249,59 @@ def get_linter_timeout():
|
|
|
249
249
|
return _positive_float(os.getenv("GITPR_LINTER_TIMEOUT"), _DEFAULT_LINTER_TIMEOUT)
|
|
250
250
|
|
|
251
251
|
|
|
252
|
+
DEFAULT_MAP_REDUCE_MAX_TOKENS = 90000
|
|
253
|
+
DEFAULT_MAP_REDUCE_MAX_PART_TOKENS = 50000
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _positive_int_from_raw(raw, fallback):
|
|
257
|
+
"""Parses raw as a positive int, falling back on non-positive or invalid input."""
|
|
258
|
+
if raw is None:
|
|
259
|
+
return fallback
|
|
260
|
+
try:
|
|
261
|
+
val = int(str(raw).strip())
|
|
262
|
+
return val if val > 0 else fallback
|
|
263
|
+
except (TypeError, ValueError):
|
|
264
|
+
return fallback
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def get_map_reduce_thresholds(provider=None):
|
|
268
|
+
"""Returns (max_tokens, max_part_tokens) for Map-Reduce chunking.
|
|
269
|
+
|
|
270
|
+
Resolution hierarchy:
|
|
271
|
+
1. Provider-specific: GITPR_MAP_REDUCE_MAX_TOKENS_<PROVIDER>
|
|
272
|
+
2. Generic: GITPR_MAP_REDUCE_MAX_TOKENS
|
|
273
|
+
3. Baseline default: 90,000 tokens
|
|
274
|
+
|
|
275
|
+
For part tokens:
|
|
276
|
+
1. Provider-specific: GITPR_MAP_REDUCE_MAX_PART_TOKENS_<PROVIDER>
|
|
277
|
+
2. Generic: GITPR_MAP_REDUCE_MAX_PART_TOKENS
|
|
278
|
+
3. Baseline default: 50,000 tokens
|
|
279
|
+
|
|
280
|
+
Guarantees max_part_tokens <= max_tokens.
|
|
281
|
+
"""
|
|
282
|
+
load_dotenv(ENV_FILE)
|
|
283
|
+
prov_key = (provider or "").strip().upper()
|
|
284
|
+
|
|
285
|
+
trigger_val = None
|
|
286
|
+
if prov_key:
|
|
287
|
+
trigger_val = os.getenv(f"GITPR_MAP_REDUCE_MAX_TOKENS_{prov_key}")
|
|
288
|
+
if trigger_val is None or not str(trigger_val).strip():
|
|
289
|
+
trigger_val = os.getenv("GITPR_MAP_REDUCE_MAX_TOKENS")
|
|
290
|
+
max_tokens = _positive_int_from_raw(trigger_val, DEFAULT_MAP_REDUCE_MAX_TOKENS)
|
|
291
|
+
|
|
292
|
+
part_val = None
|
|
293
|
+
if prov_key:
|
|
294
|
+
part_val = os.getenv(f"GITPR_MAP_REDUCE_MAX_PART_TOKENS_{prov_key}")
|
|
295
|
+
if part_val is None or not str(part_val).strip():
|
|
296
|
+
part_val = os.getenv("GITPR_MAP_REDUCE_MAX_PART_TOKENS")
|
|
297
|
+
max_part_tokens = _positive_int_from_raw(part_val, DEFAULT_MAP_REDUCE_MAX_PART_TOKENS)
|
|
298
|
+
|
|
299
|
+
if max_part_tokens > max_tokens:
|
|
300
|
+
max_part_tokens = max_tokens
|
|
301
|
+
|
|
302
|
+
return max_tokens, max_part_tokens
|
|
303
|
+
|
|
304
|
+
|
|
252
305
|
# Metrics pricing.
|
|
253
306
|
#
|
|
254
307
|
# Two layers, the second overriding the first per model:
|
|
@@ -291,6 +344,8 @@ DEFAULT_MODEL_PRICES = {
|
|
|
291
344
|
"deepseek-v4-pro": (0.435, 0.87),
|
|
292
345
|
"gemini-2.5-pro": (1.25, 10.00),
|
|
293
346
|
"gemini-2.5-flash-lite": (0.10, 0.40),
|
|
347
|
+
"gemini-pro-latest": (1.25, 10.00),
|
|
348
|
+
"gemini-flash-lite-latest": (0.10, 0.40),
|
|
294
349
|
}
|
|
295
350
|
|
|
296
351
|
# Providers that run on this machine. There is no invoice behind them, so their
|
src/config_schema.py
CHANGED
|
@@ -22,6 +22,7 @@ from src.i18n import __
|
|
|
22
22
|
# Widget kinds. Each one maps to a different control in ui/config_app.py.
|
|
23
23
|
KIND_BOOL = "bool"
|
|
24
24
|
KIND_INT = "int"
|
|
25
|
+
KIND_FLOAT = "float"
|
|
25
26
|
KIND_STR = "str"
|
|
26
27
|
KIND_ENUM = "enum"
|
|
27
28
|
KIND_TEMPLATE = "template"
|
|
@@ -39,6 +40,7 @@ KIND_BRANCH = "branch"
|
|
|
39
40
|
KINDS = (
|
|
40
41
|
KIND_BOOL,
|
|
41
42
|
KIND_INT,
|
|
43
|
+
KIND_FLOAT,
|
|
42
44
|
KIND_STR,
|
|
43
45
|
KIND_ENUM,
|
|
44
46
|
KIND_TEMPLATE,
|
|
@@ -395,6 +397,16 @@ FIELDS = (
|
|
|
395
397
|
default="180",
|
|
396
398
|
seeded=True,
|
|
397
399
|
),
|
|
400
|
+
ConfigField(
|
|
401
|
+
key="GITPR_METRICS_CURRENCY",
|
|
402
|
+
label=__("Metrics Currency"),
|
|
403
|
+
description=__(
|
|
404
|
+
"Currency code used in telemetry and cost reports (default USD)."
|
|
405
|
+
),
|
|
406
|
+
category="ai",
|
|
407
|
+
kind=KIND_STR,
|
|
408
|
+
default="",
|
|
409
|
+
),
|
|
398
410
|
ConfigField(
|
|
399
411
|
key="GEMINI_API_MODEL_PRIMARY",
|
|
400
412
|
label=__("Gemini Advanced Model"),
|
|
@@ -405,6 +417,28 @@ FIELDS = (
|
|
|
405
417
|
show_if=(("DEFAULT_AI_PROVIDER", ("gemini",)),),
|
|
406
418
|
seeded=True,
|
|
407
419
|
),
|
|
420
|
+
ConfigField(
|
|
421
|
+
key="GITPR_METRICS_PRICE_GEMINI_PRO_LATEST_INPUT",
|
|
422
|
+
label=__("Gemini Advanced Model - Input Price"),
|
|
423
|
+
description=__(
|
|
424
|
+
"Cost per million input tokens for the advanced model."
|
|
425
|
+
),
|
|
426
|
+
category="ai",
|
|
427
|
+
kind=KIND_FLOAT,
|
|
428
|
+
default="",
|
|
429
|
+
show_if=(("DEFAULT_AI_PROVIDER", ("gemini",)),),
|
|
430
|
+
),
|
|
431
|
+
ConfigField(
|
|
432
|
+
key="GITPR_METRICS_PRICE_GEMINI_PRO_LATEST_OUTPUT",
|
|
433
|
+
label=__("Gemini Advanced Model - Output Price"),
|
|
434
|
+
description=__(
|
|
435
|
+
"Cost per million completion tokens for the advanced model."
|
|
436
|
+
),
|
|
437
|
+
category="ai",
|
|
438
|
+
kind=KIND_FLOAT,
|
|
439
|
+
default="",
|
|
440
|
+
show_if=(("DEFAULT_AI_PROVIDER", ("gemini",)),),
|
|
441
|
+
),
|
|
408
442
|
ConfigField(
|
|
409
443
|
key="GEMINI_API_MODEL_SECONDARY",
|
|
410
444
|
label=__("Gemini Fast Model"),
|
|
@@ -415,6 +449,28 @@ FIELDS = (
|
|
|
415
449
|
show_if=(("DEFAULT_AI_PROVIDER", ("gemini",)),),
|
|
416
450
|
seeded=True,
|
|
417
451
|
),
|
|
452
|
+
ConfigField(
|
|
453
|
+
key="GITPR_METRICS_PRICE_GEMINI_FLASH_LITE_LATEST_INPUT",
|
|
454
|
+
label=__("Gemini Fast Model - Input Price"),
|
|
455
|
+
description=__(
|
|
456
|
+
"Cost per million input tokens for the fast model."
|
|
457
|
+
),
|
|
458
|
+
category="ai",
|
|
459
|
+
kind=KIND_FLOAT,
|
|
460
|
+
default="",
|
|
461
|
+
show_if=(("DEFAULT_AI_PROVIDER", ("gemini",)),),
|
|
462
|
+
),
|
|
463
|
+
ConfigField(
|
|
464
|
+
key="GITPR_METRICS_PRICE_GEMINI_FLASH_LITE_LATEST_OUTPUT",
|
|
465
|
+
label=__("Gemini Fast Model - Output Price"),
|
|
466
|
+
description=__(
|
|
467
|
+
"Cost per million completion tokens for the fast model."
|
|
468
|
+
),
|
|
469
|
+
category="ai",
|
|
470
|
+
kind=KIND_FLOAT,
|
|
471
|
+
default="",
|
|
472
|
+
show_if=(("DEFAULT_AI_PROVIDER", ("gemini",)),),
|
|
473
|
+
),
|
|
418
474
|
ConfigField(
|
|
419
475
|
key="GEMINI_API_KEY_ENCRYPTED",
|
|
420
476
|
label=__("Gemini API Key"),
|
|
@@ -427,6 +483,28 @@ FIELDS = (
|
|
|
427
483
|
validator=VALIDATOR_AI_KEY,
|
|
428
484
|
show_if=(("DEFAULT_AI_PROVIDER", ("gemini",)),),
|
|
429
485
|
),
|
|
486
|
+
ConfigField(
|
|
487
|
+
key="GITPR_MAP_REDUCE_MAX_TOKENS_GEMINI",
|
|
488
|
+
label=__("Gemini Map-Reduce Trigger Tokens"),
|
|
489
|
+
description=__(
|
|
490
|
+
"Maximum total diff tokens before triggering Map-Reduce processing for Gemini."
|
|
491
|
+
),
|
|
492
|
+
category="ai",
|
|
493
|
+
kind=KIND_INT,
|
|
494
|
+
default="90000",
|
|
495
|
+
show_if=(("DEFAULT_AI_PROVIDER", ("gemini",)),),
|
|
496
|
+
),
|
|
497
|
+
ConfigField(
|
|
498
|
+
key="GITPR_MAP_REDUCE_MAX_PART_TOKENS_GEMINI",
|
|
499
|
+
label=__("Gemini Map-Reduce Part Size"),
|
|
500
|
+
description=__(
|
|
501
|
+
"Maximum tokens per batch chunk during Gemini Map-Reduce processing."
|
|
502
|
+
),
|
|
503
|
+
category="ai",
|
|
504
|
+
kind=KIND_INT,
|
|
505
|
+
default="50000",
|
|
506
|
+
show_if=(("DEFAULT_AI_PROVIDER", ("gemini",)),),
|
|
507
|
+
),
|
|
430
508
|
ConfigField(
|
|
431
509
|
key="DEEPSEEK_API_MODEL_PRIMARY",
|
|
432
510
|
label=__("DeepSeek Advanced Model"),
|
|
@@ -437,6 +515,28 @@ FIELDS = (
|
|
|
437
515
|
show_if=(("DEFAULT_AI_PROVIDER", ("deepseek",)),),
|
|
438
516
|
seeded=True,
|
|
439
517
|
),
|
|
518
|
+
ConfigField(
|
|
519
|
+
key="GITPR_METRICS_PRICE_DEEPSEEK_V4_PRO_INPUT",
|
|
520
|
+
label=__("DeepSeek Advanced Model - Input Price"),
|
|
521
|
+
description=__(
|
|
522
|
+
"Cost per million input tokens for the advanced model."
|
|
523
|
+
),
|
|
524
|
+
category="ai",
|
|
525
|
+
kind=KIND_FLOAT,
|
|
526
|
+
default="",
|
|
527
|
+
show_if=(("DEFAULT_AI_PROVIDER", ("deepseek",)),),
|
|
528
|
+
),
|
|
529
|
+
ConfigField(
|
|
530
|
+
key="GITPR_METRICS_PRICE_DEEPSEEK_V4_PRO_OUTPUT",
|
|
531
|
+
label=__("DeepSeek Advanced Model - Output Price"),
|
|
532
|
+
description=__(
|
|
533
|
+
"Cost per million completion tokens for the advanced model."
|
|
534
|
+
),
|
|
535
|
+
category="ai",
|
|
536
|
+
kind=KIND_FLOAT,
|
|
537
|
+
default="",
|
|
538
|
+
show_if=(("DEFAULT_AI_PROVIDER", ("deepseek",)),),
|
|
539
|
+
),
|
|
440
540
|
ConfigField(
|
|
441
541
|
key="DEEPSEEK_API_MODEL_SECONDARY",
|
|
442
542
|
label=__("DeepSeek Fast Model"),
|
|
@@ -447,6 +547,28 @@ FIELDS = (
|
|
|
447
547
|
show_if=(("DEFAULT_AI_PROVIDER", ("deepseek",)),),
|
|
448
548
|
seeded=True,
|
|
449
549
|
),
|
|
550
|
+
ConfigField(
|
|
551
|
+
key="GITPR_METRICS_PRICE_DEEPSEEK_V4_FLASH_INPUT",
|
|
552
|
+
label=__("DeepSeek Fast Model - Input Price"),
|
|
553
|
+
description=__(
|
|
554
|
+
"Cost per million input tokens for the fast model."
|
|
555
|
+
),
|
|
556
|
+
category="ai",
|
|
557
|
+
kind=KIND_FLOAT,
|
|
558
|
+
default="",
|
|
559
|
+
show_if=(("DEFAULT_AI_PROVIDER", ("deepseek",)),),
|
|
560
|
+
),
|
|
561
|
+
ConfigField(
|
|
562
|
+
key="GITPR_METRICS_PRICE_DEEPSEEK_V4_FLASH_OUTPUT",
|
|
563
|
+
label=__("DeepSeek Fast Model - Output Price"),
|
|
564
|
+
description=__(
|
|
565
|
+
"Cost per million completion tokens for the fast model."
|
|
566
|
+
),
|
|
567
|
+
category="ai",
|
|
568
|
+
kind=KIND_FLOAT,
|
|
569
|
+
default="",
|
|
570
|
+
show_if=(("DEFAULT_AI_PROVIDER", ("deepseek",)),),
|
|
571
|
+
),
|
|
450
572
|
ConfigField(
|
|
451
573
|
key="DEEPSEEK_API_KEY_ENCRYPTED",
|
|
452
574
|
label=__("DeepSeek API Key"),
|
|
@@ -459,6 +581,28 @@ FIELDS = (
|
|
|
459
581
|
validator=VALIDATOR_AI_KEY,
|
|
460
582
|
show_if=(("DEFAULT_AI_PROVIDER", ("deepseek",)),),
|
|
461
583
|
),
|
|
584
|
+
ConfigField(
|
|
585
|
+
key="GITPR_MAP_REDUCE_MAX_TOKENS_DEEPSEEK",
|
|
586
|
+
label=__("DeepSeek Map-Reduce Trigger Tokens"),
|
|
587
|
+
description=__(
|
|
588
|
+
"Maximum total diff tokens before triggering Map-Reduce processing for DeepSeek."
|
|
589
|
+
),
|
|
590
|
+
category="ai",
|
|
591
|
+
kind=KIND_INT,
|
|
592
|
+
default="90000",
|
|
593
|
+
show_if=(("DEFAULT_AI_PROVIDER", ("deepseek",)),),
|
|
594
|
+
),
|
|
595
|
+
ConfigField(
|
|
596
|
+
key="GITPR_MAP_REDUCE_MAX_PART_TOKENS_DEEPSEEK",
|
|
597
|
+
label=__("DeepSeek Map-Reduce Part Size"),
|
|
598
|
+
description=__(
|
|
599
|
+
"Maximum tokens per batch chunk during DeepSeek Map-Reduce processing."
|
|
600
|
+
),
|
|
601
|
+
category="ai",
|
|
602
|
+
kind=KIND_INT,
|
|
603
|
+
default="50000",
|
|
604
|
+
show_if=(("DEFAULT_AI_PROVIDER", ("deepseek",)),),
|
|
605
|
+
),
|
|
462
606
|
ConfigField(
|
|
463
607
|
key="OLLAMA_API_MODEL_PRIMARY",
|
|
464
608
|
label=__("Ollama Advanced Model"),
|
|
@@ -479,6 +623,28 @@ FIELDS = (
|
|
|
479
623
|
show_if=(("DEFAULT_AI_PROVIDER", ("ollama",)),),
|
|
480
624
|
seeded=True,
|
|
481
625
|
),
|
|
626
|
+
ConfigField(
|
|
627
|
+
key="GITPR_MAP_REDUCE_MAX_TOKENS_OLLAMA",
|
|
628
|
+
label=__("Ollama Map-Reduce Trigger Tokens"),
|
|
629
|
+
description=__(
|
|
630
|
+
"Maximum total diff tokens before triggering Map-Reduce processing for Ollama."
|
|
631
|
+
),
|
|
632
|
+
category="ai",
|
|
633
|
+
kind=KIND_INT,
|
|
634
|
+
default="90000",
|
|
635
|
+
show_if=(("DEFAULT_AI_PROVIDER", ("ollama",)),),
|
|
636
|
+
),
|
|
637
|
+
ConfigField(
|
|
638
|
+
key="GITPR_MAP_REDUCE_MAX_PART_TOKENS_OLLAMA",
|
|
639
|
+
label=__("Ollama Map-Reduce Part Size"),
|
|
640
|
+
description=__(
|
|
641
|
+
"Maximum tokens per batch chunk during Ollama Map-Reduce processing."
|
|
642
|
+
),
|
|
643
|
+
category="ai",
|
|
644
|
+
kind=KIND_INT,
|
|
645
|
+
default="50000",
|
|
646
|
+
show_if=(("DEFAULT_AI_PROVIDER", ("ollama",)),),
|
|
647
|
+
),
|
|
482
648
|
# ----------------------------------------------------------- Pull request
|
|
483
649
|
ConfigField(
|
|
484
650
|
key="OUTPUT_FILE_NAME",
|
|
@@ -1595,6 +1761,16 @@ def validate_field_value(field, value):
|
|
|
1595
1761
|
if parsed <= 0:
|
|
1596
1762
|
return __("Use a whole number greater than zero.")
|
|
1597
1763
|
|
|
1764
|
+
elif field.kind == KIND_FLOAT:
|
|
1765
|
+
if not value:
|
|
1766
|
+
return ""
|
|
1767
|
+
try:
|
|
1768
|
+
parsed = float(value)
|
|
1769
|
+
except ValueError:
|
|
1770
|
+
return __("Use a number greater than or equal to zero.")
|
|
1771
|
+
if parsed < 0:
|
|
1772
|
+
return __("Use a number greater than or equal to zero.")
|
|
1773
|
+
|
|
1598
1774
|
elif field.kind == KIND_ENUM:
|
|
1599
1775
|
if value not in field.choices:
|
|
1600
1776
|
return __("Choose one of: {choices}", choices=", ".join(field.choices))
|
src/core.py
CHANGED
|
@@ -170,6 +170,10 @@ _FALLBACK_SMART_EXCLUDES = [
|
|
|
170
170
|
"*.pth",
|
|
171
171
|
"*.onnx",
|
|
172
172
|
"*.safetensors",
|
|
173
|
+
"*.arrow",
|
|
174
|
+
"*.gguf",
|
|
175
|
+
"*.tflite",
|
|
176
|
+
"*.keras",
|
|
173
177
|
"*.bin",
|
|
174
178
|
"*.dat",
|
|
175
179
|
"*.p12",
|
|
@@ -1095,20 +1099,136 @@ def estimate_token_count(text):
|
|
|
1095
1099
|
return len(text) // 4
|
|
1096
1100
|
|
|
1097
1101
|
|
|
1098
|
-
def
|
|
1099
|
-
"""Splits
|
|
1102
|
+
def _split_line_chunk(text, max_tokens):
|
|
1103
|
+
"""Splits arbitrary text by lines (and character slices if needed) keeping each part <= max_tokens."""
|
|
1104
|
+
chunks = []
|
|
1105
|
+
current = ""
|
|
1106
|
+
for line in text.splitlines(keepends=True):
|
|
1107
|
+
if estimate_token_count(line) > max_tokens:
|
|
1108
|
+
if current:
|
|
1109
|
+
chunks.append(current)
|
|
1110
|
+
current = ""
|
|
1111
|
+
step = max_tokens * 4
|
|
1112
|
+
for start in range(0, len(line), step):
|
|
1113
|
+
slice_str = line[start : start + step]
|
|
1114
|
+
chunks.append(slice_str)
|
|
1115
|
+
elif estimate_token_count(current + line) > max_tokens and current:
|
|
1116
|
+
chunks.append(current)
|
|
1117
|
+
current = line
|
|
1118
|
+
else:
|
|
1119
|
+
current += line
|
|
1120
|
+
if current:
|
|
1121
|
+
chunks.append(current)
|
|
1122
|
+
return chunks or [text]
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def _split_giant_file(file_diff, max_part_tokens):
|
|
1126
|
+
"""Splits a single file diff that exceeds max_part_tokens into smaller parts."""
|
|
1127
|
+
fn_match = re.search(r"^diff --git a/(\S+) b/\S+", file_diff, flags=re.MULTILINE)
|
|
1128
|
+
file_name = fn_match.group(1) if fn_match else "file"
|
|
1129
|
+
|
|
1130
|
+
hunk_match = re.search(
|
|
1131
|
+
r"^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@", file_diff, flags=re.MULTILINE
|
|
1132
|
+
)
|
|
1133
|
+
if hunk_match:
|
|
1134
|
+
header = file_diff[: hunk_match.start()]
|
|
1135
|
+
body = file_diff[hunk_match.start() :]
|
|
1136
|
+
else:
|
|
1137
|
+
header = ""
|
|
1138
|
+
body = file_diff
|
|
1139
|
+
|
|
1140
|
+
hunk_parts = re.split(
|
|
1141
|
+
r"(^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@.*)", body, flags=re.MULTILINE
|
|
1142
|
+
)
|
|
1143
|
+
raw_hunks = []
|
|
1144
|
+
if len(hunk_parts) > 1:
|
|
1145
|
+
if hunk_parts[0].strip():
|
|
1146
|
+
raw_hunks.append(hunk_parts[0])
|
|
1147
|
+
for j in range(1, len(hunk_parts), 2):
|
|
1148
|
+
hunk_content = hunk_parts[j] + (
|
|
1149
|
+
hunk_parts[j + 1] if j + 1 < len(hunk_parts) else ""
|
|
1150
|
+
)
|
|
1151
|
+
raw_hunks.append(hunk_content)
|
|
1152
|
+
else:
|
|
1153
|
+
raw_hunks = [body]
|
|
1154
|
+
|
|
1155
|
+
sub_chunks = []
|
|
1156
|
+
current_sub = ""
|
|
1157
|
+
header_clean = header.strip()
|
|
1158
|
+
|
|
1159
|
+
def _format_part(sub_content, idx):
|
|
1160
|
+
prefix = (
|
|
1161
|
+
f"{header_clean}\n# [{file_name} - part {idx}]\n\n"
|
|
1162
|
+
if header_clean
|
|
1163
|
+
else f"# [{file_name} - part {idx}]\n\n"
|
|
1164
|
+
)
|
|
1165
|
+
return prefix + sub_content
|
|
1166
|
+
|
|
1167
|
+
part_idx = 1
|
|
1168
|
+
for hunk in raw_hunks:
|
|
1169
|
+
if estimate_token_count(hunk) > max_part_tokens:
|
|
1170
|
+
if current_sub:
|
|
1171
|
+
sub_chunks.append(_format_part(current_sub, part_idx))
|
|
1172
|
+
part_idx += 1
|
|
1173
|
+
current_sub = ""
|
|
1174
|
+
line_pieces = _split_line_chunk(hunk, max_part_tokens)
|
|
1175
|
+
for piece in line_pieces:
|
|
1176
|
+
sub_chunks.append(_format_part(piece, part_idx))
|
|
1177
|
+
part_idx += 1
|
|
1178
|
+
elif (
|
|
1179
|
+
estimate_token_count(current_sub + hunk) > max_part_tokens
|
|
1180
|
+
and current_sub
|
|
1181
|
+
):
|
|
1182
|
+
sub_chunks.append(_format_part(current_sub, part_idx))
|
|
1183
|
+
part_idx += 1
|
|
1184
|
+
current_sub = hunk
|
|
1185
|
+
else:
|
|
1186
|
+
current_sub += hunk
|
|
1187
|
+
|
|
1188
|
+
if current_sub:
|
|
1189
|
+
sub_chunks.append(_format_part(current_sub, part_idx))
|
|
1190
|
+
|
|
1191
|
+
return sub_chunks or [file_diff]
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def split_diff_into_chunks(diff_text, max_tokens=90000, max_part_tokens=None):
|
|
1195
|
+
"""Splits the diff based on the token limit while preserving file header integrity.
|
|
1196
|
+
|
|
1197
|
+
- max_tokens: Diff size threshold that triggers Map-Reduce (default 90,000).
|
|
1198
|
+
- max_part_tokens: Maximum tokens per batch chunk (default 50,000).
|
|
1199
|
+
"""
|
|
1200
|
+
if max_part_tokens is None:
|
|
1201
|
+
max_part_tokens = 50000
|
|
1202
|
+
if max_part_tokens > max_tokens:
|
|
1203
|
+
max_part_tokens = max_tokens
|
|
1204
|
+
|
|
1100
1205
|
if estimate_token_count(diff_text) <= max_tokens:
|
|
1101
1206
|
return [diff_text]
|
|
1102
1207
|
|
|
1103
|
-
parts = re.split(r"(^diff --git
|
|
1104
|
-
|
|
1105
|
-
|
|
1208
|
+
parts = re.split(r"(^diff --git (?:[abciwos12]/|\S+ ))", diff_text, flags=re.MULTILINE)
|
|
1209
|
+
if len(parts) <= 1:
|
|
1210
|
+
return _split_line_chunk(diff_text, max_part_tokens)
|
|
1106
1211
|
|
|
1212
|
+
file_diffs = []
|
|
1213
|
+
if parts[0].strip():
|
|
1214
|
+
file_diffs.append(parts[0])
|
|
1107
1215
|
for i in range(1, len(parts), 2):
|
|
1108
|
-
|
|
1216
|
+
file_d = parts[i] + (parts[i + 1] if i + 1 < len(parts) else "")
|
|
1217
|
+
file_diffs.append(file_d)
|
|
1109
1218
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1219
|
+
chunks = []
|
|
1220
|
+
current_chunk = ""
|
|
1221
|
+
|
|
1222
|
+
for file_diff in file_diffs:
|
|
1223
|
+
file_tokens = estimate_token_count(file_diff)
|
|
1224
|
+
if file_tokens > max_part_tokens:
|
|
1225
|
+
if current_chunk:
|
|
1226
|
+
chunks.append(current_chunk)
|
|
1227
|
+
current_chunk = ""
|
|
1228
|
+
file_sub_chunks = _split_giant_file(file_diff, max_part_tokens)
|
|
1229
|
+
chunks.extend(file_sub_chunks)
|
|
1230
|
+
elif (
|
|
1231
|
+
estimate_token_count(current_chunk + file_diff) > max_part_tokens
|
|
1112
1232
|
and current_chunk
|
|
1113
1233
|
):
|
|
1114
1234
|
chunks.append(current_chunk)
|
|
@@ -1156,6 +1276,10 @@ def generate_pr_content(
|
|
|
1156
1276
|
)
|
|
1157
1277
|
return None
|
|
1158
1278
|
|
|
1279
|
+
# Defensive Binary hunk guard: collapse any U+FFFD-heavy binary hunks that
|
|
1280
|
+
# slipped past path excludes or were passed as a raw diff by an external caller.
|
|
1281
|
+
diff_text = collapse_binary_hunks(diff_text)
|
|
1282
|
+
|
|
1159
1283
|
t_start = time.perf_counter()
|
|
1160
1284
|
|
|
1161
1285
|
# Cache folder configuration — THE map for the whole project. It used to
|
|
@@ -1361,7 +1485,11 @@ def generate_pr_content(
|
|
|
1361
1485
|
fg="cyan",
|
|
1362
1486
|
)
|
|
1363
1487
|
|
|
1364
|
-
|
|
1488
|
+
from src.config import get_map_reduce_thresholds
|
|
1489
|
+
max_tokens, max_part_tokens = get_map_reduce_thresholds(provider)
|
|
1490
|
+
chunks = split_diff_into_chunks(
|
|
1491
|
+
diff_text, max_tokens=max_tokens, max_part_tokens=max_part_tokens
|
|
1492
|
+
)
|
|
1365
1493
|
total_meta = {
|
|
1366
1494
|
"prompt_tokens": 0,
|
|
1367
1495
|
"completion_tokens": 0,
|
|
@@ -1471,6 +1599,70 @@ def generate_pr_content(
|
|
|
1471
1599
|
)
|
|
1472
1600
|
diff_unificado = "\n\n".join(resumos_parciais)
|
|
1473
1601
|
|
|
1602
|
+
if estimate_token_count(diff_unificado) > max_tokens:
|
|
1603
|
+
click.secho(
|
|
1604
|
+
__("🔄 Large summary set detected! Running intermediate reduction..."),
|
|
1605
|
+
fg="cyan",
|
|
1606
|
+
dim=True,
|
|
1607
|
+
)
|
|
1608
|
+
intermediate_summaries = []
|
|
1609
|
+
curr_batch = []
|
|
1610
|
+
curr_tokens = 0
|
|
1611
|
+
for item in resumos_parciais:
|
|
1612
|
+
item_tok = estimate_token_count(item)
|
|
1613
|
+
if curr_tokens + item_tok > max_part_tokens and curr_batch:
|
|
1614
|
+
batch_str = "\n\n".join(curr_batch)
|
|
1615
|
+
inter_prompt = (
|
|
1616
|
+
__(
|
|
1617
|
+
"Generate ONLY a JSON object in the format {json_format} consolidating these technical summaries into a unified summary:\n",
|
|
1618
|
+
json_format='{"resumo": "..."}',
|
|
1619
|
+
)
|
|
1620
|
+
+ batch_str
|
|
1621
|
+
)
|
|
1622
|
+
inter_res = call_ai_model(
|
|
1623
|
+
provider,
|
|
1624
|
+
api_key,
|
|
1625
|
+
api_model,
|
|
1626
|
+
inter_prompt,
|
|
1627
|
+
instrucao_sistema,
|
|
1628
|
+
quiet=True,
|
|
1629
|
+
action=f"{action_folder}_inter_reduce",
|
|
1630
|
+
)
|
|
1631
|
+
if inter_res and "resumo" in inter_res:
|
|
1632
|
+
intermediate_summaries.append(inter_res["resumo"])
|
|
1633
|
+
_aggregate_meta(inter_res.pop("_telemetry_meta", None))
|
|
1634
|
+
else:
|
|
1635
|
+
intermediate_summaries.append(batch_str)
|
|
1636
|
+
curr_batch = [item]
|
|
1637
|
+
curr_tokens = item_tok
|
|
1638
|
+
else:
|
|
1639
|
+
curr_batch.append(item)
|
|
1640
|
+
curr_tokens += item_tok
|
|
1641
|
+
if curr_batch:
|
|
1642
|
+
batch_str = "\n\n".join(curr_batch)
|
|
1643
|
+
inter_prompt = (
|
|
1644
|
+
__(
|
|
1645
|
+
"Generate ONLY a JSON object in the format {json_format} consolidating these technical summaries into a unified summary:\n",
|
|
1646
|
+
json_format='{"resumo": "..."}',
|
|
1647
|
+
)
|
|
1648
|
+
+ batch_str
|
|
1649
|
+
)
|
|
1650
|
+
inter_res = call_ai_model(
|
|
1651
|
+
provider,
|
|
1652
|
+
api_key,
|
|
1653
|
+
api_model,
|
|
1654
|
+
inter_prompt,
|
|
1655
|
+
instrucao_sistema,
|
|
1656
|
+
quiet=True,
|
|
1657
|
+
action=f"{action_folder}_inter_reduce",
|
|
1658
|
+
)
|
|
1659
|
+
if inter_res and "resumo" in inter_res:
|
|
1660
|
+
intermediate_summaries.append(inter_res["resumo"])
|
|
1661
|
+
_aggregate_meta(inter_res.pop("_telemetry_meta", None))
|
|
1662
|
+
else:
|
|
1663
|
+
intermediate_summaries.append(batch_str)
|
|
1664
|
+
diff_unificado = "\n\n".join(intermediate_summaries)
|
|
1665
|
+
|
|
1474
1666
|
if action_type == "commit":
|
|
1475
1667
|
prompt = (
|
|
1476
1668
|
__(
|
|
@@ -70,10 +70,13 @@ def resolve_scm_provider(config: dict) -> ScmProvider:
|
|
|
70
70
|
def detect_provider_from_remote(remote_url: str, default: Optional[str] = "github") -> Optional[str]:
|
|
71
71
|
"""Guess the forge from a git remote URL (case-insensitive substrings).
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
behavior, while ``default=None`` lets a
|
|
76
|
-
apart from "not recognised at all"
|
|
73
|
+
The named forges are checked first, so a URL that mentions one of them wins
|
|
74
|
+
even when it also contains "github". Anything unknown returns *default*:
|
|
75
|
+
"github" reproduces the historical behavior, while ``default=None`` lets a
|
|
76
|
+
caller tell "recognised as GitHub" apart from "not recognised at all"
|
|
77
|
+
(self-hosted hosts need the latter). For that reason the GitHub check runs
|
|
78
|
+
before the ``None`` return: ``github.com`` is a recognised forge, not an
|
|
79
|
+
unknown host.
|
|
77
80
|
"""
|
|
78
81
|
url = (remote_url or "").lower()
|
|
79
82
|
if "gitlab" in url:
|
|
@@ -82,10 +85,10 @@ def detect_provider_from_remote(remote_url: str, default: Optional[str] = "githu
|
|
|
82
85
|
return "bitbucket"
|
|
83
86
|
if "dev.azure.com" in url or "visualstudio.com" in url:
|
|
84
87
|
return "azure_devops"
|
|
85
|
-
if default is None:
|
|
86
|
-
return None
|
|
87
88
|
if "github" in url:
|
|
88
89
|
return "github"
|
|
90
|
+
if default is None:
|
|
91
|
+
return None
|
|
89
92
|
return default
|
|
90
93
|
|
|
91
94
|
|
src/issue_engine.py
CHANGED
|
@@ -170,7 +170,12 @@ def generate_issue_content(context_text, context_type="diff"):
|
|
|
170
170
|
total_meta["duration_ms"] += new_meta.get("duration_ms", 0)
|
|
171
171
|
|
|
172
172
|
if context_type == "diff":
|
|
173
|
-
|
|
173
|
+
from src.config import get_map_reduce_thresholds
|
|
174
|
+
|
|
175
|
+
max_tokens, max_part_tokens = get_map_reduce_thresholds(provider)
|
|
176
|
+
chunks = split_diff_into_chunks(
|
|
177
|
+
context_text, max_tokens=max_tokens, max_part_tokens=max_part_tokens
|
|
178
|
+
)
|
|
174
179
|
else:
|
|
175
180
|
chunks = [context_text]
|
|
176
181
|
|
src/review/remote_pr.py
CHANGED
|
@@ -342,9 +342,15 @@ def _chunk_count(diff_text):
|
|
|
342
342
|
back, and the callers that need it (the MCP tool especially) have no
|
|
343
343
|
terminal to read the "processing in N batches" line from.
|
|
344
344
|
"""
|
|
345
|
+
from src.config import get_map_reduce_thresholds
|
|
345
346
|
from src.core import split_diff_into_chunks
|
|
346
347
|
|
|
347
348
|
try:
|
|
348
|
-
|
|
349
|
+
max_tokens, max_part_tokens = get_map_reduce_thresholds()
|
|
350
|
+
return len(
|
|
351
|
+
split_diff_into_chunks(
|
|
352
|
+
diff_text, max_tokens=max_tokens, max_part_tokens=max_part_tokens
|
|
353
|
+
)
|
|
354
|
+
)
|
|
349
355
|
except Exception:
|
|
350
356
|
return 1
|
src/scm_settings.py
CHANGED
|
@@ -366,6 +366,13 @@ def _ignored_provider_warning(choice) -> str:
|
|
|
366
366
|
)
|
|
367
367
|
|
|
368
368
|
|
|
369
|
+
def _default_provider_warning() -> str:
|
|
370
|
+
return __(
|
|
371
|
+
"⚠️ The origin remote does not name a forge, so GitPR is using GitHub. "
|
|
372
|
+
"Run 'gitpr --init' to choose the right forge for this repository."
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
369
376
|
def resolve_scm_settings(
|
|
370
377
|
start_dir=None, remote_url=None, config_path=None, warnings=None
|
|
371
378
|
) -> ScmSettings:
|
|
@@ -390,6 +397,11 @@ def resolve_scm_settings(
|
|
|
390
397
|
choice = resolve_provider_choice(project_file.provider, remote)
|
|
391
398
|
if choice.ignored:
|
|
392
399
|
collected.append(_ignored_provider_warning(choice))
|
|
400
|
+
if remote and choice.source == SOURCE_DEFAULT:
|
|
401
|
+
# An existing remote that names no forge: the repository is about to be
|
|
402
|
+
# driven as GitHub unless someone records an override. The absent-remote
|
|
403
|
+
# case has its own error at the call sites.
|
|
404
|
+
collected.append(_default_provider_warning())
|
|
393
405
|
|
|
394
406
|
token = _token_for(choice.key)
|
|
395
407
|
if os.getenv(RAW_TOKEN_KEY):
|
src/ui/config_app.py
CHANGED
src/ui/metrics_app.py
CHANGED
|
@@ -17,7 +17,15 @@ different story about one ledger.
|
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
19
|
from textual.app import App, ComposeResult
|
|
20
|
-
from textual.widgets import
|
|
20
|
+
from textual.widgets import (
|
|
21
|
+
Header,
|
|
22
|
+
Footer,
|
|
23
|
+
Static,
|
|
24
|
+
DataTable,
|
|
25
|
+
ProgressBar,
|
|
26
|
+
TabbedContent,
|
|
27
|
+
TabPane,
|
|
28
|
+
)
|
|
21
29
|
from textual.containers import Vertical, VerticalScroll
|
|
22
30
|
from textual.binding import Binding
|
|
23
31
|
from collections import Counter
|
|
@@ -25,6 +33,11 @@ import os
|
|
|
25
33
|
|
|
26
34
|
from src import ledger
|
|
27
35
|
from src.i18n import __
|
|
36
|
+
from src.ascii_chart import (
|
|
37
|
+
render_vertical_histogram,
|
|
38
|
+
render_token_timeseries,
|
|
39
|
+
render_empty_card,
|
|
40
|
+
)
|
|
28
41
|
from src.metrics import (
|
|
29
42
|
TITLE,
|
|
30
43
|
WARN,
|
|
@@ -119,9 +132,26 @@ class MetricsApp(App):
|
|
|
119
132
|
width: 100%;
|
|
120
133
|
display: none;
|
|
121
134
|
}
|
|
135
|
+
#main_tabs {
|
|
136
|
+
height: 1fr;
|
|
137
|
+
}
|
|
138
|
+
#charts_container {
|
|
139
|
+
height: 1fr;
|
|
140
|
+
margin: 0 2;
|
|
141
|
+
padding: 1 0;
|
|
142
|
+
}
|
|
143
|
+
#latency_chart_panel, #token_chart_panel {
|
|
144
|
+
background: $surface;
|
|
145
|
+
border: solid $accent;
|
|
146
|
+
padding: 1 2;
|
|
147
|
+
margin-bottom: 1;
|
|
148
|
+
height: auto;
|
|
149
|
+
}
|
|
122
150
|
"""
|
|
123
151
|
|
|
124
152
|
BINDINGS = [
|
|
153
|
+
Binding("1", "switch_tab('tab_events')", __("Events"), priority=True),
|
|
154
|
+
Binding("2", "switch_tab('tab_charts')", __("Charts"), priority=True),
|
|
125
155
|
Binding("f5", "refresh", __("Refresh")),
|
|
126
156
|
Binding("escape", "quit", __("Exit")),
|
|
127
157
|
]
|
|
@@ -141,15 +171,20 @@ class MetricsApp(App):
|
|
|
141
171
|
repo_text = self.repo_filter or __("All repositories")
|
|
142
172
|
yield Static(f"\U0001f4c1 {__('Repository')}: {repo_text}", id="repo_label")
|
|
143
173
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
174
|
+
with TabbedContent(id="main_tabs"):
|
|
175
|
+
with TabPane(__("Events"), id="tab_events"):
|
|
176
|
+
yield Static("", id="summary")
|
|
177
|
+
with VerticalScroll(id="sections"):
|
|
178
|
+
yield Static("", id="sections_notice")
|
|
179
|
+
for section_id in SECTION_IDS:
|
|
180
|
+
yield Static("", id=section_id)
|
|
181
|
+
with Vertical(id="table_container"):
|
|
182
|
+
yield DataTable(id="events_table")
|
|
183
|
+
|
|
184
|
+
with TabPane(__("Charts & Trends"), id="tab_charts"):
|
|
185
|
+
with VerticalScroll(id="charts_container"):
|
|
186
|
+
yield Static("", id="latency_chart_panel")
|
|
187
|
+
yield Static("", id="token_chart_panel")
|
|
153
188
|
|
|
154
189
|
yield Static("", id="status_bar")
|
|
155
190
|
|
|
@@ -214,14 +249,14 @@ class MetricsApp(App):
|
|
|
214
249
|
"""Background worker: reads the ledger, or the files while it is absent."""
|
|
215
250
|
if self.using_ledger:
|
|
216
251
|
rows = self._load_from_ledger()
|
|
217
|
-
sections = self._load_sections()
|
|
252
|
+
sections, chart_data = self._load_sections()
|
|
218
253
|
else:
|
|
219
254
|
rows = self._load_from_files()
|
|
220
|
-
sections = self._load_sections(with_ledger=False)
|
|
255
|
+
sections, chart_data = self._load_sections(with_ledger=False)
|
|
221
256
|
|
|
222
|
-
self.call_from_thread(self._finish_scan, rows, sections)
|
|
257
|
+
self.call_from_thread(self._finish_scan, rows, sections, chart_data)
|
|
223
258
|
|
|
224
|
-
def _load_sections(self, with_ledger: bool = True) ->
|
|
259
|
+
def _load_sections(self, with_ledger: bool = True) -> tuple:
|
|
225
260
|
"""The metric sections, through the readers the terminal also uses.
|
|
226
261
|
|
|
227
262
|
Same readers, same lines, only the markup differs: a metric drawn here
|
|
@@ -263,10 +298,16 @@ class MetricsApp(App):
|
|
|
263
298
|
sections = list(zip(SECTION_IDS, readers, renderers))
|
|
264
299
|
if not with_ledger:
|
|
265
300
|
sections = sections[-1:]
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
301
|
+
rendered = []
|
|
302
|
+
chart_data = {}
|
|
303
|
+
for section_id, reader, renderer in sections:
|
|
304
|
+
data = reader(**scope)
|
|
305
|
+
rendered.append((f"#{section_id}", renderer(data)))
|
|
306
|
+
if section_id == "section_latency":
|
|
307
|
+
chart_data["latency"] = data
|
|
308
|
+
elif section_id == "section_token_trend":
|
|
309
|
+
chart_data["token_trend"] = data
|
|
310
|
+
return rendered, chart_data
|
|
270
311
|
|
|
271
312
|
def _load_from_ledger(self) -> list:
|
|
272
313
|
"""Rows in the window, newest first, filtered by repository.
|
|
@@ -334,15 +375,19 @@ class MetricsApp(App):
|
|
|
334
375
|
except Exception:
|
|
335
376
|
pass # Widget may not exist yet or may have been removed
|
|
336
377
|
|
|
337
|
-
def _finish_scan(
|
|
378
|
+
def _finish_scan(
|
|
379
|
+
self, rows: list, sections: list = None, chart_data: dict = None
|
|
380
|
+
) -> None:
|
|
338
381
|
"""Called on the main thread when loading is complete."""
|
|
339
382
|
self.query_one("#loading_overlay").display = False
|
|
340
383
|
self.events = rows
|
|
341
384
|
self._populate_table()
|
|
342
385
|
self._update_summary()
|
|
343
|
-
self._render_sections(sections)
|
|
386
|
+
self._render_sections(sections, chart_data)
|
|
344
387
|
|
|
345
|
-
def _render_sections(
|
|
388
|
+
def _render_sections(
|
|
389
|
+
self, sections: list = None, chart_data: dict = None
|
|
390
|
+
) -> None:
|
|
346
391
|
"""Draws the shared section lines, in Textual markup.
|
|
347
392
|
|
|
348
393
|
While the ledger is absent the four readers that query it have nothing
|
|
@@ -367,6 +412,50 @@ class MetricsApp(App):
|
|
|
367
412
|
if lines:
|
|
368
413
|
widget.update(self._markup(lines))
|
|
369
414
|
|
|
415
|
+
# Render 2D charts in Tab 2
|
|
416
|
+
try:
|
|
417
|
+
latency_widget = self.query_one("#latency_chart_panel", Static)
|
|
418
|
+
token_widget = self.query_one("#token_chart_panel", Static)
|
|
419
|
+
if chart_data is None and self.using_ledger:
|
|
420
|
+
scope = {
|
|
421
|
+
"repo_filter": self.repo_filter or None,
|
|
422
|
+
"since": self.since,
|
|
423
|
+
"until": self.until,
|
|
424
|
+
}
|
|
425
|
+
chart_data = {
|
|
426
|
+
"latency": latency_report(**scope),
|
|
427
|
+
"token_trend": token_trend_report(**scope),
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if self.using_ledger and chart_data:
|
|
431
|
+
latency_widget.update(
|
|
432
|
+
render_vertical_histogram(chart_data.get("latency"))
|
|
433
|
+
)
|
|
434
|
+
token_widget.update(
|
|
435
|
+
render_token_timeseries(chart_data.get("token_trend"))
|
|
436
|
+
)
|
|
437
|
+
else:
|
|
438
|
+
latency_widget.update(
|
|
439
|
+
render_empty_card(
|
|
440
|
+
__("Latency Distribution"),
|
|
441
|
+
__(
|
|
442
|
+
"Cost and latency metrics need the ledger — "
|
|
443
|
+
"run `gitpr metrics migrate` to import the event files."
|
|
444
|
+
),
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
token_widget.update(
|
|
448
|
+
render_empty_card(
|
|
449
|
+
__("Token Trend"),
|
|
450
|
+
__(
|
|
451
|
+
"Token trend metrics need the ledger — "
|
|
452
|
+
"run `gitpr metrics migrate` to import the event files."
|
|
453
|
+
),
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
except Exception:
|
|
457
|
+
pass
|
|
458
|
+
|
|
370
459
|
@staticmethod
|
|
371
460
|
def _markup(lines: list) -> str:
|
|
372
461
|
"""The section lines as Textual markup, one rendering spelled twice.
|
|
@@ -455,6 +544,14 @@ class MetricsApp(App):
|
|
|
455
544
|
# Actions
|
|
456
545
|
# ------------------------------------------------------------------
|
|
457
546
|
|
|
547
|
+
def action_switch_tab(self, tab_id: str) -> None:
|
|
548
|
+
"""Switch active tab in TabbedContent (1 for Events, 2 for Charts)."""
|
|
549
|
+
try:
|
|
550
|
+
tabs = self.query_one("#main_tabs", TabbedContent)
|
|
551
|
+
tabs.active = tab_id
|
|
552
|
+
except Exception:
|
|
553
|
+
pass
|
|
554
|
+
|
|
458
555
|
def action_refresh(self) -> None:
|
|
459
556
|
"""Reload data from its source (F5)."""
|
|
460
557
|
self._start_scan()
|
src/updater.py
CHANGED
|
@@ -9,8 +9,8 @@ from packaging.version import InvalidVersion, Version
|
|
|
9
9
|
# Canonical Single Source of Truth (SSOT) for GitPR package version (PyPI distribution)
|
|
10
10
|
# ── Must stay ABOVE the i18n import: i18n.py lazily imports __lang_version__
|
|
11
11
|
# from here, and pyproject.toml reads __version__ via setuptools attr:.
|
|
12
|
-
__version__ = "1.
|
|
13
|
-
__lang_version__ = "v0.0.
|
|
12
|
+
__version__ = "1.8.0" # GitPR current package version (Canonical SSOT)
|
|
13
|
+
__lang_version__ = "v0.0.43" # Language dictionary version control (asset, independent of package version)
|
|
14
14
|
__scripts_version__ = (
|
|
15
15
|
"v0.0.4" # Git Hook scripts version control (independent from __lang_version__)
|
|
16
16
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|