commitwise-review 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,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,79 @@
1
+ # Commit-wise
2
+
3
+ Open Source Commit and PR reviewer.
4
+
5
+ ## Install
6
+
7
+ Install the library from PyPI:
8
+
9
+ ```bash
10
+ pip install commitwise-review
11
+ ```
12
+
13
+ Install directly from the source repository:
14
+
15
+ ```bash
16
+ pip install .
17
+ ```
18
+
19
+ Install from GitHub:
20
+
21
+ ```bash
22
+ pip install git+https://github.com/gokul-1998/Commit-wise.git
23
+ ```
24
+
25
+ To force reinstall:
26
+
27
+ ```bash
28
+ pip install --force-reinstall --no-cache-dir git+https://github.com/gokul-1998/Commit-wise.git
29
+ ```
30
+
31
+ ## Versioning
32
+
33
+ This project uses Semantic Versioning:
34
+
35
+ ```bash
36
+ MAJOR.MINOR.PATCH
37
+ ```
38
+
39
+ Example release plan:
40
+
41
+ - `0.1.0` — initial release
42
+ - `0.2.0` — new features
43
+ - `0.2.1` — bug fixes and prompt improvements
44
+ - `1.0.0` — stable public release
45
+
46
+ ## CLI MVP (`commit-wise`)
47
+
48
+ This repository now includes a minimal local CLI for staged-change review.
49
+
50
+ ### Commands
51
+
52
+ ```bash
53
+ commit-wise init
54
+ commit-wise review
55
+ commit-wise explain
56
+ commit-wise commit
57
+ commit-wise hooks install
58
+ ```
59
+
60
+ The old `gai` alias is still available:
61
+
62
+ ```bash
63
+ gai init
64
+ gai review
65
+ gai explain
66
+ gai commit
67
+ gai hooks install
68
+ ```
69
+
70
+ ### What `commit-wise review` does
71
+
72
+ - Reads staged files from `git diff --cached --name-only`
73
+ - Reads staged diff from `git diff --cached`
74
+ - Runs local analyzers when installed (`ruff`, `mypy`, `pytest`, `bandit`)
75
+ - Prints actionable review suggestions and score in terminal
76
+
77
+ ### Security
78
+
79
+ `gai init` stores provider tokens in the system keyring (`keyring` package), not in plain-text config.
@@ -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,21 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ commitwise_review.egg-info/PKG-INFO
5
+ commitwise_review.egg-info/SOURCES.txt
6
+ commitwise_review.egg-info/dependency_links.txt
7
+ commitwise_review.egg-info/entry_points.txt
8
+ commitwise_review.egg-info/requires.txt
9
+ commitwise_review.egg-info/top_level.txt
10
+ gai/__init__.py
11
+ gai/__main__.py
12
+ gai/analyzers/local.py
13
+ gai/cli/main.py
14
+ gai/config/settings.py
15
+ gai/git/staged.py
16
+ gai/hooks/install.py
17
+ gai/prompts/review_prompt.py
18
+ gai/providers/reviewer.py
19
+ tests/test_cli.py
20
+ tests/test_hooks.py
21
+ tests/test_reviewer.py
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ commit-wise = gai.cli.main:main
3
+ gai = gai.cli.main:main
@@ -0,0 +1 @@
1
+ """gai package."""
@@ -0,0 +1,4 @@
1
+ from gai.cli.main import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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
@@ -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)
@@ -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)
@@ -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")
@@ -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
+ )
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "commitwise-review"
7
+ version = "0.1.0"
8
+ description = "Git staged-change AI review helper"
9
+ readme = "README.md"
10
+ authors = [
11
+ {name = "Gokulakrishnan"}
12
+ ]
13
+ requires-python = ">=3.11"
14
+ dependencies = [
15
+ "keyring"
16
+ ]
17
+
18
+ [project.scripts]
19
+ commit-wise = "gai.cli.main:main"
20
+ gai = "gai.cli.main:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,25 @@
1
+ import argparse
2
+ import io
3
+ import unittest
4
+ from unittest.mock import patch
5
+
6
+ from gai.cli.main import cmd_init
7
+
8
+
9
+ class CliTests(unittest.TestCase):
10
+ @patch("gai.cli.main.save_config")
11
+ @patch("gai.cli.main.set_token")
12
+ def test_init_github_shows_pat_url_and_default_model(self, mock_set_token, mock_save_config):
13
+ user_inputs = ["1", "ghp_testtoken"]
14
+ with patch("builtins.input", side_effect=user_inputs):
15
+ with patch("sys.stdout", new=io.StringIO()) as fake_out:
16
+ result = cmd_init(argparse.Namespace())
17
+
18
+ self.assertEqual(result, 0)
19
+ output = fake_out.getvalue()
20
+ self.assertIn(
21
+ "https://github.com/settings/personal-access-tokens/new?description=Used+to+call+GitHub+Models+APIs+to+easily+run+LLMs%3A+https%3A%2F%2Fdocs.github.com%2Fgithub-models%2Fquickstart%23step-2-make-an-api-call&name=GitHub+Models+token&user_models=read",
22
+ output,
23
+ )
24
+ self.assertIn("github/copilot", output)
25
+ mock_save_config.assert_called_once_with({"provider": "github", "model": "github/copilot"})
@@ -0,0 +1,23 @@
1
+ import tempfile
2
+ import unittest
3
+ from pathlib import Path
4
+
5
+ from gai.hooks.install import install_pre_commit_hook
6
+
7
+
8
+ class HookTests(unittest.TestCase):
9
+ def test_install_pre_commit_hook(self):
10
+ with tempfile.TemporaryDirectory() as tmpdir:
11
+ temp_root = Path(tmpdir)
12
+ hooks_dir = temp_root / ".git" / "hooks"
13
+ hooks_dir.mkdir(parents=True, exist_ok=True)
14
+
15
+ hook_file = install_pre_commit_hook(temp_root)
16
+
17
+ self.assertTrue(hook_file.exists())
18
+ content = hook_file.read_text(encoding="utf-8")
19
+ self.assertIn("gai review", content)
20
+
21
+
22
+ if __name__ == "__main__":
23
+ unittest.main()
@@ -0,0 +1,20 @@
1
+ import unittest
2
+
3
+ from gai.providers.reviewer import review_staged_changes
4
+
5
+
6
+ class ReviewerTests(unittest.TestCase):
7
+ def test_review_flags_missing_tests_and_password_risk(self):
8
+ result = review_staged_changes(
9
+ ["app/services/user.py"],
10
+ "+ password = request.json['password']\n+ except:\n",
11
+ )
12
+
13
+ self.assertFalse(result.security)
14
+ self.assertFalse(result.tests)
15
+ self.assertTrue(any("No test updates" in s.message for s in result.suggestions))
16
+ self.assertTrue(any("password" in s.message.lower() for s in result.suggestions))
17
+
18
+
19
+ if __name__ == "__main__":
20
+ unittest.main()