diff --git a/rgithook.py b/rgithook.py new file mode 100755 index 0000000..d1c3a32 --- /dev/null +++ b/rgithook.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 + +API_URL = "https://static.molodetz.nl/rp.cgi/api/v1/chat/completions" +MODEL = "google/gemma-3-12b-it:free" +TEMPERATURE = 1.0 +MAX_TOKENS = None +HOOK_PATH = ".git/hooks/prepare-commit-msg" + +import sys +import subprocess +import re +import json +import urllib.request +import urllib.error +import os +from datetime import datetime +from collections import defaultdict + +def safe_run(cmd, default=""): + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=10 + ) + return result.stdout.strip() if result.returncode == 0 else default + except Exception: + return default + +def install_hook(): + try: + if not os.path.exists('.git'): + return False + + script_path = os.path.abspath(__file__) + hook_dir = os.path.dirname(HOOK_PATH) + + if not os.path.exists(hook_dir): + os.makedirs(hook_dir) + + if os.path.exists(HOOK_PATH): + with open(HOOK_PATH, 'r') as f: + if script_path in f.read(): + return False + + with open(HOOK_PATH, 'w') as f: + f.write(f'#!/bin/bash\n{script_path} "$@"\n') + + os.chmod(HOOK_PATH, 0o755) + return True + except Exception: + return False + +def get_latest_tag(): + try: + tags = safe_run(['git', 'tag', '--sort=-v:refname']) + if not tags: + return None + + for tag in tags.split('\n'): + tag = tag.strip() + match = re.match(r'v?(\d+\.\d+\.\d+)', tag) + if match: + return match.group(1) + return None + except Exception: + return None + +def bump_minor_version(version): + try: + parts = version.split('.') + if len(parts) >= 2: + major = int(parts[0]) + minor = int(parts[1]) + return f"{major}.{minor + 1}.0" + return version + except Exception: + return "0.1.0" + +def get_version(): + try: + if os.path.exists('pyproject.toml'): + with open('pyproject.toml', 'r') as f: + content = f.read() + match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content) + if match: + return match.group(1), 'pyproject' + + tag_version = get_latest_tag() + if tag_version: + return tag_version, 'tag' + + return "0.0.0", 'tag' + except Exception: + return "0.0.0", 'tag' + +def update_version(current_version, source): + try: + new_version = bump_minor_version(current_version) + + if source == 'pyproject': + with open('pyproject.toml', 'r') as f: + content = f.read() + + updated = re.sub( + r'(version\s*=\s*["\'])' + re.escape(current_version) + r'(["\'])', + r'\g<1>' + new_version + r'\g<2>', + content + ) + + with open('pyproject.toml', 'w') as f: + f.write(updated) + + safe_run(['git', 'add', 'pyproject.toml']) + + return new_version + except Exception: + return current_version + +def get_git_diff(): + return safe_run(['git', 'diff', '--cached', '--unified=0'], "") + +def get_changed_files(): + try: + output = safe_run(['git', 'diff', '--cached', '--name-only'], "") + return output.split('\n') if output else [] + except Exception: + return [] + +def analyze_diff_stats(diff): + try: + lines_by_lang = defaultdict(lambda: {'added': 0, 'removed': 0}) + current_file = None + + for line in diff.split('\n'): + if line.startswith('diff --git'): + match = re.search(r'b/(.+)$', line) + if match: + current_file = match.group(1) + elif line.startswith('+') and not line.startswith('+++'): + if current_file: + ext = os.path.splitext(current_file)[1] + lang = get_language_name(ext) + lines_by_lang[lang]['added'] += 1 + elif line.startswith('-') and not line.startswith('---'): + if current_file: + ext = os.path.splitext(current_file)[1] + lang = get_language_name(ext) + lines_by_lang[lang]['removed'] += 1 + + return lines_by_lang + except Exception: + return {} + +def get_language_name(ext): + lang_map = { + '.py': 'Python', + '.js': 'JavaScript', + '.ts': 'TypeScript', + '.css': 'CSS', + '.html': 'HTML', + '.java': 'Java', + '.c': 'C', + '.cpp': 'C++', + '.h': 'C', + '.rs': 'Rust', + '.go': 'Go', + '.rb': 'Ruby', + '.php': 'PHP', + '.sh': 'Shell', + '.sql': 'SQL', + '.md': 'Markdown', + '.json': 'JSON', + '.yaml': 'YAML', + '.yml': 'YAML', + '.toml': 'TOML', + '.xml': 'XML', + '.txt': 'Text', + } + return lang_map.get(ext.lower(), 'Other') + +def call_ai(prompt): + try: + api_key = os.environ.get('MOLODETZ_API_KEY',"retoorded") + if not api_key: + return None + + data = { + "model": MODEL, + "messages": [{"role": "user", "content": prompt}], + "temperature": TEMPERATURE + } + + if MAX_TOKENS is not None: + data["max_tokens"] = MAX_TOKENS + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "HTTP-Referer": "https://github.com/commit-hook", + "X-Title": "Git Commit Hook" + } + + req = urllib.request.Request( + API_URL, + data=json.dumps(data).encode('utf-8'), + headers=headers + ) + + with urllib.request.urlopen(req, timeout=60) as response: + result = json.loads(response.read().decode('utf-8')) + return result['choices'][0]['message']['content'].strip() + except Exception: + return None + +def generate_commit_message(diff, files): + try: + files_list = "\n".join([f"- {f}" for f in files[:20]]) + + prompt = f"""You write commit messages for code changes. + +Changed files: +{files_list} + +Code changes: +{diff[:12000]} + +Write a commit message with this format: +: + +Format rules: +- All lowercase for the prefix +- Colon and space after prefix +- Start description with lowercase letter +- No period at the end +- Max 72 characters total +- Use imperative mood (Add not Added) + +Choose one prefix: +- fix: for bug fixes + Example: fix: resolve null pointer error in user login + Example: fix: correct date format in export function + +- feat: for new features + Example: feat: add dark mode toggle to settings + Example: feat: implement search filter for products + +- docs: for documentation changes + Example: docs: update api endpoint descriptions + Example: docs: add setup guide for development + +- perf: for performance improvements + Example: perf: reduce database query time by 40% + Example: perf: optimize image loading with lazy load + +- refactor: for code restructuring + Example: refactor: simplify user validation logic + Example: refactor: extract common functions to utils + +- maintenance: for routine updates and maintenance + Example: maintenance: update dependencies to latest versions + Example: maintenance: clean up unused imports and files + +Reply with ONLY the commit message, nothing else.""" + + message = call_ai(prompt) + if not message: + return generate_fallback_message(files) + + message = message.strip().strip('"').strip("'") + + prefixes = ['fix:', 'feat:', 'docs:', 'perf:', 'refactor:', 'maintenance:'] + if not any(message.startswith(p) for p in prefixes): + message = f"feat: {message}" + + return message + except Exception: + return generate_fallback_message(files) + +def generate_fallback_message(files): + try: + if not files: + return "feat: update project" + + exts = set() + for f in files: + ext = os.path.splitext(f)[1] + if ext: + exts.add(ext[1:]) + + if exts: + return f"feat: update {', '.join(sorted(exts)[:3])} files" + return "feat: update project files" + except Exception: + return "feat: update project" + +def create_git_tag(version): + try: + safe_run(['git', 'tag', '-a', f'v{version}', '-m', f'Release version {version}']) + return True + except Exception: + return False + +def read_changelog(): + try: + if os.path.exists('CHANGELOG.md'): + with open('CHANGELOG.md', 'r') as f: + return f.read() + return "# Changelog\n\n" + except Exception: + return "# Changelog\n\n" + +def write_changelog(content): + try: + with open('CHANGELOG.md', 'w') as f: + f.write(content) + except Exception: + pass + +def update_changelog(version, commit_message, stats, files): + try: + changelog = read_changelog() + today = datetime.now().strftime('%Y-%m-%d') + + total_changes = len(files) + total_lines = sum(s['added'] + s['removed'] for s in stats.values()) + + lang_stats = [] + for lang in sorted(stats.keys()): + total = stats[lang]['added'] + stats[lang]['removed'] + if total > 0: + lang_stats.append(f"{lang} ({total} lines)") + + lang_summary = ", ".join(lang_stats) if lang_stats else "No code changes" + + functional_desc_prompt = f"""You write changelog entries for software releases. + +Commit message: {commit_message} +Changed files: {", ".join(files[:10])} + +Write a short functional description of what changed for users or developers. +Use simple clear words, be direct, max 2 sentences. +Focus on what it does, not how. + +Reply with ONLY the description text.""" + + functional_desc = call_ai(functional_desc_prompt) + if not functional_desc: + functional_desc = commit_message.split(':', 1)[1].strip() if ':' in commit_message else commit_message + + entry_lines = [ + f"## Version {version} - {today}", + "", + functional_desc, + "", + f"**Changes:** {total_changes} files, {total_lines} lines", + f"**Languages:** {lang_summary}", + "", + ] + + new_entry = "\n".join(entry_lines) + + lines = changelog.split('\n') + version_pattern = f"## Version {version} - {today}" + + for i, line in enumerate(lines): + if line.startswith(f"## Version {version} -"): + end_idx = i + 1 + while end_idx < len(lines) and not lines[end_idx].startswith('## Version'): + end_idx += 1 + lines = lines[:i] + new_entry.split('\n') + lines[end_idx:] + write_changelog('\n'.join(lines)) + return + + insert_idx = 2 + while insert_idx < len(lines) and lines[insert_idx].strip() == '': + insert_idx += 1 + + lines = lines[:insert_idx] + [''] + new_entry.split('\n') + lines[insert_idx:] + write_changelog('\n'.join(lines)) + except Exception: + pass + +def main(): + try: + if len(sys.argv) < 2: + if install_hook(): + print("Git hook installed successfully") + else: + print("Git hook already installed") + sys.exit(0) + + commit_msg_file = sys.argv[1] + + current_version, source = get_version() + new_version = update_version(current_version, source) + + diff = get_git_diff() + files = get_changed_files() + stats = analyze_diff_stats(diff) + + commit_message = generate_commit_message(diff, files) + + update_changelog(new_version, commit_message, stats, files) + safe_run(['git', 'add', 'CHANGELOG.md']) + + create_git_tag(new_version) + + with open(commit_msg_file, 'w') as f: + f.write(commit_message + '\n') + + sys.exit(0) + except Exception: + try: + if len(sys.argv) >= 2: + with open(sys.argv[1], 'w') as f: + f.write("feat: update project\n") + except Exception: + pass + sys.exit(0) + +if __name__ == '__main__': + main() +