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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitpr-cli
3
- Version: 1.6.0
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? Install the thin-client extension in [`extensions/vscode/`](https://gitpr.natanfiuza.dev.br/docs/editor-extension), powered by `gitpr-mcp`.
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.6.0.dist-info/licenses/LICENSE,sha256=UQzLC6mwqb-ZNDkBBAqOxC-SMQcEID0gaM6oFcu6u48,27030
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=R1TETnHD_W22s3DBuzh5_XSISkiY6tM3ty9itsDXckU,47036
10
- src/config_schema.py,sha256=SkcjI1vF1FpeFq6WK7c5HJPwHURUeiyhY7MMOgfSoc0,54494
11
- src/core.py,sha256=IGtKsqAPJ3K4DbeMBtsSI7IX2CEmQ1ZGR4cs1Uwo-mQ,98763
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=RmZsQVg6N1vCcpMdv_5FX-cMGgQ3I0vwMN4parQ4zFg,10974
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=3pXgBbxqekvD4LM-I3GgfV4IOa6QJn4x3-DPAThCris,14156
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=VoHhIBzb02wRNNGlxK4AMQlvGnuf6azaPB--m-tTpTk,9560
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=O3bRT6f15AozcldZfiH2LgOQ4g4GqMb0sbEWCIeF0HE,4487
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=9oEEo-v-D08egxwN_EINh5Ju74vow91-CAH1kAb2ql0,13669
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=7-Cwcygft2VQm_zoUr9VDHp2DrXPiQTpCZ4qQwGdRng,94655
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=dkXXb6-JkvBGrsjkvbk0A_sfsCCrcBC6pXiJBsvxCbA,17023
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.6.0.dist-info/METADATA,sha256=2X1Zhfrrpr5qke816yN3qEFunDO6FK1BQy60YYlXTtE,54016
180
- gitpr_cli-1.6.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
181
- gitpr_cli-1.6.0.dist-info/entry_points.txt,sha256=TstGnMUzdOI5qNmpM97EPDBPuMqOZxoo6EoLxHSOMSQ,71
182
- gitpr_cli-1.6.0.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
183
- gitpr_cli-1.6.0.dist-info/RECORD,,
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 split_diff_into_chunks(diff_text, max_tokens=90000):
1099
- """Splits the diff based on the token limit while preserving file header integrity."""
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 a/)", diff_text, flags=re.MULTILINE)
1104
- chunks = []
1105
- current_chunk = ""
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
- file_diff = parts[i] + parts[i + 1] if i + 1 < len(parts) else parts[i]
1216
+ file_d = parts[i] + (parts[i + 1] if i + 1 < len(parts) else "")
1217
+ file_diffs.append(file_d)
1109
1218
 
1110
- if (
1111
- estimate_token_count(current_chunk + file_diff) > max_tokens
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
- chunks = split_diff_into_chunks(diff_text, max_tokens=90000)
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
- Order matters only for URLs that match several hosts — none exist today.
74
- Anything unknown returns *default*: "github" reproduces the historical
75
- behavior, while ``default=None`` lets a caller tell "recognised as GitHub"
76
- apart from "not recognised at all" (self-hosted hosts need the latter).
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
- chunks = split_diff_into_chunks(context_text, max_tokens=90000)
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
- return len(split_diff_into_chunks(diff_text, max_tokens=90000))
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
@@ -79,6 +79,7 @@ from src.config_schema import (
79
79
  KIND_BOOL,
80
80
  KIND_BRANCH,
81
81
  KIND_ENUM,
82
+ KIND_FLOAT,
82
83
  KIND_SECRET,
83
84
  KIND_STR,
84
85
  KIND_VERSION,
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 Header, Footer, Static, DataTable, ProgressBar
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
- yield Static("", id="summary")
145
-
146
- with VerticalScroll(id="sections"):
147
- yield Static("", id="sections_notice")
148
- for section_id in SECTION_IDS:
149
- yield Static("", id=section_id)
150
-
151
- with Vertical(id="table_container"):
152
- yield DataTable(id="events_table")
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) -> list:
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
- return [
267
- (f"#{section_id}", renderer(reader(**scope)))
268
- for section_id, reader, renderer in sections
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(self, rows: list, sections: list = None) -> None:
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(self, sections: list = None) -> None:
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.6.0" # GitPR current package version (Canonical SSOT)
13
- __lang_version__ = "v0.0.41" # Language dictionary version control (asset, independent of package version)
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
  )