gitpr-cli 1.1.0__py3-none-any.whl → 1.2.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.
Files changed (39) hide show
  1. {gitpr_cli-1.1.0.dist-info → gitpr_cli-1.2.0.dist-info}/METADATA +9 -5
  2. gitpr_cli-1.2.0.dist-info/RECORD +67 -0
  3. src/cache.py +63 -2
  4. src/config.py +52 -0
  5. src/config_schema.py +60 -0
  6. src/core.py +49 -4
  7. src/diff_parser.py +162 -0
  8. src/fix/__init__.py +16 -0
  9. src/fix/apply_fix.py +479 -0
  10. src/fix/fix_history.py +153 -0
  11. src/fix/patch_applier.py +172 -0
  12. src/fix/patch_extractor.py +61 -0
  13. src/fix/patch_provenance.py +96 -0
  14. src/fix/patch_safety_classifier.py +101 -0
  15. src/fix/rollback_fix.py +69 -0
  16. src/infrastructure/scm/azure_devops_provider.py +13 -0
  17. src/infrastructure/scm/base.py +29 -1
  18. src/infrastructure/scm/bitbucket_provider.py +9 -0
  19. src/infrastructure/scm/github_provider.py +77 -4
  20. src/infrastructure/scm/gitlab_provider.py +50 -3
  21. src/linter_engine.py +11 -2
  22. src/main.py +469 -56
  23. src/mcp_server.py +263 -0
  24. src/review/__init__.py +13 -0
  25. src/review/diff_normalizer.py +84 -0
  26. src/review/diff_source.py +69 -0
  27. src/review/remote_pr.py +319 -0
  28. src/review/render.py +75 -0
  29. src/reviewer_resolution.py +159 -0
  30. src/reviewer_suggestion.py +19 -3
  31. src/suggest_reviewers.py +19 -12
  32. src/ui/chat_app.py +3 -31
  33. src/ui/pr_publish_app.py +222 -38
  34. src/updater.py +2 -2
  35. gitpr_cli-1.1.0.dist-info/RECORD +0 -53
  36. {gitpr_cli-1.1.0.dist-info → gitpr_cli-1.2.0.dist-info}/WHEEL +0 -0
  37. {gitpr_cli-1.1.0.dist-info → gitpr_cli-1.2.0.dist-info}/entry_points.txt +0 -0
  38. {gitpr_cli-1.1.0.dist-info → gitpr_cli-1.2.0.dist-info}/licenses/LICENSE +0 -0
  39. {gitpr_cli-1.1.0.dist-info → gitpr_cli-1.2.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.1.0
3
+ Version: 1.2.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
@@ -148,11 +148,11 @@ You can pass the following *flags* for specific actions:
148
148
  * `--status`: Lists uncommitted file changes categorized as **new**, **modified**, and **deleted** — fast, no AI, no network. 📖 [Full docs](https://github.com/gitpr-cli/gitpr.git/blob/main/docs/git-status.md)
149
149
  * `--no-unstaged-check`: Skips the unstaged files verification before AI processing for a single invocation. Equivalent to `GITPR_SKIP_UNSTAGED_CHECK=true` for one run. 📖 [Full docs](https://github.com/gitpr-cli/gitpr.git/blob/main/docs/git-status.md)
150
150
  * `--linter-setup`: **Interactive external linter wizard.** Guides you through installing and configuring external linters (ESLint, PHPCS, Stylelint) as a Checkstyle XML bridge. 📖 [Full docs](https://github.com/gitpr-cli/gitpr.git/blob/main/docs/linter-regras-customizadas.md)
151
- * `--mcp`: Starts GitPR as an **MCP server** (Model Context Protocol) on stdio transport. Enables integration with VS Code, Cursor, Claude Desktop, and other MCP-compatible editors — exposing all GitPR AI capabilities as 12 annotated tools, 15 resources, and 7 pre-built prompts directly inside your IDE. Also available as the standalone `gitpr-mcp` command.
151
+ * `--mcp`: Starts GitPR as an **MCP server** (Model Context Protocol) on stdio transport. Enables integration with VS Code, Cursor, Claude Desktop, and other MCP-compatible editors — exposing all GitPR AI capabilities as 14 annotated tools, 18 resources, and 7 pre-built prompts directly inside your IDE. Also available as the standalone `gitpr-mcp` command.
152
152
  * `--plugins`: Lists all **globally installed plugins** — custom linter packs from `~/.gitpr/plugins/linter/` and MCP prompt templates from `~/.gitpr/plugins/prompts/`. These plugins apply across all your projects without duplication. 📖 [Full docs](https://github.com/gitpr-cli/gitpr.git/blob/main/docs/plugins-system.md)
153
153
  * `--install`: **Interactive Setup Wizard.** Runs a guided 4-step setup: downloads skill templates, installs Git hooks, configures MCP for detected editors, and checks/requests your AI provider API key. Each step asks for confirmation before proceeding.
154
154
  * `-ih` or `--installhooks`: Automatically installs **local Git Hooks** (`pre-commit` and `prepare-commit-msg`) in your repository.
155
- * `-s` or `--skill`: Creates the AI context template files (`.gitpr.commit.md`, `.gitpr.pr.md`, `.gitpr.review.md`, `.gitpr.filereview.md`, `.gitpr.issue.md`, `.gitpr.blame.md`) and the Linter (`.gitpr.linter.yml`) at the project root.
155
+ * `-s` or `--skill`: Creates the AI context template files (`.gitpr.commit.md`, `.gitpr.pr.md`, `.gitpr.review.md`, `.gitpr.filereview.md`, `.gitpr.issue.md`, `.gitpr.blame.md`) and the Linter (`.gitpr.linter.yml`) inside `.gitpr/skill/`.
156
156
  * `-is` or `--issue`: Automatically generates a draft of a **standardized Issue** and opens an interactive interface (TUI) for editing or direct submission via REST API. This feature has **3 context engines** depending on the command combination:
157
157
  * **New Code Issue (`gitpr -is`):** Reads the current `git diff`. **Why use:** Ideal for quickly documenting the task you just finished programming, before committing.
158
158
  * **Epic/Release Issue (`gitpr -is -ht`):** Reads the full history of the current branch (Git Log + PR Cache). **Why use:** Ideal for generating consolidated documentation of an entire release or a large *feature* that took several days/commits to complete.
@@ -229,7 +229,7 @@ You can dynamically switch models by configuring the `GEMINI_API_MODEL_PRIMARY`
229
229
 
230
230
  ## 🎯 Customizable "Skills" System (Prompt Engineering)
231
231
 
232
- Instead of hiding AI instructions in the source code, GitPR uses local Markdown files that act as *System Instructions*. When running `gitpr -s`, the following files are generated at the root of your project to customize the AI's "persona" according to your company's business rules:
232
+ Instead of hiding AI instructions in the source code, GitPR uses local Markdown files that act as *System Instructions*. When running `gitpr -s`, the following files are generated inside `.gitpr/skill/` to customize the AI's "persona" according to your company's business rules:
233
233
 
234
234
  * `.gitpr.commit.md`: Rules for generating short commit messages.
235
235
  * `.gitpr.pr.md`: Required topic structure for the Pull Request description.
@@ -335,6 +335,8 @@ Once configured, use natural language in your editor's AI chat:
335
335
  | `generate_issue` | Structured issue from diff, history, or blame |
336
336
  | `list_unstaged_files` | Uncommitted file changes categorized (new/modified/deleted) |
337
337
  | `analyze_unstaged_diff` | Unstaged diff only (working tree vs index) |
338
+ | `list_fix_candidates` | Fix candidates of the last review: patch, classification, id (read-only) |
339
+ | `review_remote_pr` | AI review of a pull request already open on the forge, fetched by number (read-only) |
338
340
 
339
341
  ### Direct CLI Invocation
340
342
 
@@ -430,12 +432,14 @@ If you want to implement GitPR as an automated quality barrier in your team, che
430
432
 
431
433
  * [**Pull Request (Default Mode)**](https://github.com/gitpr-cli/gitpr.git/blob/main/docs/pr-descricao-padrao.md) — Complete flow for generating PR descriptions without flags.
432
434
  * [**Pull Request Publisher TUI**](https://github.com/gitpr-cli/gitpr.git/blob/main/docs/pull-request-publication.md) — How to review and publish Pull Requests directly to GitHub from the terminal.
433
- * [**AI Code Review**](https://github.com/gitpr-cli/gitpr.git/blob/main/docs/code-review-ia.md) — Guide to review modes (`--review`, `--fullreview`) and file auditing (`--input`).
435
+ * [**AI Code Review**](https://github.com/gitpr-cli/gitpr.git/blob/main/docs/code-review-ia.md) — Guide to review modes (`--review`, `--fullreview`), file auditing (`--input`) and remote pull request review (`gitpr review-pr`).
436
+ * [**Remote Pull Request Review (gitpr review-pr)**](https://github.com/gitpr-cli/gitpr.git/blob/main/docs/review-pr.md) — How the `gitpr review-pr` subcommand reviews a pull request that is already open on the forge, fetching its diff from the API without checking out its branch.
434
437
  * [**AI Commit Messages**](https://github.com/gitpr-cli/gitpr.git/blob/main/docs/commit-message-ia.md) — How to generate messages in the Conventional Commits standard and integrate with Git Hooks.
435
438
  * [**Issue Generation and TUI Interface**](https://github.com/gitpr-cli/gitpr.git/blob/main/docs/issue-tui-help.md) — How to use the terminal graphical interface (TUI) and the 3 context engines to manage structured Issues.
436
439
  * [**Code Archaeologist (Git Blame)**](https://github.com/gitpr-cli/gitpr.git/blob/main/docs/blame-arqueologo.md) — How to trace the origin of business rules with `git blame` and AI.
437
440
  * [**Skills and Templates System**](https://github.com/gitpr-cli/gitpr.git/blob/main/docs/skill-template.md) — How to customize AI behavior with `.gitpr.*.md` files.
438
441
  * [**Release Notes & Changelog (gitpr release)**](https://github.com/gitpr-cli/gitpr.git/blob/main/docs/release-notes.md) — How the `gitpr release` subcommand generates the changelog / release notes of a repository, suggests the next semantic version and publishes releases on the forge.
442
+ * [**Fix Command (gitpr fix)**](https://github.com/gitpr-cli/gitpr.git/blob/main/docs/fix-command.md) — How the `gitpr fix` subcommand turns the findings of the last review into patches you read before they touch your tree, classifies each one by safety, and undoes an applied patch on demand.
439
443
 
440
444
  ### Configuration & Infrastructure
441
445
 
@@ -0,0 +1,67 @@
1
+ gitpr_cli-1.2.0.dist-info/licenses/LICENSE,sha256=UQzLC6mwqb-ZNDkBBAqOxC-SMQcEID0gaM6oFcu6u48,27030
2
+ src/__init__.py,sha256=WZW9x8P5CYnru29UiarFa8q1nQzuwYWyIodSOlruCWI,29
3
+ src/ai_providers.py,sha256=QyFEOsXyA59lZziO5bl-4nlYFJpxBcxU9aLhcTBnkWE,14633
4
+ src/blame_engine.py,sha256=Zov1hxrt7Dcmf56ovJKz3QmHIfzDKtSub2iItXeg9-A,17104
5
+ src/cache.py,sha256=GC2ZT0ThAtH22aRRZapIeauATv_nqBfiRsXjqeMhAiI,6368
6
+ src/changelog_builder.py,sha256=6tqJVHpY4Ta5shOSNWDEo_eHlKmDrU2dUSzS0wyYe10,7116
7
+ src/chat_memory.py,sha256=PcohnyspAfIwwwbsp9U2Wk_1AylJMFEMoWqRSJe_w30,8949
8
+ src/commit_classifier.py,sha256=8_LHQEzisWflV5ZsK31P3fwyu2QWacv8cdFErXYe030,4668
9
+ src/config.py,sha256=9945b0sTM5xL4a81M-CyJ6qfnAQ-Vkf3vr9Old79bjw,33985
10
+ src/config_schema.py,sha256=a7fSsp39h9Pe97uMrUTncuLjGzB8mWvF77vYhJZIcmw,35283
11
+ src/core.py,sha256=zWrz86ifjly5DqoqfQvqdQ0up6yyqFlDYYdB8t8cnyU,76310
12
+ src/diff_parser.py,sha256=62X69ves4tlzNgVKXNsfWdOMwqn2h16RkUOPqszMU7k,11252
13
+ src/doc_links.py,sha256=DuovU-RF9kjbisBdEoGAsuavQ2y0MYDhqy6Sz6vbrGI,1157
14
+ src/github_api.py,sha256=ILm7e03UP80swc3XuJvIJJvhLp81-V_3kQQ1EuAcN8Q,3836
15
+ src/i18n.py,sha256=8s-Bsvp-p3ZZhj0FvDNTRqfWsEio5v4VvXmS1NSqNW8,4578
16
+ src/issue_engine.py,sha256=XwW9C5uX84eOWWZMerm0DvE8LpLRcjhrLsXtt7yftak,10872
17
+ src/linter_engine.py,sha256=8SIs_FUtxEBeTMKWuqatqzlyyToYIjG5xeg0X7FYRAA,12683
18
+ src/linter_wizard.py,sha256=7J_nQFghVwCaKG6QI3K45KcOaxHSH1oTCAvP_6tnCQM,7424
19
+ src/main.py,sha256=B9X4Yr1QZxCioL2dvXOkJ7m37DzOR6DB2E6unLBqHuI,98535
20
+ src/mcp_server.py,sha256=q2clMxTtjLLC-P6Vh4XMq0xylnoUw-K2IFQvgvHIf7o,90479
21
+ src/metrics.py,sha256=VC68t8xGUBFry-ANvEb_TjaRkboPi37__GxK0eHZOe4,21250
22
+ src/net.py,sha256=f0MOkXyIxi4HVetsybtWpV_yHjiC17T2JMWySBdoV2I,2774
23
+ src/release_engine.py,sha256=gwTnOq0ESK26BqsW1RLlysGwL0ZS5guj1qG2V-yUyWI,21610
24
+ src/reviewer_resolution.py,sha256=lZ3csSJHN6Y5TJ3U3p9uuXikOgG7fH4qqx7X9-4F7pc,5754
25
+ src/reviewer_suggestion.py,sha256=jk9FAyp7WkkJbi-yepz7LDF1_tSHxJEEjcajMKF5HdY,8501
26
+ src/security.py,sha256=fkWy-r4tdISH0xOyWZyR8ZCFyLuYqu8fn9sKsIeNAso,1351
27
+ src/spinner.py,sha256=63pEotiDa3TVwBr-sn0MJoDPQ_uWJTTwINXieJIDDKc,8964
28
+ src/suggest_reviewers.py,sha256=-wRe0HYksx4FWQZ-eDvU9Ghdk5gqtEt457KQP7sE31Y,5181
29
+ src/tui_issue.py,sha256=WEFxT4kzWQ_KbSDsOnS_iuYXasZySB-68jryGNm0peg,10331
30
+ src/updater.py,sha256=hm4brG_lSPe82LWFVb41iVMOBYUI824svwsfzMfxxUQ,4771
31
+ src/usage_log.py,sha256=314sTT5Gl_uOXqWSIi-V42bnTHuMSYAZ6G6-_15uEGQ,5968
32
+ src/version_bump.py,sha256=OjqJe9OL1-1dT5od7-SgU5ZN_dTtR_aKX_oSjSY64YQ,2954
33
+ src/fix/__init__.py,sha256=YcN1nGpzWyXczd35LWEXdFpviKKsb9JS8e4bD6KcVcQ,935
34
+ src/fix/apply_fix.py,sha256=yMinMW50Fw62V-k7hRwN1lsikCJbz6xV5iX4IH0sdxY,17912
35
+ src/fix/fix_history.py,sha256=pHXHpt-SstitbLDmlPloDVKlH79FUD8hqKjVK5xKLMA,5626
36
+ src/fix/patch_applier.py,sha256=ahruhdKOhrt2excc3nJljoWi4_0pUj26owSVAQWeeKQ,6258
37
+ src/fix/patch_extractor.py,sha256=gEiwSvxGNHM74TnubTN22NBpbXDwvNpB2vMqgpF7hQQ,2514
38
+ src/fix/patch_provenance.py,sha256=sF934ntcZjJCTzzhtuR2Pi-vMUdXFElKcuw5riwPnUk,3195
39
+ src/fix/patch_safety_classifier.py,sha256=StZvh_0Y-ZCdf_LwFoVAdfhpi0b7v1TOxTRoG86_63M,4121
40
+ src/fix/rollback_fix.py,sha256=rMBYewuvlhJOFdo-Ke_SPfu1O-yd6NJzEYiO5o-LcKk,2501
41
+ src/infrastructure/__init__.py,sha256=zTIcY3cscsdkXogSGXoJbH0mVGFbKYTRK32fg26wr9U,81
42
+ src/infrastructure/scm/__init__.py,sha256=3_4CmrZPZSmDbFwf_0YBeKc935jWV8oAHaZtC_i-Fak,1550
43
+ src/infrastructure/scm/azure_devops_provider.py,sha256=ezRS_O2450mfYEfqFOD4UBSVFhT3aJdx3DEVkZcJjac,14717
44
+ src/infrastructure/scm/base.py,sha256=L-7NYG2hqIWKzKCsQ5NurtgJJvvYvjTVOs0NJ2Aqx_M,9536
45
+ src/infrastructure/scm/bitbucket_provider.py,sha256=jv62MlOd0Iiu3VYAHUffJO2D95TE4k2lmukQL93bOd8,12105
46
+ src/infrastructure/scm/factory.py,sha256=Qeearq8eWnF_4boI68rVBkoGEiS-xRGD4V-O_FF0204,3856
47
+ src/infrastructure/scm/github_provider.py,sha256=9RXRAocYEVWS6NR2PsOg7wtqiF8989x1BxAqgBSoLOI,16438
48
+ src/infrastructure/scm/gitlab_provider.py,sha256=mznhtBhXtc4zRut_Jb0pM57RIXViNYYiKiM7VKDFhl8,13881
49
+ src/review/__init__.py,sha256=XVyQZFf6UAqIKi8Gb1pQ4dKjcrC50soUvNuKu1DBspc,687
50
+ src/review/diff_normalizer.py,sha256=PRXQ7b81EBH1nx_8_Tm9G7Kjdax_6hIVtFgEixicT0E,3551
51
+ src/review/diff_source.py,sha256=O8bIlff0Zr7wPMzCKAVk_LF2pMDr3sc9KAuyA3OvHG0,2664
52
+ src/review/remote_pr.py,sha256=maPCQO5p7u9-_PA0FEBNgimfA6na2BdQGBPmqNj1_0c,11817
53
+ src/review/render.py,sha256=Hnkz0PvtYIaaikTS1inTzrneAJL4Xqjd-F8Lr6Yh6mQ,2691
54
+ src/ui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
55
+ src/ui/chat_app.py,sha256=6qpjWxHECYbKc5PTfbr63FjGo0j5-gXf3X-qW1klWBg,25396
56
+ src/ui/config_app.py,sha256=xgppZbEBFrFijgLGo1b1PN1vTGCg8xpI8gpMYBv2KzU,73417
57
+ src/ui/help_screen.py,sha256=EvbP8kFZ6Sdehi7bJ6Wo_YcTTk5MTkgneeidWwefYrA,1890
58
+ src/ui/issue_app.py,sha256=eINMmamloeopbtwyS1rCPwosaHeZW5oLebnEV4TfamY,9281
59
+ src/ui/linter_app.py,sha256=Y5vwU7D5PQIR_HaY6t40jZy7Guc8z2w-auyosCQqoro,1566
60
+ src/ui/metrics_app.py,sha256=a3jsB_kjMusSL57A6LpJyb9LFN7JJWlOdUNClklwq-c,16016
61
+ src/ui/pr_publish_app.py,sha256=cZ9xr1TSZF6fjvQPlAdSjzSNykufHF1l7hsmo3Ib2j0,67311
62
+ src/ui/pr_publish_help.py,sha256=fBiUtD97ngxbSfAvPVGGXU19r5kbALCKyi4QeKqyQ2c,1891
63
+ gitpr_cli-1.2.0.dist-info/METADATA,sha256=1BSAiMJtMgY7j3YsU0YgaHw7rpfk2ccj5z2iOzSjyxk,34689
64
+ gitpr_cli-1.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
65
+ gitpr_cli-1.2.0.dist-info/entry_points.txt,sha256=TstGnMUzdOI5qNmpM97EPDBPuMqOZxoo6EoLxHSOMSQ,71
66
+ gitpr_cli-1.2.0.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
67
+ gitpr_cli-1.2.0.dist-info/RECORD,,
src/cache.py CHANGED
@@ -47,9 +47,22 @@ def get_cached_response(action_folder, prompt_text):
47
47
 
48
48
 
49
49
  def save_cached_response(
50
- action_folder, action_type, prompt_text, response_dict, meta_raw=None
50
+ action_folder,
51
+ action_type,
52
+ prompt_text,
53
+ response_dict,
54
+ meta_raw=None,
55
+ reviewed_diff=None,
51
56
  ):
52
- """Saves the AI response to the local cache."""
57
+ """Saves the AI response to the local cache.
58
+
59
+ ``reviewed_diff`` is the diff the review actually ran on. When given it is
60
+ stored at the top level of the record, so ``gitpr fix`` can re-derive its
61
+ patch from the revision that was reviewed instead of whatever the working
62
+ tree happens to hold later — the two differ whenever the review came from a
63
+ pull request (src/review/remote_pr.py) or from a full-branch diff. Records
64
+ written before this field existed simply lack it, and the reader falls back.
65
+ """
53
66
  md5_hash = generate_md5(prompt_text)
54
67
  folder_path = get_cache_base_dir() / action_folder
55
68
  folder_path.mkdir(parents=True, exist_ok=True)
@@ -76,6 +89,9 @@ def save_cached_response(
76
89
  "response": response_dict,
77
90
  }
78
91
 
92
+ if reviewed_diff is not None:
93
+ cache_data["diff"] = reviewed_diff
94
+
79
95
  try:
80
96
  with open(cache_file, "w", encoding="utf-8") as f:
81
97
  json.dump(cache_data, f, indent=2, ensure_ascii=False)
@@ -83,6 +99,51 @@ def save_cached_response(
83
99
  pass # Silent cache failure to avoid crashing the tool
84
100
 
85
101
 
102
+ # Review action types whose prompt is a branch diff. `filereview` is left out
103
+ # on purpose: it audits a single file and has no diff to re-derive.
104
+ REVIEW_ACTION_TYPES = ("review", "fullreview")
105
+
106
+
107
+ def resolve_last_review(repo_name, branch_name):
108
+ """The most recent cached review for *repo_name* and *branch_name*, or None.
109
+
110
+ All three review modes write into the same ``review/`` folder (see
111
+ ``core.generate_pr_content``), so ``action_type`` is what tells them apart —
112
+ without that filter a file audit would be mistaken for a branch review.
113
+
114
+ Returns the cache record itself: the caller reads
115
+ ``record["response"]["review"]`` for the text and ``record["action_type"]``
116
+ to know which diff produced it, so the patch can be re-derived against
117
+ today's tree rather than the tree of the day the review ran.
118
+ """
119
+ review_folder = get_cache_base_dir() / "review"
120
+ if not review_folder.exists():
121
+ return None
122
+
123
+ newest = None
124
+ for cache_file in review_folder.glob("*.json"):
125
+ try:
126
+ with open(cache_file, "r", encoding="utf-8", errors="replace") as f:
127
+ data = json.load(f)
128
+ except (json.JSONDecodeError, IOError):
129
+ continue
130
+
131
+ if not isinstance(data, dict):
132
+ continue
133
+ if data.get("repo") != repo_name or data.get("branch") != branch_name:
134
+ continue
135
+ if data.get("action_type") not in REVIEW_ACTION_TYPES:
136
+ continue
137
+ if not (data.get("response") or {}).get("review"):
138
+ continue
139
+
140
+ # Timestamps are '%Y-%m-%d %H:%M:%S', so string order is date order.
141
+ if newest is None or data.get("datetime", "") > newest.get("datetime", ""):
142
+ newest = data
143
+
144
+ return newest
145
+
146
+
86
147
  def get_cached_pr_descriptions(repo_name, branch_name):
87
148
  """Searches the cache for all historically generated PRs for this repository and branch."""
88
149
  from src.i18n import __
src/config.py CHANGED
@@ -61,6 +61,16 @@ DEFAULT_CONFIG = {
61
61
  "GITPR_RELEASE_AI_SUMMARY": "true",
62
62
  "GITPR_RELEASE_AUTO_BUMP": "true",
63
63
  "GITPR_RELEASE_PUBLISH_DRAFT_BY_DEFAULT": "true",
64
+ # Fix feature (gitpr fix). The excluded paths are glob patterns a patch may
65
+ # not touch and still count as safe; they are about *risk*, not diff noise,
66
+ # which is why they are not shared with the smart-excludes list.
67
+ "GITPR_FIX_SAFE_MAX_LINES_CHANGED": "5",
68
+ "GITPR_FIX_SAFE_EXCLUDED_PATHS": (
69
+ "database/migrations/**;**/*.ci.yml;docker/**;terraform/**;.github/workflows/**"
70
+ ),
71
+ "GITPR_FIX_REQUIRE_CONFIRMATION": "true",
72
+ "GITPR_FIX_CREATE_BRANCH_ON_ALL_SAFE": "true",
73
+ "GITPR_FIX_BRANCH_NAME_TEMPLATE": "fix/gitpr-{datetime}",
64
74
  }
65
75
 
66
76
  # Fallbacks used when the .env value is missing or not a positive number.
@@ -131,6 +141,7 @@ SKILL_FILES_BY_TYPE = {
131
141
  "issue": ".gitpr.issue.md",
132
142
  "blame": ".gitpr.blame.md",
133
143
  "release": ".gitpr.release.md",
144
+ "fix": ".gitpr.fix.md",
134
145
  }
135
146
  SKILL_TYPES = tuple(SKILL_FILES_BY_TYPE)
136
147
 
@@ -680,6 +691,47 @@ def get_release_settings():
680
691
  }
681
692
 
682
693
 
694
+ def get_fix_settings():
695
+ """Returns the gitpr fix configuration as a flat dict.
696
+
697
+ ``safe_excluded_paths`` is the ; separated list of glob patterns a patch may
698
+ not touch and still be classified safe; an empty value falls back to the
699
+ built-in list, because clearing the field by accident must not silently drop
700
+ the protection. ``branch_name_template`` names the branch of a batch with
701
+ the vocabulary the output filenames already use ({branch} and {datetime}),
702
+ so there is one placeholder syntax to learn.
703
+ """
704
+ load_dotenv(ENV_FILE)
705
+ try:
706
+ max_lines = int(
707
+ (os.getenv("GITPR_FIX_SAFE_MAX_LINES_CHANGED") or "").strip() or 5
708
+ )
709
+ if max_lines <= 0:
710
+ raise ValueError
711
+ except ValueError:
712
+ max_lines = 5
713
+
714
+ # The fallback is spelled out, like every other getter here, instead of
715
+ # looked up in DEFAULT_CONFIG.
716
+ raw_paths = os.getenv("GITPR_FIX_SAFE_EXCLUDED_PATHS") or (
717
+ "database/migrations/**;**/*.ci.yml;docker/**;terraform/**;.github/workflows/**"
718
+ )
719
+ excluded_paths = tuple(
720
+ item.strip() for item in raw_paths.split(";") if item.strip()
721
+ )
722
+
723
+ template = (os.getenv("GITPR_FIX_BRANCH_NAME_TEMPLATE") or "").strip()
724
+ return {
725
+ "safe_max_lines_changed": max_lines,
726
+ "safe_excluded_paths": excluded_paths,
727
+ "require_confirmation": _env_bool_default_true("GITPR_FIX_REQUIRE_CONFIRMATION"),
728
+ "create_branch_on_all_safe": _env_bool_default_true(
729
+ "GITPR_FIX_CREATE_BRANCH_ON_ALL_SAFE"
730
+ ),
731
+ "branch_name_template": template or "fix/gitpr-{datetime}",
732
+ }
733
+
734
+
683
735
  def validate_github_token(token):
684
736
  """
685
737
  Validates a GitHub PAT by making a lightweight API call to /user.
src/config_schema.py CHANGED
@@ -180,6 +180,12 @@ CATEGORIES = (
180
180
  __("Changelog generation and release publishing."),
181
181
  doc="release-notes.md",
182
182
  ),
183
+ Category(
184
+ "fix",
185
+ __("Fix"),
186
+ __("Review findings turned into patches, and the safety rules of a batch."),
187
+ doc="fix-command.md",
188
+ ),
183
189
  Category(
184
190
  "scm",
185
191
  __("SCM / Forge"),
@@ -221,6 +227,7 @@ SKILL_LABELS = {
221
227
  "issue": __("Issue"),
222
228
  "blame": __("Blame"),
223
229
  "release": __("Release"),
230
+ "fix": __("Fix"),
224
231
  }
225
232
 
226
233
  # Sub-headers shown inside a category. Generic words go through __(); forge and
@@ -614,6 +621,59 @@ FIELDS = (
614
621
  kind=KIND_TEMPLATE,
615
622
  default="{branch}_{datetime}_RELEASE.md",
616
623
  ),
624
+ # --------------------------------------------------------------------- Fix
625
+ ConfigField(
626
+ key="GITPR_FIX_SAFE_MAX_LINES_CHANGED",
627
+ label=__("Safe Patch Size"),
628
+ description=__(
629
+ "Maximum number of added plus removed lines a patch may have and still be classified as safe."
630
+ ),
631
+ category="fix",
632
+ kind=KIND_INT,
633
+ default="5",
634
+ ),
635
+ ConfigField(
636
+ key="GITPR_FIX_SAFE_EXCLUDED_PATHS",
637
+ label=__("Sensitive Paths"),
638
+ description=__(
639
+ "Semicolon separated glob patterns a patch may not touch and still be safe (migrations, workflows, containers, infrastructure)."
640
+ ),
641
+ category="fix",
642
+ kind=KIND_STR,
643
+ default=(
644
+ "database/migrations/**;**/*.ci.yml;docker/**;terraform/**;.github/workflows/**"
645
+ ),
646
+ ),
647
+ ConfigField(
648
+ key="GITPR_FIX_REQUIRE_CONFIRMATION",
649
+ label=__("Ask Before Applying"),
650
+ description=__(
651
+ "Shows the diff and asks for confirmation before a patch is written to the working tree."
652
+ ),
653
+ category="fix",
654
+ kind=KIND_BOOL,
655
+ default="true",
656
+ ),
657
+ ConfigField(
658
+ key="GITPR_FIX_CREATE_BRANCH_ON_ALL_SAFE",
659
+ label=__("Branch for a Batch"),
660
+ description=__(
661
+ "Creates a new branch when the safe patches are applied in a batch, so the current branch stays untouched."
662
+ ),
663
+ category="fix",
664
+ kind=KIND_BOOL,
665
+ default="true",
666
+ ),
667
+ ConfigField(
668
+ key="GITPR_FIX_BRANCH_NAME_TEMPLATE",
669
+ label=__("Branch Name"),
670
+ description=__(
671
+ "Name of the branch created for a batch. Placeholders: branch and datetime."
672
+ ),
673
+ category="fix",
674
+ kind=KIND_TEMPLATE,
675
+ default="fix/gitpr-{datetime}",
676
+ ),
617
677
  # --------------------------------------------------------------- SCM/Forge
618
678
  # The common settings stay ungrouped and always visible; the fields that
619
679
  # only exist for one forge live under its sub-header and follow the
src/core.py CHANGED
@@ -330,6 +330,23 @@ def _load_docs_smart_excludes():
330
330
  SMART_EXCLUDES = _load_smart_excludes() + _load_docs_smart_excludes()
331
331
 
332
332
 
333
+ def get_smart_exclude_patterns():
334
+ """
335
+ Return the raw glob patterns behind SMART_EXCLUDES, without the prefix.
336
+
337
+ SMART_EXCLUDES is a list of git pathspecs (":(exclude)*.lock") because that
338
+ is what `git diff` takes on its command line. A diff fetched from a forge
339
+ API never passes through git, so the same exclusions have to be applied in
340
+ Python — this exposes the patterns both paths share, derived from the very
341
+ list the git command uses so the two can never drift apart.
342
+
343
+ GITPR_SKIP_SMART_EXCLUDES is honored upstream in the loaders, so an empty
344
+ SMART_EXCLUDES already yields an empty list here.
345
+ """
346
+ prefix = ":(exclude)"
347
+ return [p[len(prefix):] for p in SMART_EXCLUDES if p.startswith(prefix)]
348
+
349
+
333
350
  def _get_raw_docs_patterns():
334
351
  """
335
352
  Returns raw glob patterns for documentation extensions (e.g. ['*.md', '*.txt']).
@@ -742,8 +759,30 @@ def split_diff_into_chunks(diff_text, max_tokens=90000):
742
759
  return chunks
743
760
 
744
761
 
745
- def generate_pr_content(action_folder, action_type, diff_text, provider="gemini"):
746
- """Sends the diff to the AI using System Instruction and returns a parsed JSON."""
762
+ def generate_pr_content(
763
+ action_folder,
764
+ action_type,
765
+ diff_text,
766
+ provider="gemini",
767
+ cache_scope="",
768
+ store_diff=False,
769
+ ):
770
+ """Sends the diff to the AI using System Instruction and returns a parsed JSON.
771
+
772
+ The engine has no idea where ``diff_text`` came from and does not need one:
773
+ a local ``git diff`` and a pull request fetched over the API are the same
774
+ text by the time they arrive, so both review flows share this function.
775
+
776
+ ``cache_scope`` is appended to the cache key only — never to the prompt the
777
+ model receives — so a review of PR 42 and a review of an identical local
778
+ diff cannot answer each other. The default "" leaves the MD5 of every
779
+ existing local review untouched, so switching a caller to DiffSource
780
+ invalidates no cache.
781
+
782
+ ``store_diff`` records the reviewed diff alongside the response, which is
783
+ what lets ``gitpr fix`` patch against the revision that was reviewed rather
784
+ than the working tree of the day it runs.
785
+ """
747
786
  if not diff_text or not diff_text.strip():
748
787
  click.secho(
749
788
  __("⚠️ No diff found. Make some changes before running the command."),
@@ -870,7 +909,7 @@ def generate_pr_content(action_folder, action_type, diff_text, provider="gemini"
870
909
  pass # Non-critical — never block the main flow for this metadata
871
910
 
872
911
  # TRY TO RETRIEVE FROM CACHE
873
- cached_data = get_cached_response(action_folder, prompt)
912
+ cached_data = get_cached_response(action_folder, prompt + cache_scope)
874
913
  if cached_data:
875
914
  click.secho(__("⚡ Response retrieved from local cache."), fg="green", dim=True)
876
915
  from src.metrics import log_command_metric
@@ -1061,7 +1100,12 @@ def generate_pr_content(action_folder, action_type, diff_text, provider="gemini"
1061
1100
  # SAVE TO CACHE AND RETURN
1062
1101
  if result_json:
1063
1102
  save_cached_response(
1064
- action_folder, action_type, prompt, result_json, meta_raw=total_meta
1103
+ action_folder,
1104
+ action_type,
1105
+ prompt + cache_scope,
1106
+ result_json,
1107
+ meta_raw=total_meta,
1108
+ reviewed_diff=diff_text if store_diff else None,
1065
1109
  )
1066
1110
  # Fire-and-forget metric for successful AI-powered command
1067
1111
  from src.metrics import log_command_metric
@@ -1200,6 +1244,7 @@ def generate_skill_template():
1200
1244
  ".gitpr.blame.md": f"gitpr.blame{lang_suffix}.md",
1201
1245
  ".gitpr.issue.md": f"gitpr.issue{lang_suffix}.md",
1202
1246
  ".gitpr.release.md": f"gitpr.release{lang_suffix}.md",
1247
+ ".gitpr.fix.md": f"gitpr.fix{lang_suffix}.md",
1203
1248
  }
1204
1249
 
1205
1250
  success_count = 0
src/diff_parser.py CHANGED
@@ -5,8 +5,14 @@ The PR flows diff with ``git diff -U1 -w -M -B <ancestor> -- <excludes>``
5
5
  numbers of the added ('+') lines, per file — the numbers used to blame the
6
6
  working tree. It never runs git and never re-applies excludes: it only
7
7
  interprets the diff text it is given.
8
+
9
+ ``summarize_patch()`` reads the same text a second way — as a patch a whole:
10
+ which files it touches, how many hunks, how many lines it adds and removes.
11
+ That is what the ``gitpr fix`` safety classifier scores, and it accepts the
12
+ looser shape an AI returns (no ``diff --git`` line, just ``---``/``+++``).
8
13
  """
9
14
  import re
15
+ from dataclasses import dataclass
10
16
 
11
17
  _HUNK_HEADER_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
12
18
 
@@ -157,3 +163,159 @@ def parse_added_lines(diff_text):
157
163
  for lines in added_by_file.values():
158
164
  lines.sort()
159
165
  return added_by_file
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class PatchSummary:
170
+ """What a unified diff touches, as the safety classifier needs to see it.
171
+
172
+ ``files`` holds each file the patch mentions, in order of appearance, taken
173
+ from the new side — the 'diff --git' line when there is one (a deletion
174
+ keeps its name there, since its '+++ /dev/null' carries none), otherwise the
175
+ '+++ b/path' header. ``hunks`` counts well-formed '@@' headers, and
176
+ ``added``/``removed`` count content lines inside those hunks, so headers and
177
+ the '\\ No newline' marker never count. ``removed_lines`` keeps the text of
178
+ each removed line — what lets a caller spot a deleted function call.
179
+ """
180
+ files: tuple[str, ...]
181
+ hunks: int
182
+ added: int
183
+ removed: int
184
+ removed_lines: tuple[str, ...]
185
+
186
+
187
+ def summarize_patch(diff_text):
188
+ """Return a PatchSummary for *diff_text* (see the dataclass docstring).
189
+
190
+ Pure like parse_added_lines: it interprets the text it is given and never
191
+ runs git. Text with no recognizable diff section yields an empty summary
192
+ rather than an error — the caller decides whether that means "no patch".
193
+ """
194
+ files = []
195
+ hunks = 0
196
+ added = 0
197
+ removed = 0
198
+ removed_lines = []
199
+ in_hunk = False
200
+
201
+ for raw_line in diff_text.split("\n"):
202
+ line = raw_line.rstrip("\r")
203
+
204
+ if line.startswith("diff --git "):
205
+ _remember_file(files, _strip_diff_prefix(_header_path(line)))
206
+ in_hunk = False
207
+ continue
208
+ if line.startswith("+++ ") and not in_hunk:
209
+ path = _strip_diff_prefix(_header_path(line, marker="+++ "))
210
+ if path and path != "/dev/null":
211
+ _remember_file(files, path)
212
+ in_hunk = False
213
+ continue
214
+ if line.startswith("@@"):
215
+ if _HUNK_HEADER_RE.match(line):
216
+ hunks += 1
217
+ in_hunk = True
218
+ continue
219
+
220
+ if not in_hunk or line.startswith("\\ No newline"):
221
+ continue
222
+ if line.startswith("+"):
223
+ added += 1
224
+ elif line.startswith("-"):
225
+ removed += 1
226
+ removed_lines.append(line[1:])
227
+
228
+ return PatchSummary(
229
+ files=tuple(files),
230
+ hunks=hunks,
231
+ added=added,
232
+ removed=removed,
233
+ removed_lines=tuple(removed_lines),
234
+ )
235
+
236
+
237
+ def _remember_file(files, path):
238
+ """Append *path* to *files* once, preserving appearance order."""
239
+ if path and path not in files:
240
+ files.append(path)
241
+
242
+
243
+ @dataclass(frozen=True)
244
+ class PatchSection:
245
+ """One file's slice of a unified diff, with the path it belongs to.
246
+
247
+ ``path`` is the new-side path (the b/ side), so a deletion keeps its name —
248
+ its own '+++ /dev/null' header carries none. ``text`` is the section
249
+ verbatim, headers included, so the pieces of a split diff concatenate back
250
+ into something the review engine can still read.
251
+ """
252
+ path: str
253
+ text: str
254
+
255
+
256
+ def split_patch_sections(diff_text):
257
+ """Split *diff_text* into one PatchSection per file, in order.
258
+
259
+ The counterpart of summarize_patch for callers that must drop whole files
260
+ (the remote-PR smart-excludes filter): it needs each file's path next to its
261
+ own text, which no other helper here exposes. Accepts both header shapes
262
+ summarize_patch accepts — git's own output ('diff --git' plus a '---'/'+++'
263
+ pair per file) and the looser shape an AI returns (bare '---'/'+++' pairs).
264
+ Text before the first header is dropped, as are sections whose path never
265
+ resolves; a diff with no recognizable file yields [].
266
+
267
+ Pure, like its siblings: it interprets the text it is given, never runs git.
268
+ """
269
+ lines = [raw.rstrip("\r") for raw in diff_text.split("\n")]
270
+ sections = []
271
+ start = None
272
+ path = ""
273
+ in_hunk = False
274
+
275
+ def flush(end):
276
+ if start is not None and path:
277
+ sections.append(
278
+ PatchSection(path=path, text="\n".join(lines[start:end]))
279
+ )
280
+
281
+ i = 0
282
+ while i < len(lines):
283
+ line = lines[i]
284
+
285
+ if line.startswith("diff --git "):
286
+ flush(i)
287
+ start, in_hunk = i, False
288
+ # The b/ side names the file on the new revision, and unlike the
289
+ # '+++ /dev/null' of a deletion it is always populated.
290
+ path = _strip_diff_prefix(_header_path(line))
291
+ i += 1
292
+ continue
293
+
294
+ # A '---' immediately followed by a '+++' is a file header pair. Inside
295
+ # a 'diff --git' section it is that section's own header; anywhere else
296
+ # it opens a new file, which is how the AI's looser patches arrive.
297
+ if (
298
+ line.startswith("--- ")
299
+ and i + 1 < len(lines)
300
+ and lines[i + 1].startswith("+++ ")
301
+ ):
302
+ candidate = _strip_diff_prefix(_header_path(lines[i + 1], marker="+++ "))
303
+ if candidate == "/dev/null":
304
+ candidate = ""
305
+ already_open = start is not None and lines[start].startswith("diff --git ")
306
+ if not already_open:
307
+ flush(i)
308
+ start, in_hunk = i, False
309
+ path = candidate
310
+ elif candidate:
311
+ path = candidate
312
+ i += 1
313
+ continue
314
+
315
+ if line.startswith("@@"):
316
+ in_hunk = bool(_HUNK_HEADER_RE.match(line))
317
+
318
+ i += 1
319
+
320
+ flush(len(lines))
321
+ return sections
src/fix/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """The ``gitpr fix`` feature: turn a review finding into a reviewable patch.
2
+
3
+ Modules, in dependency order:
4
+ - patch_provenance.py — the data contract (safety classes, candidates, results)
5
+ - patch_extractor.py — reading the code an AI hands back (shared with the chat)
6
+ - patch_safety_classifier.py — SAFE / REVIEW_REQUIRED / EXPERIMENTAL, pure logic
7
+ - patch_applier.py — the project's first shared git wrapper (apply, check, reverse)
8
+ - fix_history.py — the applied-patch record that makes --rollback deterministic
9
+ - apply_fix.py — the use case: review -> AI findings -> classify -> dry-run/apply
10
+ - rollback_fix.py — the use case that undoes an applied patch
11
+
12
+ Import the submodule you need (``from src.fix.apply_fix import apply_fix``); this
13
+ package deliberately re-exports nothing, because the chat imports
14
+ ``src.fix.patch_extractor`` on every session and must not drag the AI and git
15
+ layers in with it.
16
+ """