commitwise-review 0.1.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.
- commitwise_review-0.1.0.dist-info/METADATA +90 -0
- commitwise_review-0.1.0.dist-info/RECORD +15 -0
- commitwise_review-0.1.0.dist-info/WHEEL +5 -0
- commitwise_review-0.1.0.dist-info/entry_points.txt +3 -0
- commitwise_review-0.1.0.dist-info/licenses/LICENSE +21 -0
- commitwise_review-0.1.0.dist-info/top_level.txt +1 -0
- gai/__init__.py +1 -0
- gai/__main__.py +4 -0
- gai/analyzers/local.py +30 -0
- gai/cli/main.py +180 -0
- gai/config/settings.py +55 -0
- gai/git/staged.py +39 -0
- gai/hooks/install.py +16 -0
- gai/prompts/review_prompt.py +30 -0
- gai/providers/reviewer.py +102 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: commitwise-review
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Git staged-change AI review helper
|
|
5
|
+
Author: Gokulakrishnan
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: keyring
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# Commit-wise
|
|
13
|
+
|
|
14
|
+
Open Source Commit and PR reviewer.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
Install the library from PyPI:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install commitwise-review
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Install directly from the source repository:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install .
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Install from GitHub:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install git+https://github.com/gokul-1998/Commit-wise.git
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
To force reinstall:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install --force-reinstall --no-cache-dir git+https://github.com/gokul-1998/Commit-wise.git
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Versioning
|
|
43
|
+
|
|
44
|
+
This project uses Semantic Versioning:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
MAJOR.MINOR.PATCH
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Example release plan:
|
|
51
|
+
|
|
52
|
+
- `0.1.0` — initial release
|
|
53
|
+
- `0.2.0` — new features
|
|
54
|
+
- `0.2.1` — bug fixes and prompt improvements
|
|
55
|
+
- `1.0.0` — stable public release
|
|
56
|
+
|
|
57
|
+
## CLI MVP (`commit-wise`)
|
|
58
|
+
|
|
59
|
+
This repository now includes a minimal local CLI for staged-change review.
|
|
60
|
+
|
|
61
|
+
### Commands
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
commit-wise init
|
|
65
|
+
commit-wise review
|
|
66
|
+
commit-wise explain
|
|
67
|
+
commit-wise commit
|
|
68
|
+
commit-wise hooks install
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The old `gai` alias is still available:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
gai init
|
|
75
|
+
gai review
|
|
76
|
+
gai explain
|
|
77
|
+
gai commit
|
|
78
|
+
gai hooks install
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### What `commit-wise review` does
|
|
82
|
+
|
|
83
|
+
- Reads staged files from `git diff --cached --name-only`
|
|
84
|
+
- Reads staged diff from `git diff --cached`
|
|
85
|
+
- Runs local analyzers when installed (`ruff`, `mypy`, `pytest`, `bandit`)
|
|
86
|
+
- Prints actionable review suggestions and score in terminal
|
|
87
|
+
|
|
88
|
+
### Security
|
|
89
|
+
|
|
90
|
+
`gai init` stores provider tokens in the system keyring (`keyring` package), not in plain-text config.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
commitwise_review-0.1.0.dist-info/licenses/LICENSE,sha256=-ggiz8LjSj4d3hpG04rZNpJItsWBRwrVPnbFCKHRxO4,1094
|
|
2
|
+
gai/__init__.py,sha256=68lF8K7l_2VZxs1qgpAcUV54oNaE-5av2yCAClcqN9A,20
|
|
3
|
+
gai/__main__.py,sha256=5164d53qIXkvzGC4evy5wq-ZKE1eQkfw1huUfO_6Hs8,91
|
|
4
|
+
gai/analyzers/local.py,sha256=nNQQo2zijf51ROmUJVMC1MgrUVQ9P6kBOu6lNBzEdbc,948
|
|
5
|
+
gai/cli/main.py,sha256=Ke6nNwLD0QiFHJelUrz4KSJpsGYJC1CM_LX_2aKotj8,6287
|
|
6
|
+
gai/config/settings.py,sha256=GOVLQ3t3SNbGf-eE_fulYzvGdeuKMkkOklk9K5wcFQg,1612
|
|
7
|
+
gai/git/staged.py,sha256=01WoWLhuhGsfThquxbh2fjZ6d3TEjCzNaFv0xOSsDcQ,1138
|
|
8
|
+
gai/hooks/install.py,sha256=4nIhCNky7F6q0MsAAQODY9Ixedj5vIMvKC9lx88msik,459
|
|
9
|
+
gai/prompts/review_prompt.py,sha256=WZ5r4fG9_mySWMxZEFBIYUs9GWPAdvEhv0rsEmK1-1Y,539
|
|
10
|
+
gai/providers/reviewer.py,sha256=EkGGm55Wjpcnmvx3u-fdUMxay-v-ailppEDfL2ednes,3004
|
|
11
|
+
commitwise_review-0.1.0.dist-info/METADATA,sha256=iz3ot_xiB_RmJffnJ-nmJ2Y3ZIq-QSWDm0n8VFTv0rI,1779
|
|
12
|
+
commitwise_review-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
commitwise_review-0.1.0.dist-info/entry_points.txt,sha256=ZGE8xHFhG2USA_cxBc5xmm8vG_jnXCBk8OnWpBTp71Y,74
|
|
14
|
+
commitwise_review-0.1.0.dist-info/top_level.txt,sha256=6xwodMh-vDJyWtXK1HUAhKGl7vn0wbfHDqzdJnLKwVU,4
|
|
15
|
+
commitwise_review-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gokulakrishnan M
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gai
|
gai/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""gai package."""
|
gai/__main__.py
ADDED
gai/analyzers/local.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
TOOLS = [
|
|
8
|
+
("Ruff", ["ruff", "check", "."]),
|
|
9
|
+
("MyPy", ["mypy", "."]),
|
|
10
|
+
("Pytest", ["pytest", "tests/"]),
|
|
11
|
+
("Bandit", ["bandit", "-r", "."]),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_local_analyzers(cwd: Path) -> list[tuple[str, str]]:
|
|
16
|
+
reports: list[tuple[str, str]] = []
|
|
17
|
+
for name, cmd in TOOLS:
|
|
18
|
+
if shutil.which(cmd[0]) is None:
|
|
19
|
+
reports.append((name, "not installed (skipped)"))
|
|
20
|
+
continue
|
|
21
|
+
|
|
22
|
+
proc = subprocess.run(cmd, cwd=str(cwd), check=False, capture_output=True, text=True)
|
|
23
|
+
status = "passed" if proc.returncode == 0 else "issues found"
|
|
24
|
+
output = (proc.stdout + "\n" + proc.stderr).strip()
|
|
25
|
+
if output:
|
|
26
|
+
output = output[:1500]
|
|
27
|
+
reports.append((name, f"{status}: {output}"))
|
|
28
|
+
else:
|
|
29
|
+
reports.append((name, status))
|
|
30
|
+
return reports
|
gai/cli/main.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from gai.analyzers.local import run_local_analyzers
|
|
8
|
+
from gai.config.settings import get_token, load_config, save_config, set_token
|
|
9
|
+
from gai.git.staged import get_repo_root, get_staged_diff, get_staged_files
|
|
10
|
+
from gai.hooks.install import install_pre_commit_hook
|
|
11
|
+
from gai.prompts.review_prompt import build_explain_prompt
|
|
12
|
+
from gai.providers.reviewer import ReviewResult, review_staged_changes
|
|
13
|
+
|
|
14
|
+
PROVIDERS = ["github", "openai", "anthropic", "gemini", "ollama"]
|
|
15
|
+
DEFAULT_PROVIDER_MODELS = {
|
|
16
|
+
"github": "github/copilot",
|
|
17
|
+
"openai": "openai/gpt-5-mini",
|
|
18
|
+
"anthropic": "anthropic/claude-3",
|
|
19
|
+
"gemini": "gemini/1",
|
|
20
|
+
"ollama": "ollama/ollama-small",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
GITHUB_PAT_URL = (
|
|
24
|
+
"https://github.com/settings/personal-access-tokens/new?"
|
|
25
|
+
"description=Used+to+call+GitHub+Models+APIs+to+easily+run+LLMs%3A+"
|
|
26
|
+
"https%3A%2F%2Fdocs.github.com%2Fgithub-models%2Fquickstart%23step-2-make-an-api-call&"
|
|
27
|
+
"name=GitHub+Models+token&user_models=read"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _status(flag: bool) -> str:
|
|
32
|
+
return "✓" if flag else "⚠"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _print_review(result: ReviewResult) -> None:
|
|
36
|
+
print("\nReviewing staged changes...\n")
|
|
37
|
+
print(f"{_status(result.security)} Security")
|
|
38
|
+
print(f"{_status(result.performance)} Performance")
|
|
39
|
+
print(f"{_status(result.maintainability)} Maintainability")
|
|
40
|
+
print(f"{_status(result.tests)} Tests")
|
|
41
|
+
|
|
42
|
+
print("\nSuggestions:\n")
|
|
43
|
+
if not result.suggestions:
|
|
44
|
+
print("No critical suggestions found.")
|
|
45
|
+
else:
|
|
46
|
+
for idx, suggestion in enumerate(result.suggestions, start=1):
|
|
47
|
+
print(f"{idx}. {suggestion.file} [{suggestion.severity}]")
|
|
48
|
+
print(f" {suggestion.message}")
|
|
49
|
+
print(f" Suggested: {suggestion.fix}\n")
|
|
50
|
+
|
|
51
|
+
print(f"Overall score: {result.score}/10")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_init(_: argparse.Namespace) -> int:
|
|
55
|
+
print("Choose provider:\n")
|
|
56
|
+
for idx, provider in enumerate(PROVIDERS, start=1):
|
|
57
|
+
print(f"{idx}. {provider}")
|
|
58
|
+
|
|
59
|
+
raw_choice = input("\n> ").strip()
|
|
60
|
+
if raw_choice.isdigit() and 1 <= int(raw_choice) <= len(PROVIDERS):
|
|
61
|
+
provider = PROVIDERS[int(raw_choice) - 1]
|
|
62
|
+
elif raw_choice in PROVIDERS:
|
|
63
|
+
provider = raw_choice
|
|
64
|
+
else:
|
|
65
|
+
print("Invalid provider selection.")
|
|
66
|
+
return 1
|
|
67
|
+
|
|
68
|
+
setup_info = _provider_setup_instructions(provider)
|
|
69
|
+
if setup_info:
|
|
70
|
+
print(setup_info)
|
|
71
|
+
|
|
72
|
+
token = input(f"Enter {provider} token (stored securely with keyring):\n> ").strip()
|
|
73
|
+
if not token:
|
|
74
|
+
print("Token is required.")
|
|
75
|
+
return 1
|
|
76
|
+
|
|
77
|
+
set_token(provider, token)
|
|
78
|
+
save_config({"provider": provider, "model": DEFAULT_PROVIDER_MODELS.get(provider, "openai/gpt-5-mini")})
|
|
79
|
+
|
|
80
|
+
print("\nSaved ~/.gai/config.toml (without token).")
|
|
81
|
+
print("Token saved in keyring.")
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _provider_setup_instructions(provider: str) -> str | None:
|
|
86
|
+
if provider == "github":
|
|
87
|
+
return (
|
|
88
|
+
"\nGitHub provider requires a Personal Access Token (PAT).\n"
|
|
89
|
+
f"Create one at: {GITHUB_PAT_URL}\n"
|
|
90
|
+
"Select scopes: repo, workflow (and read:org if needed).\n"
|
|
91
|
+
"Recommended model for GitHub provider: github/copilot\n"
|
|
92
|
+
)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_review(_: argparse.Namespace) -> int:
|
|
97
|
+
cwd = Path.cwd()
|
|
98
|
+
staged_files = get_staged_files(cwd)
|
|
99
|
+
if not staged_files:
|
|
100
|
+
print("No staged files found. Run `git add` first.")
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
staged_diff = get_staged_diff(cwd)
|
|
104
|
+
_ = load_config()
|
|
105
|
+
|
|
106
|
+
print("Running local analyzers (when available)...")
|
|
107
|
+
analyzer_reports = run_local_analyzers(cwd)
|
|
108
|
+
for name, report in analyzer_reports:
|
|
109
|
+
short = report.splitlines()[0] if report else ""
|
|
110
|
+
print(f"→ {name}: {short}")
|
|
111
|
+
|
|
112
|
+
result = review_staged_changes(staged_files, staged_diff)
|
|
113
|
+
_print_review(result)
|
|
114
|
+
|
|
115
|
+
proceed = input("\nProceed with commit? [y/N] ").strip().lower()
|
|
116
|
+
if proceed in {"y", "yes"}:
|
|
117
|
+
return 0
|
|
118
|
+
return 2
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_explain(_: argparse.Namespace) -> int:
|
|
122
|
+
cwd = Path.cwd()
|
|
123
|
+
staged_diff = get_staged_diff(cwd)
|
|
124
|
+
if not staged_diff.strip():
|
|
125
|
+
print("No staged diff found. Run `git add` first.")
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
prompt = build_explain_prompt(staged_diff)
|
|
129
|
+
print(prompt)
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_commit(_: argparse.Namespace) -> int:
|
|
134
|
+
review_code = cmd_review(argparse.Namespace())
|
|
135
|
+
if review_code != 0:
|
|
136
|
+
return review_code
|
|
137
|
+
|
|
138
|
+
message = input("Commit message (leave empty for auto-generated):\n> ").strip()
|
|
139
|
+
if not message:
|
|
140
|
+
message = "chore: apply reviewed staged changes"
|
|
141
|
+
|
|
142
|
+
proc = subprocess.run(["git", "commit", "-m", message], check=False)
|
|
143
|
+
return proc.returncode
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def cmd_hooks_install(_: argparse.Namespace) -> int:
|
|
147
|
+
repo_root = get_repo_root(Path.cwd())
|
|
148
|
+
hook_file = install_pre_commit_hook(repo_root)
|
|
149
|
+
print(f"Installed pre-commit hook at {hook_file}")
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
154
|
+
parser = argparse.ArgumentParser(prog="gai", description="Git AI review helper")
|
|
155
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
156
|
+
|
|
157
|
+
init_parser = subparsers.add_parser("init", help="Configure provider and token storage")
|
|
158
|
+
init_parser.set_defaults(func=cmd_init)
|
|
159
|
+
|
|
160
|
+
review_parser = subparsers.add_parser("review", help="Review staged changes")
|
|
161
|
+
review_parser.set_defaults(func=cmd_review)
|
|
162
|
+
|
|
163
|
+
explain_parser = subparsers.add_parser("explain", help="Print prompt for staged changes")
|
|
164
|
+
explain_parser.set_defaults(func=cmd_explain)
|
|
165
|
+
|
|
166
|
+
commit_parser = subparsers.add_parser("commit", help="Review then commit staged changes")
|
|
167
|
+
commit_parser.set_defaults(func=cmd_commit)
|
|
168
|
+
|
|
169
|
+
hooks_parser = subparsers.add_parser("hooks", help="Manage git hooks")
|
|
170
|
+
hooks_sub = hooks_parser.add_subparsers(dest="hooks_command", required=True)
|
|
171
|
+
install_parser = hooks_sub.add_parser("install", help="Install pre-commit hook")
|
|
172
|
+
install_parser.set_defaults(func=cmd_hooks_install)
|
|
173
|
+
|
|
174
|
+
return parser
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main(argv: list[str] | None = None) -> int:
|
|
178
|
+
parser = build_parser()
|
|
179
|
+
args = parser.parse_args(argv)
|
|
180
|
+
return args.func(args)
|
gai/config/settings.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
CONFIG_DIR = Path.home() / ".gai"
|
|
7
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _toml_dumps(data: dict[str, str]) -> str:
|
|
11
|
+
lines = []
|
|
12
|
+
for key, value in data.items():
|
|
13
|
+
escaped = value.replace('"', '\\"')
|
|
14
|
+
lines.append(f'{key}="{escaped}"')
|
|
15
|
+
return "\n".join(lines) + "\n"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def save_config(config: dict[str, str]) -> None:
|
|
19
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
CONFIG_FILE.write_text(_toml_dumps(config), encoding="utf-8")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_config() -> dict[str, str]:
|
|
24
|
+
if not CONFIG_FILE.exists():
|
|
25
|
+
return {}
|
|
26
|
+
|
|
27
|
+
text = CONFIG_FILE.read_text(encoding="utf-8")
|
|
28
|
+
result: dict[str, str] = {}
|
|
29
|
+
for raw_line in text.splitlines():
|
|
30
|
+
line = raw_line.strip()
|
|
31
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
32
|
+
continue
|
|
33
|
+
key, _, raw_value = line.partition("=")
|
|
34
|
+
value = raw_value.strip().strip('"')
|
|
35
|
+
result[key.strip()] = value
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def set_token(provider: str, token: str) -> None:
|
|
40
|
+
try:
|
|
41
|
+
import keyring # type: ignore
|
|
42
|
+
except Exception as exc: # pragma: no cover - import guard
|
|
43
|
+
raise RuntimeError(
|
|
44
|
+
"keyring is required for secure token storage. Install it with `pip install keyring`."
|
|
45
|
+
) from exc
|
|
46
|
+
|
|
47
|
+
keyring.set_password("gai", provider, token)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_token(provider: str) -> str | None:
|
|
51
|
+
try:
|
|
52
|
+
import keyring # type: ignore
|
|
53
|
+
except Exception:
|
|
54
|
+
return None
|
|
55
|
+
return keyring.get_password("gai", provider)
|
gai/git/staged.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _git(args: list[str], cwd: Path | None = None) -> str:
|
|
8
|
+
proc = subprocess.run(
|
|
9
|
+
["git", *args],
|
|
10
|
+
cwd=str(cwd) if cwd else None,
|
|
11
|
+
check=False,
|
|
12
|
+
capture_output=True,
|
|
13
|
+
text=True,
|
|
14
|
+
)
|
|
15
|
+
if proc.returncode != 0:
|
|
16
|
+
raise RuntimeError(proc.stderr.strip() or "git command failed")
|
|
17
|
+
return proc.stdout
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_staged_files(cwd: Path | None = None) -> list[str]:
|
|
21
|
+
out = _git(["diff", "--cached", "--name-only"], cwd=cwd)
|
|
22
|
+
return [line.strip() for line in out.splitlines() if line.strip()]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_staged_diff(cwd: Path | None = None) -> str:
|
|
26
|
+
return _git(["diff", "--cached"], cwd=cwd)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_repo_root(cwd: Path | None = None) -> Path:
|
|
30
|
+
out = _git(["rev-parse", "--show-toplevel"], cwd=cwd)
|
|
31
|
+
return Path(out.strip())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_file(path: str, cwd: Path | None = None) -> str:
|
|
35
|
+
root = get_repo_root(cwd)
|
|
36
|
+
full_path = root / path
|
|
37
|
+
if not full_path.exists():
|
|
38
|
+
return ""
|
|
39
|
+
return full_path.read_text(encoding="utf-8", errors="ignore")
|
gai/hooks/install.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
HOOK_CONTENT = "#!/usr/bin/env bash\nset -euo pipefail\ngai review\n"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def install_pre_commit_hook(repo_root: Path) -> Path:
|
|
11
|
+
hooks_dir = repo_root / ".git" / "hooks"
|
|
12
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
hook_file = hooks_dir / "pre-commit"
|
|
14
|
+
hook_file.write_text(HOOK_CONTENT, encoding="utf-8")
|
|
15
|
+
os.chmod(hook_file, 0o755)
|
|
16
|
+
return hook_file
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def build_explain_prompt(staged_diff: str) -> str:
|
|
5
|
+
return """You are a Staff Software Engineer.
|
|
6
|
+
|
|
7
|
+
Review ONLY the staged changes.
|
|
8
|
+
|
|
9
|
+
Consider:
|
|
10
|
+
1. Bugs
|
|
11
|
+
2. Security
|
|
12
|
+
3. Performance
|
|
13
|
+
4. Readability
|
|
14
|
+
5. Architecture consistency
|
|
15
|
+
6. Error handling
|
|
16
|
+
7. Missing tests
|
|
17
|
+
8. Backward compatibility
|
|
18
|
+
|
|
19
|
+
Provide:
|
|
20
|
+
- Severity
|
|
21
|
+
- File
|
|
22
|
+
- Explanation
|
|
23
|
+
- Suggested fix
|
|
24
|
+
- Code snippet
|
|
25
|
+
- Overall score (/10)
|
|
26
|
+
|
|
27
|
+
Do not comment on formatting issues already covered by Ruff.
|
|
28
|
+
|
|
29
|
+
Staged diff:
|
|
30
|
+
""" + staged_diff
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Suggestion:
|
|
8
|
+
severity: str
|
|
9
|
+
file: str
|
|
10
|
+
message: str
|
|
11
|
+
fix: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ReviewResult:
|
|
16
|
+
security: bool
|
|
17
|
+
performance: bool
|
|
18
|
+
maintainability: bool
|
|
19
|
+
tests: bool
|
|
20
|
+
suggestions: list[Suggestion]
|
|
21
|
+
score: float
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _suggest_test_file(file_path: str) -> str:
|
|
25
|
+
stem = file_path.rsplit(".", 1)[0].replace("/", "_")
|
|
26
|
+
return f"tests/test_{stem}.py"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def review_staged_changes(staged_files: list[str], staged_diff: str) -> ReviewResult:
|
|
30
|
+
suggestions: list[Suggestion] = []
|
|
31
|
+
|
|
32
|
+
has_tests = any("test" in path.lower() for path in staged_files)
|
|
33
|
+
maintainability_ok = True
|
|
34
|
+
security_ok = True
|
|
35
|
+
performance_ok = True
|
|
36
|
+
|
|
37
|
+
lowered = staged_diff.lower()
|
|
38
|
+
if "password" in lowered and "hash" not in lowered:
|
|
39
|
+
security_ok = False
|
|
40
|
+
suggestions.append(
|
|
41
|
+
Suggestion(
|
|
42
|
+
severity="high",
|
|
43
|
+
file="(staged diff)",
|
|
44
|
+
message="Potential raw password handling detected.",
|
|
45
|
+
fix="Ensure password values are hashed and never logged or stored in plain text.",
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if "except:" in staged_diff:
|
|
50
|
+
maintainability_ok = False
|
|
51
|
+
suggestions.append(
|
|
52
|
+
Suggestion(
|
|
53
|
+
severity="medium",
|
|
54
|
+
file="(staged diff)",
|
|
55
|
+
message="Bare except detected.",
|
|
56
|
+
fix="Catch explicit exceptions to avoid hiding unexpected failures.",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if "for " in staged_diff and "append(" in staged_diff and "set(" in staged_diff:
|
|
61
|
+
performance_ok = False
|
|
62
|
+
suggestions.append(
|
|
63
|
+
Suggestion(
|
|
64
|
+
severity="low",
|
|
65
|
+
file="(staged diff)",
|
|
66
|
+
message="Potentially expensive loop pattern found.",
|
|
67
|
+
fix="Consider using set/dict lookups outside loops where possible.",
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if not has_tests:
|
|
72
|
+
maintainability_ok = False
|
|
73
|
+
for file_path in staged_files[:3]:
|
|
74
|
+
if "test" in file_path.lower():
|
|
75
|
+
continue
|
|
76
|
+
suggestions.append(
|
|
77
|
+
Suggestion(
|
|
78
|
+
severity="medium",
|
|
79
|
+
file=file_path,
|
|
80
|
+
message="No test updates detected for this staged change.",
|
|
81
|
+
fix=f"Add or update tests, e.g. {_suggest_test_file(file_path)}.",
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
score = 10.0
|
|
86
|
+
if not security_ok:
|
|
87
|
+
score -= 2.0
|
|
88
|
+
if not performance_ok:
|
|
89
|
+
score -= 1.0
|
|
90
|
+
if not maintainability_ok:
|
|
91
|
+
score -= 1.5
|
|
92
|
+
if not has_tests:
|
|
93
|
+
score -= 1.0
|
|
94
|
+
|
|
95
|
+
return ReviewResult(
|
|
96
|
+
security=security_ok,
|
|
97
|
+
performance=performance_ok,
|
|
98
|
+
maintainability=maintainability_ok,
|
|
99
|
+
tests=has_tests,
|
|
100
|
+
suggestions=suggestions,
|
|
101
|
+
score=max(0.0, round(score, 1)),
|
|
102
|
+
)
|