From c27dafdef75b3c51303cafcefc5e21eb73ac02d8 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Fri, 1 Nov 2024 11:29:06 +0800 Subject: [PATCH] feat: gitguard (#17) * chore: consitent changelog * feat: trial * feat(gitguard): update docs * feat(gitguard): update docs * feat(gitguard): update docs * feat(gitguard): update docs * feat(gitguard): update docs * update docs * update documentation * update documentation * fff * fff * fff * feat(TOREMOVE): fff * fff * fff * fff * fff * fff * feat: initial gitguard setup * feat(gitguard): initial gitguard setup * feat(gitguard): fallback api and local api via ollama * feat: more gitguard improv * feat(gitguard): more gitguard improv * feat(gitguard): new PR * feat(gitguard): ai * feat(gitguard): add multi-AI suggestions and token count logging * feat(gitguard): tiktoken * feat(gitguard): integrate tiktoken for commit message generation * feat(gitguard): add commit cohesion analysis and enhanced installation script * feat(publisher): enable source maps in tsconfig * feat(gitguard): enhance installation and analysis functionalities --- README.md | 2 +- packages/TOREMOVE | 7 + packages/design-system/TEMP | 5 + packages/gitguard/.publisher/hooks/index.ts | 21 + packages/gitguard/CHANGELOG.md | 7 + packages/gitguard/IDEA.md | 23 + packages/gitguard/README.md | 133 +++ packages/gitguard/cleanup-samples.sh | 56 ++ packages/gitguard/create-samples.sh | 78 ++ packages/gitguard/gitguard-prepare.py | 795 ++++++++++++++++++ packages/gitguard/install.sh | 153 ++++ packages/gitguard/package copy.json | 47 ++ packages/gitguard/package.json | 47 ++ packages/gitguard/publisher.config.ts | 41 + packages/gitguard/src/cli.ts | 0 packages/gitguard/src/config.ts | 0 .../gitguard/src/services/analysis.service.ts | 115 +++ packages/gitguard/src/services/git.service.ts | 80 ++ .../gitguard/src/services/reporter.service.ts | 0 packages/gitguard/src/types/analysis.types.ts | 27 + packages/gitguard/src/types/commit.types.ts | 27 + packages/gitguard/src/types/config.types.ts | 11 + packages/gitguard/src/types/git.types.ts | 10 + .../gitguard/src/utils/commit-parser.util.ts | 24 + packages/gitguard/tsconfig.build.json | 7 + packages/gitguard/tsconfig.json | 42 + packages/gitguard/tsconfig.test.json | 14 + packages/publisher/CHANGELOG.md | 8 +- packages/publisher/tsconfig.json | 1 + yarn.lock | 8 + 30 files changed, 1781 insertions(+), 8 deletions(-) create mode 100644 packages/TOREMOVE create mode 100644 packages/design-system/TEMP create mode 100644 packages/gitguard/.publisher/hooks/index.ts create mode 100644 packages/gitguard/CHANGELOG.md create mode 100644 packages/gitguard/IDEA.md create mode 100644 packages/gitguard/README.md create mode 100755 packages/gitguard/cleanup-samples.sh create mode 100755 packages/gitguard/create-samples.sh create mode 100644 packages/gitguard/gitguard-prepare.py create mode 100755 packages/gitguard/install.sh create mode 100644 packages/gitguard/package copy.json create mode 100644 packages/gitguard/package.json create mode 100644 packages/gitguard/publisher.config.ts create mode 100644 packages/gitguard/src/cli.ts create mode 100644 packages/gitguard/src/config.ts create mode 100644 packages/gitguard/src/services/analysis.service.ts create mode 100644 packages/gitguard/src/services/git.service.ts create mode 100644 packages/gitguard/src/services/reporter.service.ts create mode 100644 packages/gitguard/src/types/analysis.types.ts create mode 100644 packages/gitguard/src/types/commit.types.ts create mode 100644 packages/gitguard/src/types/config.types.ts create mode 100644 packages/gitguard/src/types/git.types.ts create mode 100644 packages/gitguard/src/utils/commit-parser.util.ts create mode 100644 packages/gitguard/tsconfig.build.json create mode 100644 packages/gitguard/tsconfig.json create mode 100644 packages/gitguard/tsconfig.test.json diff --git a/README.md b/README.md index 86d2e6fb..254d4322 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ Welcome to `@siteed/universe`, a comprehensive monorepo containing a design syst ## Package Overview Here's a quick overview of the main packages in this monorepo: - - [**@siteed/design-system**](./packages/design-system/README.md): Core design system components and utilities. - [**@siteed/react-native-logger**](./packages/react-native-logger/README.md): A flexible logging solution for React Native applications. - [**@siteed/publisher**](./packages/publisher/README.md): A monorepo release management tool. +- [**@siteed/gitguard**](./packages/gitguard/README.md): Smart Git commit hook for maintaining consistent commit messages. For more detailed information about each package, please refer to their individual README files linked above. diff --git a/packages/TOREMOVE b/packages/TOREMOVE new file mode 100644 index 00000000..1518c479 --- /dev/null +++ b/packages/TOREMOVE @@ -0,0 +1,7 @@ +test +TEST +TEST +TEST +TEST +TEST +TEST diff --git a/packages/design-system/TEMP b/packages/design-system/TEMP new file mode 100644 index 00000000..f7626841 --- /dev/null +++ b/packages/design-system/TEMP @@ -0,0 +1,5 @@ +test +test +test +test +test diff --git a/packages/gitguard/.publisher/hooks/index.ts b/packages/gitguard/.publisher/hooks/index.ts new file mode 100644 index 00000000..ac8e843c --- /dev/null +++ b/packages/gitguard/.publisher/hooks/index.ts @@ -0,0 +1,21 @@ +import type { PackageContext } from '@siteed/publisher'; +import { exec } from '@siteed/publisher'; + +export async function preRelease(context: PackageContext): Promise { + // Run tests + await exec('{{packageManager}} test', { cwd: context.path }); + + // Run type checking + await exec('{{packageManager}} typecheck', { cwd: context.path }); + + // Build the package + await exec('{{packageManager}} build', { cwd: context.path }); +} + +export async function postRelease(context: PackageContext): Promise { + // Clean up build artifacts + await exec('{{packageManager}} clean', { cwd: context.path }); + + // Run any post-release notifications or integrations + console.log(`Successfully released ${context.name}@${context.newVersion}`); +} diff --git a/packages/gitguard/CHANGELOG.md b/packages/gitguard/CHANGELOG.md new file mode 100644 index 00000000..81739212 --- /dev/null +++ b/packages/gitguard/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +[unreleased]: https://github.com/owner/repo/tree/HEAD \ No newline at end of file diff --git a/packages/gitguard/IDEA.md b/packages/gitguard/IDEA.md new file mode 100644 index 00000000..62049f76 --- /dev/null +++ b/packages/gitguard/IDEA.md @@ -0,0 +1,23 @@ +# @siteed/gitguard + +A smart git assistant that improves commit quality using AI. It analyzes your changes, suggests meaningful commit messages, and helps maintain a clean git history in both standard and monorepo projects. + +## Core Features + +- šŸ¤– **Smart Git Wrapper** + - Intercepts git commits to suggest improvements + - Preserves existing git workflow + - Zero config needed for basic usage + - Automatic repository structure detection + +- šŸ” **Intelligent Change Analysis** + - Parses and categorizes git diffs + - Groups related changes + - Detects breaking changes + - Creates meaningful summaries + +- šŸ“¦ **Monorepo Support** + - Automatic package detection + - Smart commit splitting + - Dependency-aware commit ordering + - Scope suggestions diff --git a/packages/gitguard/README.md b/packages/gitguard/README.md new file mode 100644 index 00000000..09cf1b43 --- /dev/null +++ b/packages/gitguard/README.md @@ -0,0 +1,133 @@ +# GitGuard + +A smart Git commit hook that helps maintain high-quality, consistent commit messages using AI. + +## Installation + +### Quick Install (Recommended) +```bash +curl -sSL https://raw.githubusercontent.com/deeeed/universe/main/packages/gitguard/install.sh | CURL_INSTALL=1 bash +``` + +### Development Install +If you're working on GitGuard itself: +```bash +git clone https://github.com/deeeed/universe.git +cd universe +yarn install +cd packages/gitguard +./install.sh +``` + +## Configuration + +1. Create a configuration file (optional): + - Global: `~/.gitguard/config.json` + - Project: `.gitguard/config.json` + +2. Set up environment variables (optional): + - `AZURE_OPENAI_API_KEY` - for Azure OpenAI integration + - `GITGUARD_USE_AI=1` - to enable AI suggestions + - `GITGUARD_DEBUG=1` - to enable debug logging + +For more information, visit the [GitGuard documentation](https://deeeed.github.io/universe/packages/gitguard). + +## Features + +- šŸŽÆ **Automatic Scope Detection**: Automatically detects the package scope based on changed files +- šŸ¤– **Multi-Provider AI Suggestions**: Offers intelligent commit message suggestions using: + - Azure OpenAI (with fallback model support) + - Local Ollama models +- šŸ“¦ **Monorepo Awareness**: Detects changes across multiple packages and suggests appropriate formatting +- āœØ **Conventional Commits**: Enforces conventional commit format (`type(scope): description`) +- šŸ” **Change Analysis**: Analyzes file changes to suggest appropriate commit types +- šŸšØ **Multi-Package Warning**: Alerts when changes span multiple packages, encouraging atomic commits + +## How It Works + +1. When you create a commit, the hook analyzes your staged changes +2. If changes span multiple packages, it warns you and suggests splitting the commit +3. You can request AI suggestions, which will provide 3 different commit message options with explanations +4. If you skip AI suggestions or prefer manual input, it helps format your message with the correct scope and type +5. For multi-package changes, it automatically adds an "Affected packages" section + +## Example Usage + +```bash +# Regular commit +git commit -m "update login form" +# GitGuard will transform to: feat(auth): update login form + +# Multi-package changes +git commit -m "update theme colors" +# GitGuard will warn about multiple packages and suggest: +# style(design-system): update theme colors +# +# Affected packages: +# - @siteed/design-system +# - @siteed/mobile-components +``` + +## Configuration + +GitGuard can be configured using: +- Global config: `~/.gitguard/config.json` +- Local repo config: `.gitguard/config.json` +- Environment variables + +### Configuration Options + +```json +{ + "auto_mode": false, // Skip prompts and use automatic formatting + "use_ai": false, // Enable/disable AI suggestions by default + "ai_provider": "azure", // AI provider to use ("azure" or "ollama") + + // Azure OpenAI Configuration + "azure_endpoint": "", // Azure OpenAI endpoint + "azure_deployment": "", // Primary Azure OpenAI deployment name + "azure_fallback_deployment": "", // Fallback model if primary fails + "azure_api_version": "", // Azure OpenAI API version + + // Ollama Configuration + "ollama_host": "http://localhost:11434", // Ollama API host + "ollama_model": "codellama", // Ollama model to use + + "debug": false // Enable debug logging +} +``` + +### Environment Variables + +- `GITGUARD_AUTO`: Enable automatic mode (1/true/yes) +- `GITGUARD_USE_AI`: Enable AI suggestions (1/true/yes) +- `GITGUARD_AI_PROVIDER`: AI provider to use ("azure" or "ollama") + +Azure OpenAI Variables: +- `AZURE_OPENAI_ENDPOINT`: Azure OpenAI endpoint +- `AZURE_OPENAI_API_KEY`: Azure OpenAI API key +- `AZURE_OPENAI_DEPLOYMENT`: Azure OpenAI deployment name +- `AZURE_OPENAI_API_VERSION`: Azure OpenAI API version + +Ollama Variables: +- `OLLAMA_HOST`: Ollama API host +- `OLLAMA_MODEL`: Ollama model to use + +Debug Variables: +- `GITGUARD_DEBUG`: Enable debug logging (1/true/yes) + +### AI Provider Configuration + +#### Azure OpenAI +GitGuard supports Azure OpenAI with fallback model capability. If the primary model fails (e.g., rate limits), it will automatically try the fallback model. + +```json +{ + "ai_provider": "azure", + "azure_deployment": "gpt-4", + "azure_fallback_deployment": "gpt-35-turbo" +} +``` + +#### Ollama +For local AI processing, GitGuard supports Ollama. Make sure Ollama is running. diff --git a/packages/gitguard/cleanup-samples.sh b/packages/gitguard/cleanup-samples.sh new file mode 100755 index 00000000..692f4941 --- /dev/null +++ b/packages/gitguard/cleanup-samples.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +# Exit on any error +set -e + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Find git root directory +GIT_ROOT=$(git rev-parse --show-toplevel) +if [ $? -ne 0 ]; then + echo -e "${RED}āŒ Error: Not in a git repository${NC}" + exit 1 +fi + +# List of sample files and directories to remove +SAMPLE_PATHS=( + "packages/ui/src/Button.tsx" + "packages/ui/tests/Button.test.tsx" + "packages/ui/package.json" + "packages/core/src/utils.ts" + "packages/core/package.json" + "docs/README.md" +) + +# Remove sample files +for path in "${SAMPLE_PATHS[@]}"; do + full_path="$GIT_ROOT/$path" + if [ -f "$full_path" ]; then + rm "$full_path" + echo "Removed: $path" + fi +done + +# Clean up empty directories +SAMPLE_DIRS=( + "packages/ui/src" + "packages/ui/tests" + "packages/ui" + "packages/core/src" + "packages/core" + "docs" +) + +for dir in "${SAMPLE_DIRS[@]}"; do + full_dir="$GIT_ROOT/$dir" + if [ -d "$full_dir" ] && [ -z "$(ls -A $full_dir)" ]; then + rmdir "$full_dir" + echo "Removed empty directory: $dir" + fi +done + +echo -e "${GREEN}āœ… Sample files cleaned up successfully!${NC}" diff --git a/packages/gitguard/create-samples.sh b/packages/gitguard/create-samples.sh new file mode 100755 index 00000000..195d30f2 --- /dev/null +++ b/packages/gitguard/create-samples.sh @@ -0,0 +1,78 @@ +#!/bin/bash + +# Exit on any error +set -e + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Find git root directory +GIT_ROOT=$(git rev-parse --show-toplevel) +if [ $? -ne 0 ]; then + echo -e "${RED}āŒ Error: Not in a git repository${NC}" + exit 1 +fi + +# Create sample directories +mkdir -p "$GIT_ROOT/packages/ui/src" +mkdir -p "$GIT_ROOT/packages/ui/tests" +mkdir -p "$GIT_ROOT/packages/core/src" +mkdir -p "$GIT_ROOT/docs" + +# Create sample files +cat > "$GIT_ROOT/packages/ui/package.json" << 'EOF' +{ + "name": "@project/ui", + "version": "1.0.0" +} +EOF + +# Note the use of 'EOFBUTTON' to avoid confusion with backticks +cat > "$GIT_ROOT/packages/ui/src/Button.tsx" << 'EOFBUTTON' +import styled from 'styled-components'; + +export const Button = styled.button` + background: blue; + color: white; +`; +EOFBUTTON + +cat > "$GIT_ROOT/packages/ui/tests/Button.test.tsx" << 'EOF' +import { render } from '@testing-library/react'; +import { Button } from '../src/Button'; + +describe('Button', () => { + it('renders correctly', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); +EOF + +cat > "$GIT_ROOT/packages/core/package.json" << 'EOF' +{ + "name": "@project/core", + "version": "1.0.0" +} +EOF + +cat > "$GIT_ROOT/packages/core/src/utils.ts" << 'EOF' +export function formatDate(date: Date): string { + return date.toISOString(); +} +EOF + +cat > "$GIT_ROOT/docs/README.md" << 'EOF' +# Project Documentation +This is a sample documentation file. +EOF + +echo -e "${GREEN}āœ… Sample files created successfully!${NC}" +echo -e "${YELLOW}Try creating commits with changes in different files to test GitGuard:${NC}" +echo "- UI component changes (packages/ui/src/Button.tsx)" +echo "- Test file changes (packages/ui/tests/Button.test.tsx)" +echo "- Core utility changes (packages/core/src/utils.ts)" +echo "- Documentation changes (docs/README.md)" diff --git a/packages/gitguard/gitguard-prepare.py b/packages/gitguard/gitguard-prepare.py new file mode 100644 index 00000000..54968dd2 --- /dev/null +++ b/packages/gitguard/gitguard-prepare.py @@ -0,0 +1,795 @@ +#!/usr/bin/env python3 +"""GitGuard - A tool to help maintain consistent git commit messages.""" + +import sys +import os +import requests +import re +import json +from pathlib import Path +from subprocess import check_output +from typing import Dict, List, Optional, Set, Any +from collections import defaultdict + + +# Try to import optional dependencies / check +try: + from openai import AzureOpenAI + HAS_OPENAI = True +except ImportError: + HAS_OPENAI = False + +try: + import tiktoken + HAS_TIKTOKEN = True +except ImportError: + HAS_TIKTOKEN = False + +class Config: + """Configuration handler with global and local settings.""" + DEFAULT_CONFIG = { + "auto_mode": False, + "use_ai": True, + "ai_provider": "azure", # Can be 'azure' or 'ollama' + "azure_endpoint": "https://your-endpoint.openai.azure.com/", + "azure_deployment": "gpt-4", + "azure_api_version": "2024-05-13", + "ollama_host": "http://localhost:11434", + "ollama_model": "codellama", + "debug": False, + } + + def __init__(self): + self._config = self.DEFAULT_CONFIG.copy() + self._load_configurations() + + def _load_json_file(self, path: Path) -> Dict: + try: + if path.exists(): + return json.loads(path.read_text()) + except Exception as e: + if self._config.get("debug"): + print(f"āš ļø Error loading config from {path}: {e}") + return {} + + def _load_configurations(self): + # 1. Global configuration + global_config = self._load_json_file(Path.home() / ".gitguard" / "config.json") + self._config.update(global_config) + + # 2. Local configuration + try: + git_root = Path(check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip()) + local_config = self._load_json_file(git_root / ".gitguard" / "config.json") + self._config.update(local_config) + except Exception: + pass + + # 3. Environment variables + env_mappings = { + "GITGUARD_AUTO": ("auto_mode", lambda x: x.lower() in ("1", "true", "yes")), + "GITGUARD_USE_AI": ("use_ai", lambda x: x.lower() in ("1", "true", "yes")), + "AZURE_OPENAI_ENDPOINT": ("azure_endpoint", str), + "AZURE_OPENAI_DEPLOYMENT": ("azure_deployment", str), + "AZURE_OPENAI_API_VERSION": ("azure_api_version", str), + "GITGUARD_DEBUG": ("debug", lambda x: x.lower() in ("1", "true", "yes")), + } + + for env_var, (config_key, transform) in env_mappings.items(): + if (value := os.environ.get(env_var)) is not None: + self._config[config_key] = transform(value) + + if self._config.get("debug"): + print("\nšŸ”§ Active configuration:", json.dumps(self._config, indent=2)) + + def get(self, key: str, default=None): + return self._config.get(key, default) + +class OllamaClient: + """Client for interacting with Ollama API.""" + def __init__(self, host: str, model: str): + self.host = host.rstrip("/") + self.model = model + + def generate(self, prompt: str, original_message: str) -> Optional[List[Dict[str, str]]]: + """Generate commit message suggestions using Ollama.""" + try: + response = requests.post( + f"{self.host}/api/generate", + json={ + "model": self.model, + "prompt": prompt, + "stream": False, + }, + ) + response.raise_for_status() + + result = response.json() + response_text = result.get("response", "") + + try: + # Find JSON object in the response + start = response_text.find("{") + end = response_text.rfind("}") + 1 + if start >= 0 and end > start: + json_str = response_text[start:end] + suggestions = json.loads(json_str).get("suggestions", []) + return suggestions[:3] + except json.JSONDecodeError: + print("\nāš ļø Failed to parse Ollama response as JSON") + + # Fallback: Create a single suggestion from the raw response + return [{ + "message": response_text.split("\n")[0], + "explanation": "Generated by Ollama", + "type": "feat", + "scope": "default", + "description": response_text, + }] + + except Exception as e: + print(f"\nāš ļø Ollama API error: {str(e)}") + return None + +def debug_log(message: str, title: str = "Debug", separator: bool = True) -> None: + """Print debug messages in a clearly visible format.""" + if not Config().get("debug"): + return + + print("\n" + "ā•" * 80) + print(f"šŸ” {title.upper()}") + print("ā•" * 80) + print(message) + if separator: + print("ā•" * 80) + +def calculate_commit_complexity(packages: List[Dict[str, Any]]) -> Dict[str, Any]: + """Calculate commit complexity metrics to determine if structured format is needed.""" + complexity = { + "score": 0, + "reasons": [], + "needs_structure": False + } + + # 1. Multiple packages changes (most significant factor) + if len(packages) > 1: + complexity["score"] += 3 + complexity["reasons"].append("Changes span multiple packages") + + # 2. Number of files changed + total_files = sum(len(pkg["files"]) for pkg in packages) + if total_files > 3: + complexity["score"] += min(total_files - 3, 5) # Cap at 5 points + complexity["reasons"].append(f"Large number of files changed ({total_files})") + + # 3. Mixed content types (e.g., code + tests + config) + content_types = set() + for pkg in packages: + for file in pkg["files"]: + if file.endswith(('.test.ts', '.test.js', '.spec.ts', '.spec.js')): + content_types.add('test') + elif file.endswith(('.json', '.yml', '.yaml', '.config.js')): + content_types.add('config') + elif file.endswith(('.css', '.scss', '.less')): + content_types.add('styles') + elif file.endswith(('.ts', '.js', '.tsx', '.jsx')): + content_types.add('code') + + if len(content_types) > 2: + complexity["score"] += 2 + complexity["reasons"].append("Multiple content types modified") + + # Determine if structured commit is needed (threshold = 5) + complexity["needs_structure"] = complexity["score"] >= 5 + + if Config().get("debug"): + debug_log( + f"Score: {complexity['score']}\nReasons:\n" + + "\n".join(f"- {reason}" for reason in complexity["reasons"]), + "Complexity Analysis šŸ“Š" + ) + + return complexity + +def group_files_by_type(files: List[str]) -> Dict[str, List[str]]: + """Group files by their type for better readability.""" + groups = { + "Tests": [], + "Config": [], + "Styles": [], + "Source": [] + } + + for file in files: + if file.endswith(('.test.ts', '.test.js', '.spec.ts', '.spec.js')): + groups["Tests"].append(file) + elif file.endswith(('.json', '.yml', '.yaml', '.config.js')): + groups["Config"].append(file) + elif file.endswith(('.css', '.scss', '.less')): + groups["Styles"].append(file) + else: + groups["Source"].append(file) + + # Return only non-empty groups + return {k: v for k, v in groups.items() if v} + +def enhance_ai_prompt(packages: List[Dict], original_msg: str) -> str: + """Generate detailed AI prompt based on commit complexity analysis.""" + complexity = calculate_commit_complexity(packages) + + try: + diff = check_output(["git", "diff", "--cached"]).decode("utf-8") + except: + diff = "Failed to get diff" + + # Build comprehensive analysis for AI + analysis = { + "complexity_score": complexity["score"], + "complexity_reasons": complexity["reasons"], + "packages": [] + } + + for pkg in packages: + files_by_type = group_files_by_type(pkg["files"]) + analysis["packages"].append({ + "name": pkg["name"], + "scope": pkg["scope"], + "files_by_type": files_by_type + }) + + prompt = f"""Analyze the following git changes and suggest a commit message. + +Complexity Analysis: +- Score: {complexity['score']} (threshold for structured format: 5) +- Factors: {', '.join(complexity['reasons'])} + +Changed Packages:""" + + for pkg in analysis["packages"]: + prompt += f"\n\nšŸ“¦ {pkg['name']} ({pkg['scope']})" + for file_type, files in pkg["files_by_type"].items(): + prompt += f"\n{file_type}:" + for file in files: + prompt += f"\n - {file}" + + prompt += f""" + +Original message: "{original_msg}" + +Git diff: +```diff +{diff} +``` + +Please provide 3 conventional commit suggestions in this JSON format: +{{ + "suggestions": [ + {{ + "message": "complete commit message", + "explanation": "reasoning", + "type": "commit type", + "scope": "scope", + "description": "title description" + }} + ] +}}""" + + return prompt + +def count_tokens(text: str) -> int: + """Count tokens using tiktoken if available, otherwise estimate.""" + if HAS_TIKTOKEN: + try: + # Use the appropriate model encoding + encoding = tiktoken.encoding_for_model("gpt-4") + token_count = len(encoding.encode(text)) + return token_count + except Exception as e: + debug_log(f"Tiktoken error: {str(e)}, falling back to estimation", "Warning āš ļø") + return len(text) // 4 + else: + debug_log( + "Tiktoken not installed. For accurate token counting, install with:\n" + + "pip install tiktoken", + "Token Count Info ā„¹ļø" + ) + return len(text) // 4 + +def get_token_cost(token_count: int) -> str: + """Calculate cost based on current GPT-4 pricing.""" + # Current GPT-4 Turbo pricing (as of 2024) + COST_PER_1K_INPUT = 0.01 + COST_PER_1K_OUTPUT = 0.03 + + # Estimate output tokens as ~25% of input + estimated_output_tokens = token_count * 0.25 + + input_cost = (token_count / 1000) * COST_PER_1K_INPUT + output_cost = (estimated_output_tokens / 1000) * COST_PER_1K_OUTPUT + total_cost = input_cost + output_cost + + return ( + f"Input tokens: {token_count:,}\n" + f"Estimated output tokens: {int(estimated_output_tokens):,}\n" + f"Estimated total cost: ${total_cost:.4f}\n" + f" - Input cost: ${input_cost:.4f}\n" + f" - Output cost: ${output_cost:.4f}" + ) + +def get_ai_suggestion(prompt: str, original_message: str) -> Optional[List[Dict[str, str]]]: + """Get structured commit message suggestions from configured AI provider.""" + config = Config() + + # Calculate and log token usage estimation + token_count = count_tokens(prompt) + debug_log( + f"Estimated tokens: {token_count}\n" + f"Estimated cost: ${(token_count / 1000 * 0.03):.4f} (GPT-4 rate)", + "Token Usage šŸ’°" + ) + + if config.get("debug"): + debug_log(prompt, "AI Prompt") + + # Try Azure OpenAI if available + if HAS_OPENAI and config.get("ai_provider") == "azure": + api_key = config.get("azure_api_key") or os.getenv("AZURE_OPENAI_API_KEY") + if not api_key: + debug_log("No Azure OpenAI API key found in config or environment", "Warning āš ļø") + return None + + try: + client = AzureOpenAI( + api_key=api_key, + api_version=config.get("azure_api_version"), + azure_endpoint=config.get("azure_endpoint"), + ) + + # Default suggestions template + default_suggestions = [ + { + "message": original_message, + "explanation": "Original message preserved due to API error", + "type": "feat", + "scope": "default", + "description": original_message + } + ] + + # Try primary deployment (GPT-4) first + try: + debug_log(f"Attempting GPT-4 request to {config.get('azure_deployment')}", "Azure OpenAI šŸ¤–") + + response = client.chat.completions.create( + model=config.get("azure_deployment"), + messages=[ + { + "role": "system", + "content": "You are a git commit message assistant. Generate 3 distinct conventional commit format suggestions in JSON format." + }, + {"role": "user", "content": prompt} + ], + temperature=0.7, + max_tokens=1500, + n=1, + response_format={"type": "json_object"}, + ) + + result = json.loads(response.choices[0].message.content) + suggestions = result.get("suggestions", []) + + if suggestions: + debug_log( + f"Received {len(suggestions)} suggestions\n" + + json.dumps(suggestions, indent=2), + "GPT-4 Response āœØ" + ) + return suggestions[:3] # Ensure we return up to 3 suggestions + return default_suggestions + + except Exception as e: + debug_log(f"Primary model error: {str(e)}", "Error āŒ") + + # Try fallback deployment (GPT-3.5) with simplified response + fallback_deployment = config.get("azure_fallback_deployment") + if fallback_deployment: + try: + debug_log(f"Attempting fallback to {fallback_deployment}", "Fallback Model šŸ”„") + + response = client.chat.completions.create( + model=fallback_deployment, + messages=[ + {"role": "system", "content": "You are a helpful git commit message assistant."}, + {"role": "user", "content": prompt} + ], + temperature=0.7, + max_tokens=500, + n=1, + ) + + message = response.choices[0].message.content.strip() + debug_log(message, "Fallback Response šŸ“") + + return [{ + "message": message, + "explanation": "Generated using fallback model", + "type": "feat", + "scope": "default", + "description": message + }] + except Exception as fallback_error: + debug_log(f"Fallback model error: {str(fallback_error)}", "Error āŒ") + return default_suggestions + + except Exception as e: + debug_log(f"Azure OpenAI error: {str(e)}", "Error āŒ") + return default_suggestions + + # Fallback to Ollama if configured + if config.get("ai_provider") == "ollama": + debug_log("Attempting Ollama request", "Ollama šŸ¤–") + client = OllamaClient( + host=config.get("ollama_host"), + model=config.get("ollama_model") + ) + return client.generate(prompt, original_message) + + return None + +def create_commit_message(commit_info: Dict[str, Any], packages: List[Dict[str, Any]]) -> str: + """Create appropriate commit message based on complexity.""" + # Clean the description to remove any existing type prefix + description = commit_info['description'] + type_pattern = r'^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\([^)]+\))?:\s*' + if re.match(type_pattern, description): + description = re.sub(type_pattern, '', description) + commit_info['description'] = description.strip() + + # Calculate complexity + complexity = calculate_commit_complexity(packages) + + if Config().get("debug"): + print("\nšŸ” Commit Complexity Analysis:") + print(f"Score: {complexity['score']}") + print("Reasons:") + for reason in complexity["reasons"]: + print(f"- {reason}") + + # For simple commits, just return the title + if not complexity["needs_structure"]: + return f"{commit_info['type']}({commit_info['scope']}): {commit_info['description']}" + + # For complex commits, use structured format + return create_structured_commit(commit_info, packages) + +def create_structured_commit(commit_info: Dict[str, Any], packages: List[Dict[str, Any]]) -> str: + """Create a structured commit message for complex changes.""" + # Clean the description to remove any existing type prefix + description = commit_info['description'] + type_pattern = r'^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\([^)]+\))?:\s*' + if re.match(type_pattern, description): + description = re.sub(type_pattern, '', description) + + # Start with the commit title + message_parts = [ + f"{commit_info['type']}({commit_info['scope']}): {description}" + ] + + # Add a blank line after title + message_parts.append("") + + # Add detailed description if available + if description := commit_info.get("detailed_description"): + message_parts.append(description) + message_parts.append("") + + # For multiple packages, add affected packages section + if len(packages) > 1: + message_parts.append("Affected packages:") + for pkg in packages: + message_parts.append(f"- {pkg['name']}") + # Add files changed under each package (grouped by type for readability) + files_by_type = group_files_by_type(pkg["files"]) + for file_type, files in files_by_type.items(): + if files: + message_parts.append(f" {file_type}:") + for file in files[:3]: # Limit to 3 files per type + message_parts.append(f" ā€¢ {file}") + if len(files) > 3: + message_parts.append(f" ā€¢ ...and {len(files) - 3} more") + message_parts.append("") + + return "\n".join(message_parts) + +def get_package_json_name(package_path: Path) -> Optional[str]: + """Get package name from package.json if it exists.""" + try: + pkg_json_path = Path.cwd() / package_path / "package.json" + if pkg_json_path.exists(): + return json.loads(pkg_json_path.read_text()).get("name") + except: + return None + return None + +# ... [previous code remains the same until get_changed_packages] + +def get_changed_packages() -> List[Dict]: + """Get all packages with changes in the current commit.""" + try: + # Get git root directory + git_root = Path(check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip()) + current_dir = Path.cwd() + + # Get relative path from current directory to git root + try: + rel_path = current_dir.relative_to(git_root) + except ValueError: + rel_path = Path("") + + changed_files = check_output(["git", "diff", "--cached", "--name-only"], text=True) + changed_files = [f for f in changed_files.strip().split("\n") if f] + + if Config().get("debug"): + print("\nšŸ“¦ Git root:", git_root) + print("šŸ“¦ Current dir:", current_dir) + print("šŸ“¦ Relative path:", rel_path) + print("\nšŸ“¦ Staged files detected:") + for f in changed_files: + print(f" - {f}") + except Exception as e: + if Config().get("debug"): + print(f"Error getting changed files: {e}") + return [] + + packages = {} + for file in changed_files: + if not file: + continue + + if file.startswith("packages/"): + parts = file.split("/") + if len(parts) > 1: + pkg_path = f"packages/{parts[1]}" + if pkg_path not in packages: + packages[pkg_path] = [] + packages[pkg_path].append(file) + else: + if "root" not in packages: + packages["root"] = [] + packages["root"].append(file) + + results = [] + for pkg_path, files in packages.items(): + if pkg_path == "root": + scope = name = "root" + else: + pkg_name = get_package_json_name(Path(pkg_path)) + if pkg_name: + name = pkg_name + scope = pkg_name.split("/")[-1] + else: + name = scope = pkg_path.split("/")[-1] + + results.append({ + "name": name, + "scope": scope, + "files": files, + }) + + return results + +def get_main_package(packages: List[Dict]) -> Optional[Dict]: + """Get the package with the most changes.""" + if not packages: + return {"name": "default", "scope": "default", "files": []} + + # Sort packages by number of files changed + sorted_packages = sorted(packages, key=lambda x: len(x["files"]), reverse=True) + return sorted_packages[0] + +def prompt_user(question: str) -> bool: + """Prompt user for yes/no question using /dev/tty.""" + try: + with open("/dev/tty", "r", encoding="utf-8") as tty: + print(f"{question} [Y/n]", end=" ", flush=True) + response = tty.readline().strip().lower() + return response == "" or response != "n" + except Exception as e: + print(f"Warning: Could not get user input: {str(e)}") + return True + +def display_suggestions(suggestions: List[Dict[str, str]]) -> Optional[str]: + """Display suggestions and get user choice.""" + print("\nāœØ AI Suggestions:") + + for i, suggestion in enumerate(suggestions, 1): + print(f"\n{i}. {'=' * 48}") + print(f"Message: {suggestion['message']}") + print(f"Type: {suggestion['type']}") + print(f"Scope: {suggestion['scope']}") + print(f"Explanation: {suggestion['explanation']}") + print("=" * 50) + + try: + with open("/dev/tty", "r", encoding="utf-8") as tty: + while True: + print("\nChoose suggestion (1-3) or press Enter to skip: ", end="", flush=True) + choice = tty.readline().strip() + if not choice: + return None + if choice in ("1", "2", "3"): + return suggestions[int(choice) - 1]["message"] + print("Please enter 1, 2, 3 or press Enter to skip") + except EOFError: + print("\nāš ļø Input not available. Defaulting to first suggestion.") + return suggestions[0]["message"] if suggestions else None + +def analyze_commit_cohesion(packages: List[Dict[str, Any]]) -> Dict[str, Any]: + """Analyze if files in the commit are cohesive or should be split.""" + analysis = { + "should_split": False, + "reasons": [], + "primary_scope": None, + "files_to_unstage": [] + } + + if len(packages) <= 1: + return analysis + + # Find the primary package (one with most changes) + primary_package = max(packages, key=lambda x: len(x["files"])) + analysis["primary_scope"] = primary_package["scope"] + + # Check for files in different scopes + for pkg in packages: + if pkg["scope"] != primary_package["scope"]: + analysis["should_split"] = True + analysis["files_to_unstage"].extend(pkg["files"]) + analysis["reasons"].append( + f"Found {len(pkg['files'])} files in '{pkg['scope']}' scope while primary scope is '{primary_package['scope']}'" + ) + + return analysis + +def display_cohesion_warning(analysis: Dict[str, Any]) -> bool: + """Display warning about commit cohesion and get user decision.""" + try: + # Get git root to convert to absolute paths + git_root = Path(check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip()) + + # Convert to absolute paths + files_to_unstage = [ + str(git_root / file) + for file in analysis.get("files_to_unstage", []) + ] + except Exception as e: + if Config().get("debug"): + print(f"Error converting to absolute paths: {e}") + files_to_unstage = analysis.get("files_to_unstage", []) + + print("\nāš ļø Potential non-cohesive commit detected!") + print(f"\nPrimary scope: {analysis['primary_scope']}") + print("\nReasons:") + for reason in analysis["reasons"]: + print(f"- {reason}") + + print("\nFiles that should be in separate commits:") + # Show relative paths in the display for readability + for file in analysis.get("files_to_unstage", []): + print(f" - {file}") + + if not prompt_user("\nWould you like to clean up this commit?"): + return True + + # Generate unstage command with absolute paths + unstage_cmd = "git reset HEAD " + " ".join(f'"{f}"' for f in files_to_unstage) + + # Try to copy to clipboard + copied = try_copy_to_clipboard(unstage_cmd) + + print("\nāŒ Commit aborted. To fix:") + if copied: + print("1. Command to unstage unrelated files has been copied to your clipboard:") + print(f" {unstage_cmd}") + print("2. Paste and run the command") + else: + print("1. Run this command to unstage unrelated files:") + print(f" {unstage_cmd}") + print("3. Run 'git commit' again to create a clean commit") + + return False + +def try_copy_to_clipboard(text: str) -> bool: + """Try to copy text to clipboard using various methods.""" + try: + # Try pyperclip first if available + try: + import pyperclip + pyperclip.copy(text) + return True + except ImportError: + pass + + # Try pbcopy on macOS + if sys.platform == "darwin": + process = subprocess.Popen(['pbcopy'], stdin=subprocess.PIPE) + process.communicate(text.encode('utf-8')) + return True + + # Try xclip on Linux + elif sys.platform.startswith('linux'): + process = subprocess.Popen(['xclip', '-selection', 'clipboard'], stdin=subprocess.PIPE) + process.communicate(text.encode('utf-8')) + return True + + return False + except: + return False + +def main() -> None: + """Main function to process git commit messages.""" + try: + config = Config() + + commit_msg_file = sys.argv[1] + with open(commit_msg_file, "r", encoding="utf-8") as f: + original_msg = f.read().strip() + + if original_msg.startswith("Merge"): + sys.exit(0) + + packages = get_changed_packages() + if not packages: + sys.exit(0) + + print("\nšŸ” Analyzing changes...") + + # Check commit cohesion first + cohesion_analysis = analyze_commit_cohesion(packages) + if cohesion_analysis["should_split"]: + if not display_cohesion_warning(cohesion_analysis): + print("\nāŒ Commit aborted. Please split your changes into more focused commits.") + sys.exit(1) + + # Only proceed with AI and other checks if cohesion check passes + if config.get("use_ai", True): + prompt = enhance_ai_prompt(packages, original_msg) + token_count = count_tokens(prompt) + debug_log( + get_token_cost(token_count), + "Token Usage šŸ’°" + ) + + if prompt_user("\nWould you like AI suggestions?"): + print("\nšŸ¤– Getting AI suggestions...") + suggestions = get_ai_suggestion(prompt, original_msg) + if suggestions: + chosen_message = display_suggestions(suggestions) + if chosen_message: + with open(commit_msg_file, "w", encoding="utf-8") as f: + f.write(chosen_message) + print("āœ… Commit message updated!\n") + return + + # Fallback to automatic formatting + print("\nāš™ļø Using automatic formatting...") + commit_info = { + "type": "feat", # Default type + "scope": get_main_package(packages)["scope"], + "description": original_msg + } + new_msg = create_commit_message(commit_info, packages) + + print(f"\nāœØ Suggested message:\n{new_msg}") + + if prompt_user("\nUse suggested message?"): + with open(commit_msg_file, "w", encoding="utf-8") as f: + f.write(new_msg) + print("āœ… Commit message updated!\n") + + except Exception as e: + print(f"āŒ Error: {str(e)}") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/packages/gitguard/install.sh b/packages/gitguard/install.sh new file mode 100755 index 00000000..07fa5255 --- /dev/null +++ b/packages/gitguard/install.sh @@ -0,0 +1,153 @@ +#!/bin/bash + +# Exit on any error +set -e + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Function to handle direct curl installation +handle_remote_install() { + echo -e "${BLUE}Installing GitGuard from @siteed/universe...${NC}" + + # Create temporary directory + TMP_DIR=$(mktemp -d) + cleanup() { + rm -rf "$TMP_DIR" + } + trap cleanup EXIT + + # Download the script + echo -e "${YELLOW}Downloading GitGuard...${NC}" + curl -sSL https://raw.githubusercontent.com/deeeed/universe/main/packages/gitguard/gitguard-prepare.py -o "$TMP_DIR/gitguard-prepare.py" + chmod +x "$TMP_DIR/gitguard-prepare.py" + + # Install dependencies + echo -e "${YELLOW}Installing dependencies...${NC}" + python3 -m pip install --user requests openai tiktoken + + # Install the hook + if [ ! -d ".git" ]; then + echo -e "${RED}Error: Not a git repository. Please run this script from your git project root.${NC}" + exit 1 + fi + + HOOK_PATH=".git/hooks/prepare-commit-msg" + mkdir -p .git/hooks + cp "$TMP_DIR/gitguard-prepare.py" "$HOOK_PATH" + + echo -e "${GREEN}āœ… GitGuard installed successfully!${NC}" + echo -e "\n${BLUE}Next Steps:${NC}" + echo -e "1. Create a configuration file (optional):" + echo -e " ā€¢ Global: ~/.gitguard/config.json" + echo -e " ā€¢ Project: .gitguard/config.json" + echo -e "\n2. Set up environment variables (optional):" + echo -e " ā€¢ AZURE_OPENAI_API_KEY - for Azure OpenAI integration" + echo -e " ā€¢ GITGUARD_USE_AI=1 - to enable AI suggestions" +} + +# Store the script's directory for development installation +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# Your existing installation functions remain the same +check_existing_hook() { + local hook_path="$1" + if [ -f "$hook_path" ]; then + if grep -q "gitguard" "$hook_path"; then + echo "gitguard" + else + echo "other" + fi + else + echo "none" + fi +} + +install_hook() { + local target_dir="$1" + local hook_path="$target_dir/hooks/prepare-commit-msg" + mkdir -p "$target_dir/hooks" + cp "$SCRIPT_DIR/gitguard-prepare.py" "$hook_path" + chmod +x "$hook_path" +} + +handle_installation() { + local target_dir="$1" + local install_type="$2" + local hook_path="$target_dir/hooks/prepare-commit-msg" + + # Check existing hook + local existing_hook=$(check_existing_hook "$hook_path") + + if [ "$existing_hook" = "gitguard" ]; then + echo -e "${YELLOW}āš ļø GitGuard is already installed for this $install_type installation${NC}" + read -p "Do you want to reinstall? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + return + fi + elif [ "$existing_hook" = "other" ]; then + echo -e "${RED}āš ļø Another prepare-commit-msg hook exists at: $hook_path${NC}" + read -p "Do you want to overwrite it? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo -e "${YELLOW}Skipping $install_type installation${NC}" + return + fi + fi + + # Install the hook + install_hook "$target_dir" + echo -e "${GREEN}āœ… GitGuard installed successfully for $install_type use!${NC}" +} + +# Main installation logic +main() { + # Development installation flow + echo -e "${BLUE}Welcome to GitGuard Development Installation!${NC}" + + # Check if script exists + if [ ! -f "$SCRIPT_DIR/gitguard-prepare.py" ]; then + echo -e "${RED}āŒ Error: Could not find gitguard-prepare.py in $SCRIPT_DIR${NC}" + exit 1 + fi + + # Rest of your existing installation logic... + if git rev-parse --git-dir > /dev/null 2>&1; then + GIT_PROJECT_DIR="$(git rev-parse --git-dir)" + echo -e "šŸ“ Current project: $(git rev-parse --show-toplevel)" + + read -p "Do you want to install GitGuard for this project? (Y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Nn]$ ]]; then + handle_installation "$GIT_PROJECT_DIR" "project" + fi + else + echo -e "${YELLOW}āš ļø Not in a git repository - skipping project installation${NC}" + fi + + # Ask about global installation + read -p "Do you want to install GitGuard globally? (y/N) " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + GLOBAL_GIT_DIR="$(git config --global core.hooksPath)" + if [ -z "$GLOBAL_GIT_DIR" ]; then + GLOBAL_GIT_DIR="$HOME/.git/hooks" + git config --global core.hooksPath "$GLOBAL_GIT_DIR" + fi + handle_installation "$GLOBAL_GIT_DIR" "global" + fi +} + +# Check how the script was invoked +if [ "${BASH_SOURCE[0]}" -ef "$0" ]; then + if [ -n "$CURL_INSTALL" ] || [ "$1" = "--remote" ]; then + handle_remote_install + else + main + fi +fi diff --git a/packages/gitguard/package copy.json b/packages/gitguard/package copy.json new file mode 100644 index 00000000..65cdd483 --- /dev/null +++ b/packages/gitguard/package copy.json @@ -0,0 +1,47 @@ +{ + "name": "@siteed/gitguard", + "packageManager": "yarn@4.5.0", + "version": "0.1.0", + "description": "GitGuard is a tool that helps you enforce best practices in your Git commit messages.", + "author": "Arthur Breton (https://github.com/deeeed)", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/deeeed/universe", + "directory": "packages/gitguard" + }, + "main": "dist/src/index.js", + "module": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "files": [ + "dist", + "package.json", + "README.md" + ], + "scripts": { + "clean": "rimraf ./dist", + "build": "yarn build:tsc", + "build:clean": "yarn clean && yarn build", + "build:tsc": "tsc --project tsconfig.build.json", + "typecheck": "tsc -p tsconfig.test.json --noEmit && tsc -p tsconfig.json --noEmit", + "test": "jest" + }, + "dependencies": { + "commander": "^11.1.0" + }, + "devDependencies": { + "@siteed/publisher": "workspace:^", + "@typescript-eslint/eslint-plugin": "^6.7.3", + "@typescript-eslint/parser": "^6.7.3", + "@types/jest": "^29.5.14", + "@types/node": "^20.8.0", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.1", + "ts-jest": "^29.1.1", + "rimraf": "^5.0.5", + "ts-node": "^10.9.2", + "@jest/types": "^29.6.3", + "typescript": "^5.2.2" + } +} diff --git a/packages/gitguard/package.json b/packages/gitguard/package.json new file mode 100644 index 00000000..65cdd483 --- /dev/null +++ b/packages/gitguard/package.json @@ -0,0 +1,47 @@ +{ + "name": "@siteed/gitguard", + "packageManager": "yarn@4.5.0", + "version": "0.1.0", + "description": "GitGuard is a tool that helps you enforce best practices in your Git commit messages.", + "author": "Arthur Breton (https://github.com/deeeed)", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/deeeed/universe", + "directory": "packages/gitguard" + }, + "main": "dist/src/index.js", + "module": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "files": [ + "dist", + "package.json", + "README.md" + ], + "scripts": { + "clean": "rimraf ./dist", + "build": "yarn build:tsc", + "build:clean": "yarn clean && yarn build", + "build:tsc": "tsc --project tsconfig.build.json", + "typecheck": "tsc -p tsconfig.test.json --noEmit && tsc -p tsconfig.json --noEmit", + "test": "jest" + }, + "dependencies": { + "commander": "^11.1.0" + }, + "devDependencies": { + "@siteed/publisher": "workspace:^", + "@typescript-eslint/eslint-plugin": "^6.7.3", + "@typescript-eslint/parser": "^6.7.3", + "@types/jest": "^29.5.14", + "@types/node": "^20.8.0", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.1", + "ts-jest": "^29.1.1", + "rimraf": "^5.0.5", + "ts-node": "^10.9.2", + "@jest/types": "^29.6.3", + "typescript": "^5.2.2" + } +} diff --git a/packages/gitguard/publisher.config.ts b/packages/gitguard/publisher.config.ts new file mode 100644 index 00000000..ad89aeeb --- /dev/null +++ b/packages/gitguard/publisher.config.ts @@ -0,0 +1,41 @@ +import type { ReleaseConfig, DeepPartial } from '@siteed/publisher'; + +const config: DeepPartial = { + "packageManager": "yarn", + "changelogFile": "CHANGELOG.md", + "conventionalCommits": true, + "changelogFormat": "conventional", + "versionStrategy": "independent", + "bumpStrategy": "prompt", + "packValidation": { + "enabled": true, + "validateFiles": true, + "validateBuildArtifacts": true + }, + "git": { + "tagPrefix": "gitguard@", + "requireCleanWorkingDirectory": true, + "requireUpToDate": true, + "requireUpstreamTracking": true, + "commit": true, + "push": true, + "commitMessage": "chore(release): release gitguard@${version}", + "tag": true, + "allowedBranches": [ + "main", + "master" + ], + "remote": "origin" + }, + "npm": { + "publish": true, + "registry": "https://registry.npmjs.org", + "tag": "latest", + "access": "public" + }, + "hooks": {}, + "updateDependenciesOnRelease": false, + "dependencyUpdateStrategy": "none" +}; + +export default config; \ No newline at end of file diff --git a/packages/gitguard/src/cli.ts b/packages/gitguard/src/cli.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/gitguard/src/config.ts b/packages/gitguard/src/config.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/gitguard/src/services/analysis.service.ts b/packages/gitguard/src/services/analysis.service.ts new file mode 100644 index 00000000..70e276e4 --- /dev/null +++ b/packages/gitguard/src/services/analysis.service.ts @@ -0,0 +1,115 @@ +import { + AnalysisResult, + AnalysisOptions, + AnalysisStats, + AnalysisWarning + } from '../types/analysis.types'; + import { Config } from '../types/config.types'; + import { CommitInfo } from '../types/commit.types'; + import { GitService } from './git.service'; + + export class AnalysisService { + private git: GitService; + + constructor(params: { config: Config }) { + this.git = new GitService({ config: params.config.git }); + } + + async analyze(params: AnalysisOptions): Promise { + const branch = params.branch || await this.git.getCurrentBranch(); + const commits = await this.git.getCommits({ + from: this.git.config.baseBranch, + to: branch + }); + + const stats = this.calculateStats({ commits }); + const warnings = this.generateWarnings({ + commits, + stats + }); + + return { + branch, + baseBranch: this.git.config.baseBranch, + commits, + stats, + warnings + }; + } + + private calculateStats(params: { + commits: CommitInfo[] + }): AnalysisStats { + const { commits } = params; + const filesChanged = new Set(); + let additions = 0; + let deletions = 0; + + commits.forEach(commit => { + commit.files.forEach(file => { + filesChanged.add(file.path); + additions += file.additions; + deletions += file.deletions; + }); + }); + + return { + totalCommits: commits.length, + filesChanged: filesChanged.size, + additions, + deletions + }; + } + + private generateWarnings(params: { + commits: CommitInfo[]; + stats: AnalysisStats; + }): AnalysisWarning[] { + const { commits, stats } = params; + const warnings: AnalysisWarning[] = []; + + // Check for large PR + if (stats.filesChanged > 10) { + warnings.push({ + type: 'general', + severity: 'warning', + message: `Large PR detected: ${stats.filesChanged} files changed` + }); + } + + // Check for large commits + commits.forEach(commit => { + const totalChanges = commit.files.reduce( + (sum, file) => sum + file.additions + file.deletions, + 0 + ); + + if (totalChanges > 300) { + warnings.push({ + type: 'commit', + severity: 'warning', + message: `Large commit detected: ${commit.hash.slice(0, 7)} with ${totalChanges} changes` + }); + } + + // Check conventional commit format + if (!this.isValidConventionalCommit(commit)) { + warnings.push({ + type: 'commit', + severity: 'error', + message: `Invalid conventional commit format: ${commit.hash.slice(0, 7)}` + }); + } + }); + + return warnings; + } + + private isValidConventionalCommit(commit: CommitInfo): boolean { + return Boolean( + commit.parsed.type && + commit.parsed.description && + commit.message.match(/^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\([^)]+\))?: .+/) + ); + } + } diff --git a/packages/gitguard/src/services/git.service.ts b/packages/gitguard/src/services/git.service.ts new file mode 100644 index 00000000..a1e2a360 --- /dev/null +++ b/packages/gitguard/src/services/git.service.ts @@ -0,0 +1,80 @@ +import { GitConfig, GitCommandParams } from '../types/git.types'; +import { CommitInfo, FileChange } from '../types/commit.types'; +import { CommitParser } from '../utils/commit-parser.util'; + +export class GitService { + private parser: CommitParser; + private readonly gitConfig: GitConfig; + + constructor(params: { config: GitConfig }) { + this.gitConfig = params.config; + this.parser = new CommitParser(); + } + + // Add getter for config + public get config(): GitConfig { + return this.gitConfig; + } + + async getCurrentBranch(): Promise { + const result = await this.execGit({ + command: 'rev-parse', + args: ['--abbrev-ref', 'HEAD'] + }); + return result.trim(); + } + + async getCommits(params: { + from: string; + to: string; + }): Promise { + const output = await this.execGit({ + command: 'log', + args: [ + '--format=%H%n%an%n%aI%n%B%n--END--', + `${params.from}..${params.to}` + ] + }); + + const commits = this.parser.parseCommitLog({ log: output }); + return this.attachFileChanges({ commits }); + } + + private async attachFileChanges(params: { + commits: Omit[] + }): Promise { + return Promise.all( + params.commits.map(async commit => ({ + ...commit, + files: await this.getFileChanges({ commit: commit.hash }) + })) + ); + } + + private async getFileChanges(params: { + commit: string + }): Promise { + const output = await this.execGit({ + command: 'show', + args: ['--numstat', '--format=', params.commit] + }); + + return this.parser.parseFileChanges({ numstat: output }); + } + + private async execGit(params: GitCommandParams): Promise { + const { exec } = await import('child_process'); + const { promisify } = await import('util'); + const execAsync = promisify(exec); + + const { stdout, stderr } = await execAsync( + `git ${params.command} ${params.args.join(' ')}` + ); + + if (stderr) { + throw new Error(`Git error: ${stderr}`); + } + + return stdout; + } +} diff --git a/packages/gitguard/src/services/reporter.service.ts b/packages/gitguard/src/services/reporter.service.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/gitguard/src/types/analysis.types.ts b/packages/gitguard/src/types/analysis.types.ts new file mode 100644 index 00000000..7a0af446 --- /dev/null +++ b/packages/gitguard/src/types/analysis.types.ts @@ -0,0 +1,27 @@ +import { CommitInfo } from "./commit.types"; + +export interface AnalysisResult { + branch: string; + baseBranch: string; + commits: CommitInfo[]; + stats: AnalysisStats; + warnings: AnalysisWarning[]; + } + + export interface AnalysisStats { + totalCommits: number; + filesChanged: number; + additions: number; + deletions: number; + } + + export interface AnalysisWarning { + type: 'commit' | 'file' | 'general'; + message: string; + severity: 'info' | 'warning' | 'error'; + } + + export interface AnalysisOptions { + branch?: string; + includeDrafts?: boolean; + } diff --git a/packages/gitguard/src/types/commit.types.ts b/packages/gitguard/src/types/commit.types.ts new file mode 100644 index 00000000..8ac8a898 --- /dev/null +++ b/packages/gitguard/src/types/commit.types.ts @@ -0,0 +1,27 @@ +export type CommitType = 'feat' | 'fix' | 'docs' | 'style' | 'refactor' | + 'test' | 'chore' | 'build' | 'ci' | 'perf' | 'revert'; + +export interface ParsedCommit { + type: CommitType; + scope: string | null; + description: string; + body: string | null; + breaking: boolean; +} + +export interface CommitInfo { + hash: string; + author: string; + date: Date; + message: string; + parsed: ParsedCommit; + files: FileChange[]; +} + +export interface FileChange { + path: string; + additions: number; + deletions: number; + isTest: boolean; + isConfig: boolean; +} diff --git a/packages/gitguard/src/types/config.types.ts b/packages/gitguard/src/types/config.types.ts new file mode 100644 index 00000000..f8ddd34f --- /dev/null +++ b/packages/gitguard/src/types/config.types.ts @@ -0,0 +1,11 @@ +import { GitConfig } from "./git.types"; + +export interface Config { + git: GitConfig; + analysis: { + maxCommitSize: number; + maxFileSize: number; + checkConventionalCommits: boolean; + }; + } + \ No newline at end of file diff --git a/packages/gitguard/src/types/git.types.ts b/packages/gitguard/src/types/git.types.ts new file mode 100644 index 00000000..23b2910f --- /dev/null +++ b/packages/gitguard/src/types/git.types.ts @@ -0,0 +1,10 @@ +export interface GitCommandParams { + command: string; + args: string[]; + } + + export interface GitConfig { + baseBranch: string; + ignorePatterns?: string[]; + } + \ No newline at end of file diff --git a/packages/gitguard/src/utils/commit-parser.util.ts b/packages/gitguard/src/utils/commit-parser.util.ts new file mode 100644 index 00000000..34e0230e --- /dev/null +++ b/packages/gitguard/src/utils/commit-parser.util.ts @@ -0,0 +1,24 @@ +import { CommitInfo, ParsedCommit, FileChange } from '../types/commit.types'; + +export class CommitParser { + parseCommitLog(params: { log: string }): Omit[] { + // Implementation + return []; + } + + parseFileChanges(params: { numstat: string }): FileChange[] { + // Implementation + return []; + } + + private parseCommitMessage(params: { message: string }): ParsedCommit { + // Implementation + return { + type: 'feat', + scope: null, + description: '', + body: null, + breaking: false + }; + } +} diff --git a/packages/gitguard/tsconfig.build.json b/packages/gitguard/tsconfig.build.json new file mode 100644 index 00000000..4f90cf02 --- /dev/null +++ b/packages/gitguard/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"] // Override to exclude jest types in production + }, + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/__tests__/**", "**/__mocks__/**", "jest.setup.ts", "jest.config.ts"] +} diff --git a/packages/gitguard/tsconfig.json b/packages/gitguard/tsconfig.json new file mode 100644 index 00000000..adbc1b92 --- /dev/null +++ b/packages/gitguard/tsconfig.json @@ -0,0 +1,42 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": ".", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "declaration": true, + "sourceMap": true, + "baseUrl": ".", + "paths": { + "@siteed/gitguard": ["./src/index.ts"] + } + }, + "include": [ + "src/**/*", + "publisher.config.ts", + ".publisher/**/*", + "package.json", + "jest.config.ts", + "jest.setup.ts" + ], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/gitguard/tsconfig.test.json b/packages/gitguard/tsconfig.test.json new file mode 100644 index 00000000..050a7d76 --- /dev/null +++ b/packages/gitguard/tsconfig.test.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "jest"], + "esModuleInterop": true + }, + "include": [ + "src/**/*", + "**/*.test.ts", + "jest.setup.ts", + "jest.config.ts" + ], + "exclude": ["node_modules", "dist"] + } diff --git a/packages/publisher/CHANGELOG.md b/packages/publisher/CHANGELOG.md index 78576070..90e5e3dd 100644 --- a/packages/publisher/CHANGELOG.md +++ b/packages/publisher/CHANGELOG.md @@ -13,28 +13,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - filter autochangelog by package ([c3b1221](https://github.com/deeeed/universe/commit/c3b12212c7dfd7c6fa630ec1541fc198120a1a43)) - chore(release): release @siteed/publisher@0.4.14 ([11f1b85](https://github.com/deeeed/universe/commit/11f1b85603d910d10e6ee963ccd9784624921cec)) + ## [0.4.14] - 2024-10-31 - changelog update with commit links - proper changelog generation - docs: cleanup changelog - ## [0.4.12] - 2024-10-31 - fix: invalid changelog format - - ## [0.4.11] - 2024-10-31 - āš ļø **WARNING: DEVELOPMENT IN PROGRESS** āš ļø - - ## [0.4.10] - 2024-10-30 - dry run mode - feat: dry run mode - - ## [0.4.9] - 2024-10-30 - full process with helper scripts - feat: full process with helper scripts diff --git a/packages/publisher/tsconfig.json b/packages/publisher/tsconfig.json index ba600085..4acd87f5 100644 --- a/packages/publisher/tsconfig.json +++ b/packages/publisher/tsconfig.json @@ -19,6 +19,7 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "types": ["node","jest"], + "sourcemap": true, "composite": true, "paths": { "@siteed/publisher": ["./src/index.ts"] diff --git a/yarn.lock b/yarn.lock index f35bba87..5fb7d966 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15521,6 +15521,14 @@ __metadata: languageName: node linkType: hard +"gitguard@workspace:packages/gitguard": + version: 0.0.0-use.local + resolution: "gitguard@workspace:packages/gitguard" + dependencies: + "@siteed/publisher": "workspace:^" + languageName: unknown + linkType: soft + "github-slugger@npm:^2.0.0": version: 2.0.0 resolution: "github-slugger@npm:2.0.0"