From 6d354c16f2dd45f5050e68cccadf498407dd4063 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 16:21:51 +0800 Subject: [PATCH 01/32] chore: consitent changelog --- packages/publisher/CHANGELOG.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/publisher/CHANGELOG.md b/packages/publisher/CHANGELOG.md index 78576070..6c55f96e 100644 --- a/packages/publisher/CHANGELOG.md +++ b/packages/publisher/CHANGELOG.md @@ -18,23 +18,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 From 56798832160cfa7173dba147b639c0a0133b8ccd Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 16:28:45 +0800 Subject: [PATCH 02/32] feat: trial --- packages/publisher/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/publisher/CHANGELOG.md b/packages/publisher/CHANGELOG.md index 6c55f96e..90e5e3dd 100644 --- a/packages/publisher/CHANGELOG.md +++ b/packages/publisher/CHANGELOG.md @@ -13,6 +13,7 @@ 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 From 62a4cbca5ea5868681bc2590de5ab12cd90213b3 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 18:12:47 +0800 Subject: [PATCH 03/32] feat(gitguard): update docs --- packages/gitguard/README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/gitguard/README.md diff --git a/packages/gitguard/README.md b/packages/gitguard/README.md new file mode 100644 index 00000000..9daeafb9 --- /dev/null +++ b/packages/gitguard/README.md @@ -0,0 +1 @@ +test From 5805b74d85e45a8dcf6dde36e2a86f4ca65e8e79 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 18:14:42 +0800 Subject: [PATCH 04/32] feat(gitguard): update docs --- packages/gitguard/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/gitguard/README.md b/packages/gitguard/README.md index 9daeafb9..dec2cbe1 100644 --- a/packages/gitguard/README.md +++ b/packages/gitguard/README.md @@ -1 +1,2 @@ test +test From a8bf08a1a47f79bc1637c5efb94160d15ca10687 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 18:15:25 +0800 Subject: [PATCH 05/32] feat(gitguard): update docs --- packages/gitguard/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/gitguard/README.md b/packages/gitguard/README.md index dec2cbe1..0867e73e 100644 --- a/packages/gitguard/README.md +++ b/packages/gitguard/README.md @@ -1,2 +1,3 @@ test test +test From 4892ba2ad101c4949f26bccc1c9081dde4424410 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 18:18:24 +0800 Subject: [PATCH 06/32] feat(gitguard): update docs --- packages/gitguard/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/gitguard/README.md b/packages/gitguard/README.md index 0867e73e..f7626841 100644 --- a/packages/gitguard/README.md +++ b/packages/gitguard/README.md @@ -1,3 +1,5 @@ test test test +test +test From 3af0d60fee7768eb3826cfa670a717f157ad723a Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 18:20:23 +0800 Subject: [PATCH 07/32] feat(gitguard): update docs --- packages/gitguard/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/gitguard/README.md b/packages/gitguard/README.md index f7626841..0883796b 100644 --- a/packages/gitguard/README.md +++ b/packages/gitguard/README.md @@ -3,3 +3,5 @@ test test test test +test +test From 6b6ab1fcc425f45a28c61fdb4f290b5fcf81643f Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 18:51:56 +0800 Subject: [PATCH 08/32] update docs --- packages/gitguard/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/gitguard/README.md b/packages/gitguard/README.md index 0883796b..d5b79cea 100644 --- a/packages/gitguard/README.md +++ b/packages/gitguard/README.md @@ -5,3 +5,4 @@ test test test test +test From 28a85eec5afcc70df8b889eda7411dc6eb70355a Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 18:54:00 +0800 Subject: [PATCH 09/32] update documentation --- packages/TOREMOVE | 1 + packages/design-system/TEMP | 1 + packages/gitguard/README.md | 1 + 3 files changed, 3 insertions(+) create mode 100644 packages/TOREMOVE create mode 100644 packages/design-system/TEMP diff --git a/packages/TOREMOVE b/packages/TOREMOVE new file mode 100644 index 00000000..9daeafb9 --- /dev/null +++ b/packages/TOREMOVE @@ -0,0 +1 @@ +test diff --git a/packages/design-system/TEMP b/packages/design-system/TEMP new file mode 100644 index 00000000..9daeafb9 --- /dev/null +++ b/packages/design-system/TEMP @@ -0,0 +1 @@ +test diff --git a/packages/gitguard/README.md b/packages/gitguard/README.md index d5b79cea..8b16b71d 100644 --- a/packages/gitguard/README.md +++ b/packages/gitguard/README.md @@ -6,3 +6,4 @@ test test test test +test From d047cddd02f896f8dc6d26af7c38355e0a925739 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 19:30:36 +0800 Subject: [PATCH 10/32] update documentation --- packages/design-system/TEMP | 1 + packages/gitguard/README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/design-system/TEMP b/packages/design-system/TEMP index 9daeafb9..dec2cbe1 100644 --- a/packages/design-system/TEMP +++ b/packages/design-system/TEMP @@ -1 +1,2 @@ test +test diff --git a/packages/gitguard/README.md b/packages/gitguard/README.md index 8b16b71d..e5e6241c 100644 --- a/packages/gitguard/README.md +++ b/packages/gitguard/README.md @@ -7,3 +7,4 @@ test test test test +test From b0f7120fc13afff2ae5bfda719808cadacfdb44b Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 20:34:24 +0800 Subject: [PATCH 11/32] fff --- packages/design-system/TEMP | 1 + packages/gitguard/README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/design-system/TEMP b/packages/design-system/TEMP index dec2cbe1..0867e73e 100644 --- a/packages/design-system/TEMP +++ b/packages/design-system/TEMP @@ -1,2 +1,3 @@ test test +test diff --git a/packages/gitguard/README.md b/packages/gitguard/README.md index e5e6241c..8fe39761 100644 --- a/packages/gitguard/README.md +++ b/packages/gitguard/README.md @@ -8,3 +8,4 @@ test test test test +test From b4dff29e04aceacdfb2659d7e300b82ea9072d0d Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 20:35:43 +0800 Subject: [PATCH 12/32] fff --- packages/design-system/TEMP | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/design-system/TEMP b/packages/design-system/TEMP index 0867e73e..d0c7fbe0 100644 --- a/packages/design-system/TEMP +++ b/packages/design-system/TEMP @@ -1,3 +1,4 @@ test test test +test From aef98b79270d56aa477848568750549a771ed6f0 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 20:44:05 +0800 Subject: [PATCH 13/32] fff --- packages/design-system/TEMP | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/design-system/TEMP b/packages/design-system/TEMP index d0c7fbe0..f7626841 100644 --- a/packages/design-system/TEMP +++ b/packages/design-system/TEMP @@ -2,3 +2,4 @@ test test test test +test From 040dd80d8a4393292731f9bd119e823a80e7ab13 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 20:45:24 +0800 Subject: [PATCH 14/32] feat(TOREMOVE): fff --- packages/TOREMOVE | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/TOREMOVE b/packages/TOREMOVE index 9daeafb9..433e90ee 100644 --- a/packages/TOREMOVE +++ b/packages/TOREMOVE @@ -1 +1,2 @@ test +TEST From d02d6a55690a6042d8595a1d9ce925376c86a7e5 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 21:04:00 +0800 Subject: [PATCH 15/32] fff --- packages/TOREMOVE | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/TOREMOVE b/packages/TOREMOVE index 433e90ee..b790601e 100644 --- a/packages/TOREMOVE +++ b/packages/TOREMOVE @@ -1,2 +1,3 @@ test TEST +TEST From 24c5e379e592b5ef5937330f54d09fc85fbc5fe8 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 21:05:18 +0800 Subject: [PATCH 16/32] fff --- packages/TOREMOVE | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/TOREMOVE b/packages/TOREMOVE index b790601e..415581a7 100644 --- a/packages/TOREMOVE +++ b/packages/TOREMOVE @@ -1,3 +1,4 @@ test TEST TEST +TEST From f0ee26ca0f166f831d30378e09bb910658bec892 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 21:06:16 +0800 Subject: [PATCH 17/32] fff --- packages/TOREMOVE | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/TOREMOVE b/packages/TOREMOVE index 415581a7..e10d8233 100644 --- a/packages/TOREMOVE +++ b/packages/TOREMOVE @@ -2,3 +2,4 @@ test TEST TEST TEST +TEST From 94177321e6e502aa6a2824ad4207af7abb558ed2 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 21:08:15 +0800 Subject: [PATCH 18/32] fff --- packages/TOREMOVE | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/TOREMOVE b/packages/TOREMOVE index e10d8233..02841040 100644 --- a/packages/TOREMOVE +++ b/packages/TOREMOVE @@ -3,3 +3,4 @@ TEST TEST TEST TEST +TEST From a7cda0174ed105cd68c7feb8f4e57477883a15c6 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 21:09:21 +0800 Subject: [PATCH 19/32] fff --- packages/TOREMOVE | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/TOREMOVE b/packages/TOREMOVE index 02841040..1518c479 100644 --- a/packages/TOREMOVE +++ b/packages/TOREMOVE @@ -4,3 +4,4 @@ TEST TEST TEST TEST +TEST From 968abad87c29b0a1de11dceb672f40153a9e0375 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 21:54:11 +0800 Subject: [PATCH 20/32] feat: initial gitguard setup --- README.md | 2 +- packages/gitguard/IDEA.md | 197 ++++++++++++++ packages/gitguard/README.md | 95 ++++++- packages/gitguard/create-samples.sh | 78 ++++++ packages/gitguard/gitguard-prepare.py | 352 ++++++++++++++++++++++++++ packages/gitguard/install.sh | 33 +++ 6 files changed, 745 insertions(+), 12 deletions(-) create mode 100644 packages/gitguard/IDEA.md create mode 100755 packages/gitguard/create-samples.sh create mode 100644 packages/gitguard/gitguard-prepare.py create mode 100755 packages/gitguard/install.sh 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/gitguard/IDEA.md b/packages/gitguard/IDEA.md new file mode 100644 index 00000000..c068ae35 --- /dev/null +++ b/packages/gitguard/IDEA.md @@ -0,0 +1,197 @@ +# @siteed/guardian + +A smart git commit hook that uses AI to improve your commit messages and optionally split commits in monorepos. + +## Quick Start + +```bash +# Install +npm install -D @siteed/guardian + +# Initialize (adds the commit hook automatically) +npx guardian init +``` + +That's it! Now just commit as usual, and Guardian will help improve your commits. + +## How it Works + +```bash +# Your normal git workflow +git add . +git commit -m "update auth stuff" + +# Guardian intercepts via prepare-commit-msg hook: +šŸ” Analyzing changes... + +šŸ“ Suggested message: +feat(auth): implement OAuth2 authentication flow +- Add Google OAuth integration +- Update user session handling +- Add authentication types + +? Accept this message? [Y/n] +``` + +## Monorepo Detection + +```bash +# When changes span multiple packages: +git add . +git commit -m "add login" + +šŸ” Detected changes in multiple packages + +šŸ“¦ Suggested splits: +1. feat(auth): implement OAuth service +2. feat(ui): add login component +3. feat(api): create auth endpoints + +? Create separate commits? [Y/n] +``` + +## Configuration + +`.guardianrc.json` (optional): +```json +{ + "ai": { + // Required: Your OpenAI API key + "apiKey": "sk-xxx", + + // Optional: Custom prompts + "prompts": { + "analyze": "Custom analysis prompt", + "improve": "Custom improvement prompt" + } + }, + + // Optional: Hook behavior + "hooks": { + "autoAccept": false, // Accept suggestions without asking + "allowSkip": true, // Allow skipping with --no-verify + "splitCommits": true // Enable auto-split for monorepos + } +} +``` + +Or use environment variables: +```bash +GUARDIAN_AI_KEY=sk-xxx +GUARDIAN_AUTO_ACCEPT=true +``` + +## Core Git Hooks Used + +```bash +# .git/hooks/prepare-commit-msg +#!/bin/sh +npx guardian improve "$1" + +# .git/hooks/pre-commit (optional, for split detection) +#!/bin/sh +npx guardian check-split +``` + +## Examples + +### Single Package Changes +```bash +# Before: +$ git commit -m "fix bug" + +# Guardian suggests: +fix(auth): resolve token expiration validation +- Fix JWT verification logic +- Add proper error handling +- Update error messages + +# After user accepts: +[main abc123d] fix(auth): resolve token expiration validation + 3 files changed, 25 insertions(+), 10 deletions(-) +``` + +### Multi-Package Changes +```bash +# Before: +$ git commit -m "add login" + +# Guardian analyzes: +šŸ“¦ Changes detected in: +- packages/auth/src/oauth.ts +- packages/ui/components/Login.tsx +- packages/api/routes/auth.ts + +Suggested commits: +1. feat(auth): implement OAuth authentication +2. feat(ui): add login component +3. feat(api): create auth endpoints + +# After user accepts: +[main def456e] feat(auth): implement OAuth authentication +[main ghi789f] feat(ui): add login component +[main jkl012m] feat(api): create auth endpoints +``` + +## Key Features + +- šŸŽÆ **Simple Installation**: Just a commit hook +- šŸ¤– **Smart Analysis**: AI-powered commit improvements +- šŸ“¦ **Monorepo Aware**: Automatic package detection +- šŸ”„ **Split Commits**: Optional multi-package commit splitting +- āš” **Fast**: Quick analysis and suggestions +- šŸŽØ **Customizable**: Configure prompts and behavior + +## Commit Analysis Prompt + +```typescript +const defaultAnalysisPrompt = ` +Analyze these git changes and suggest a conventional commit message: + +DIFF: +{{diff}} + +GUIDELINES: +1. Use conventional commit format (type(scope): description) +2. Be specific and concise +3. List main changes as bullet points +4. Detect breaking changes +5. Keep subject under 72 chars + +{{#if isMonorepo}} +MONOREPO CONTEXT: +- Changed packages: {{changedPackages}} +- Package structure: {{packagePaths}} +{{/if}} +`; +``` + +## Advanced Usage + +### Skip Guardian for a commit +```bash +git commit -m "quick fix" --no-verify +``` + +### Custom prompts +```json +{ + "ai": { + "prompts": { + "analyze": "Custom prompt that handles {{diff}}" + } + } +} +``` + +### CI/CD Usage +```bash +# Disable interactivity in CI +GUARDIAN_CI=true git commit -m "..." +``` + +Would you like me to: +1. Add more hook implementation details? +2. Show more prompt examples? +3. Expand the configuration options? +4. Add debugging information? diff --git a/packages/gitguard/README.md b/packages/gitguard/README.md index 8fe39761..a9fb6ae1 100644 --- a/packages/gitguard/README.md +++ b/packages/gitguard/README.md @@ -1,11 +1,84 @@ -test -test -test -test -test -test -test -test -test -test -test +# GitGuard Commit Hook + +A smart Git commit hook that helps maintain high-quality, consistent commit messages across your monorepo. + +## Features + +- šŸŽÆ **Automatic Scope Detection**: Automatically detects the package scope based on changed files +- šŸ¤– **AI-Powered Suggestions**: Offers intelligent commit message suggestions using Azure OpenAI +- šŸ“¦ **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 +``` + +## Installation + +1. Install the package in your monorepo: +```bash +yarn add -D @siteed/gitguard +``` + +2. Add to your git hooks (using husky or direct installation): +```bash +# Using husky +yarn husky add .husky/prepare-commit-msg 'yarn gitguard $1' + +# Direct installation +cp node_modules/@siteed/gitguard/gitguard-prepare.py .git/hooks/prepare-commit-msg +chmod +x .git/hooks/prepare-commit-msg +``` + +## 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 + "azure_endpoint": "", // Azure OpenAI endpoint + "azure_deployment": "", // Azure OpenAI deployment name + "azure_api_version": "", // Azure OpenAI API version + "debug": false // Enable debug logging +} +``` + +### Environment Variables + +- `GITGUARD_AUTO`: Enable automatic mode (1/true/yes) +- `GITGUARD_USE_AI`: Enable AI suggestions (1/true/yes) +- `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 +- `GITGUARD_DEBUG`: Enable debug logging (1/true/yes) 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..bc05c512 --- /dev/null +++ b/packages/gitguard/gitguard-prepare.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 + +import sys +import os +from pathlib import Path +from subprocess import check_output, run +import json +from typing import Dict, List, Optional, Set +from datetime import datetime + +# Try to import optional dependencies +try: + from azure.identity import DefaultAzureCredential + from openai import AzureOpenAI + HAS_OPENAI = True +except ImportError: + HAS_OPENAI = False + +class Config: + """Configuration handler with global and local settings.""" + + DEFAULT_CONFIG = { + "auto_mode": False, + "use_ai": False, + "azure_endpoint": "https://consensys-ai.openai.azure.com/", + "azure_deployment": "gpt-4o", + "azure_api_version": "2024-02-15-preview", + "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) + +def detect_change_types(files: List[str]) -> Set[str]: + """Detect change types based on files modified.""" + types = set() + + for file in files: + file_lower = file.lower() + name = Path(file).name.lower() + + if any(pattern in file_lower for pattern in ['.test.', '.spec.', '/tests/']): + types.add('test') + elif any(pattern in file_lower for pattern in ['.md', 'readme', 'docs/']): + types.add('docs') + elif any(pattern in file_lower for pattern in ['.css', '.scss', '.styled.']): + types.add('style') + elif any(pattern in name for pattern in ['package.json', '.config.', 'tsconfig']): + types.add('chore') + elif any(word in file_lower for word in ['fix', 'bug', 'patch']): + types.add('fix') + + if not types: + types.add('feat') + + return types + +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 + +def get_changed_packages() -> List[Dict]: + """Get all packages with changes in the current commit.""" + changed_files = check_output(['git', 'diff', '--cached', '--name-only']) + changed_files = changed_files.decode('utf-8').strip().split('\n') + + 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, + 'types': detect_change_types(files) + }) + + return results + +def format_commit_message(original_msg: str, package: Dict, commit_type: Optional[str] = None) -> str: + """Format commit message for a single package.""" + if ':' in original_msg: + type_part, msg = original_msg.split(':', 1) + msg = msg.strip() + if '(' in type_part and ')' in type_part: + commit_type = type_part.split('(')[0] + else: + commit_type = type_part + else: + msg = original_msg + if not commit_type: + commit_type = next(iter(package['types'])) + + return f"{commit_type}({package['scope']}): {msg}" + +def generate_ai_prompt(packages: List[Dict], original_msg: str) -> str: + """Generate a detailed prompt for AI assistance.""" + try: + diff = check_output(['git', 'diff', '--cached']).decode('utf-8') + except: + diff = "Failed to get diff" + + prompt = f"""Please suggest a git commit message following conventional commits format. + +Original message: "{original_msg}" + +Changed packages: +{'-' * 40}""" + + for pkg in packages: + prompt += f""" + +šŸ“¦ Package: {pkg['name']} +Detected change types: {', '.join(pkg['types'])} +Files changed: +{chr(10).join(f'- {file}' for file in pkg['files'])}""" + + prompt += f""" +{'-' * 40} + +Git diff: +```diff +{diff} +``` + +Please provide a single commit message that: +1. Follows the format: type(scope): description +2. Uses the most significant package as scope +3. Lists other affected packages if any +4. Includes brief bullet points for significant changes + +Use one of: feat|fix|docs|style|refactor|perf|test|chore +Keep the description clear and concise""" + + return prompt + +def prompt_user(question: str) -> bool: + """Prompt user for yes/no question using /dev/tty.""" + try: + with open('/dev/tty', 'r') as tty: + print(f"{question} [Y/n]", end=' ', flush=True) + response = tty.readline().strip().lower() + return response == '' or response != 'n' + except Exception: + return True + +def get_ai_suggestion(prompt: str) -> Optional[List[Dict[str, str]]]: + """Get structured commit message suggestions from Azure OpenAI.""" + if not HAS_OPENAI: + return None + + config = Config() + try: + client = AzureOpenAI( + api_key=config.get('azure_api_key') or os.getenv("AZURE_OPENAI_API_KEY"), + api_version=config.get('azure_api_version'), + azure_endpoint=config.get('azure_endpoint') + ) + + system_prompt = """You are a helpful git commit message assistant. + Provide 3 different conventional commit messages that are clear and concise. + Return your response in the following JSON format: + { + "suggestions": [ + { + "message": "type(scope): description", + "explanation": "Brief explanation of why this format was chosen", + "type": "commit type used", + "scope": "scope used", + "description": "main message" + } + ] + }""" + + response = client.chat.completions.create( + model=config.get('azure_deployment'), + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt} + ], + temperature=0.7, + max_tokens=1000, + response_format={ "type": "json_object" } + ) + + result = json.loads(response.choices[0].message.content) + return result.get('suggestions', []) + + except Exception as e: + print(f"\nāš ļø AI suggestion failed: {e}") + return None + +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) + + while True: + choice = input('\nChoose suggestion (1-3) or press Enter to skip: ').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") + +def main(): + try: + config = Config() + + commit_msg_file = sys.argv[1] + with open(commit_msg_file, 'r') 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...') + print('Original message:', original_msg) + + # Handle multiple packages first + if len(packages) > 1: + print('\nšŸ“¦ Changes in multiple packages:') + for pkg in packages: + print(f"ā€¢ {pkg['name']} ({', '.join(pkg['types'])})") + for file in pkg['files']: + print(f" - {file}") + print("\nāš ļø Consider splitting this commit for better readability!") + + # AI suggestion flow - only if user wants it + if prompt_user('\nWould you like AI suggestions?'): + print("\nšŸ¤– Getting AI suggestions...") + prompt = generate_ai_prompt(packages, original_msg) + suggestions = get_ai_suggestion(prompt) + + if suggestions: + chosen_message = display_suggestions(suggestions) + if chosen_message: + with open(commit_msg_file, 'w') as f: + f.write(chosen_message) + print('āœ… Commit message updated!\n') + return + + # Fallback to automatic formatting + if len(packages) > 1: + main_pkg = packages[0] + main_type = next(iter(main_pkg['types'])) + new_msg = format_commit_message(original_msg, main_pkg, main_type) + new_msg += '\n\nAffected packages:\n' + '\n'.join(f"- {p['name']}" for p in packages) + else: + pkg = packages[0] + main_type = next(iter(pkg['types'])) + new_msg = format_commit_message(original_msg, pkg, main_type) + + print(f'\nāœØ Suggested message: {new_msg}') + + if prompt_user('\nUse suggested message?'): + with open(commit_msg_file, 'w') as f: + f.write(new_msg) + print('āœ… Commit message updated!\n') + + except Exception as e: + print(f"āŒ Error: {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..8ebfdcad --- /dev/null +++ b/packages/gitguard/install.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Exit on any error +set -e + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Store the script's directory +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# 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 hooks directory if it doesn't exist +mkdir -p "$GIT_ROOT/.git/hooks" + +# Copy the prepare-commit-msg hook from the correct location +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 + +cp "$SCRIPT_DIR/gitguard-prepare.py" "$GIT_ROOT/.git/hooks/prepare-commit-msg" +chmod +x "$GIT_ROOT/.git/hooks/prepare-commit-msg" + +echo -e "${GREEN}āœ… GitGuard hook installed successfully!${NC}" From 0fa730fa451d50e50c3ad53850e695c7792ec049 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 22:05:40 +0800 Subject: [PATCH 21/32] feat(gitguard): initial gitguard setup --- packages/gitguard/gitguard-prepare.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/gitguard/gitguard-prepare.py b/packages/gitguard/gitguard-prepare.py index bc05c512..5afc2b5e 100644 --- a/packages/gitguard/gitguard-prepare.py +++ b/packages/gitguard/gitguard-prepare.py @@ -265,10 +265,11 @@ def get_ai_suggestion(prompt: str) -> Optional[List[Dict[str, str]]]: print(f"\nāš ļø AI suggestion failed: {e}") return None + def display_suggestions(suggestions: List[Dict[str, str]]) -> Optional[str]: - """Display suggestions and get user choice.""" + """Display suggestions and get user choice, defaults to the first suggestion on EOF.""" print("\nāœØ AI Suggestions:") - + for i, suggestion in enumerate(suggestions, 1): print(f"\n{i}. {'=' * 48}") print(f"Message: {suggestion['message']}") @@ -276,14 +277,18 @@ def display_suggestions(suggestions: List[Dict[str, str]]) -> Optional[str]: print(f"Scope: {suggestion['scope']}") print(f"Explanation: {suggestion['explanation']}") print('=' * 50) - - while True: - choice = input('\nChoose suggestion (1-3) or press Enter to skip: ').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") + + try: + while True: + choice = input('\nChoose suggestion (1-3) or press Enter to skip: ').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 main(): try: From 1996717efab791a195673eb8c640773caecaa68a Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Thu, 31 Oct 2024 23:10:28 +0800 Subject: [PATCH 22/32] feat(gitguard): fallback api and local api via ollama --- packages/gitguard/README.md | 40 ++- packages/gitguard/gitguard-prepare.py | 480 ++++++++++++++++++-------- 2 files changed, 370 insertions(+), 150 deletions(-) diff --git a/packages/gitguard/README.md b/packages/gitguard/README.md index a9fb6ae1..e5b16b83 100644 --- a/packages/gitguard/README.md +++ b/packages/gitguard/README.md @@ -5,7 +5,9 @@ A smart Git commit hook that helps maintain high-quality, consistent commit mess ## Features - šŸŽÆ **Automatic Scope Detection**: Automatically detects the package scope based on changed files -- šŸ¤– **AI-Powered Suggestions**: Offers intelligent commit message suggestions using Azure OpenAI +- šŸ¤– **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 @@ -66,9 +68,18 @@ GitGuard can be configured using: { "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": "", // Azure OpenAI deployment name + "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 } ``` @@ -77,8 +88,33 @@ GitGuard can be configured using: - `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/gitguard-prepare.py b/packages/gitguard/gitguard-prepare.py index 5afc2b5e..b9b2e3e5 100644 --- a/packages/gitguard/gitguard-prepare.py +++ b/packages/gitguard/gitguard-prepare.py @@ -1,31 +1,38 @@ #!/usr/bin/env python3 +"""GitGuard - A tool to help maintain consistent git commit messages.""" import sys import os from pathlib import Path -from subprocess import check_output, run +from subprocess import check_output import json from typing import Dict, List, Optional, Set -from datetime import datetime +import requests # Try to import optional dependencies try: - from azure.identity import DefaultAzureCredential from openai import AzureOpenAI + HAS_OPENAI = True except ImportError: HAS_OPENAI = False + class Config: """Configuration handler with global and local settings.""" - + DEFAULT_CONFIG = { "auto_mode": False, - "use_ai": False, + "use_ai": True, + "ai_provider": "azure", # Can be 'azure' or 'ollama' "azure_endpoint": "https://consensys-ai.openai.azure.com/", "azure_deployment": "gpt-4o", + "azure_fallback_deployment": "gpt-35-turbo-16k", # Fallback model "azure_api_version": "2024-02-15-preview", - "debug": False, + "azure_fallback_api_version": "2024-02-15-preview", # Fallback API version + "ollama_host": "http://localhost:11434", + "ollama_model": "codellama", + "debug": True, } def __init__(self): @@ -37,141 +44,341 @@ def _load_json_file(self, path: Path) -> Dict: if path.exists(): return json.loads(path.read_text()) except Exception as e: - if self._config.get('debug'): + 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') + 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') + 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')) + "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'): + 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, system_prompt: str, user_prompt: 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": f"{system_prompt}\n\n{user_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 get_azure_suggestions( + client: AzureOpenAI, + config: Config, + system_prompt: str, + user_prompt: str, + deployment: str, + api_version: str +) -> Optional[List[Dict[str, str]]]: + """Get suggestions from Azure OpenAI with specific deployment and API version.""" + try: + # Update client with specific API version + client.api_version = api_version + + response = client.chat.completions.create( + model=deployment, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + temperature=0.9, + max_tokens=1500, + n=3, + response_format={"type": "json_object"}, + ) + + all_suggestions = [] + for choice in response.choices: + result = json.loads(choice.message.content) + all_suggestions.extend(result.get("suggestions", [])) + + return all_suggestions[:3] + + except Exception as e: + if config.get("debug"): + print(f"\nāš ļø Azure OpenAI error with {deployment} (API version {api_version}): {str(e)}") + raise # Re-raise the exception to handle it in the calling function + + +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() + + if config.get("debug"): + print("\nšŸ”§ Active configuration:", json.dumps(config._config, indent=2)) + + system_prompt = """You are a helpful git commit message assistant. + Provide exactly 3 different conventional commit message suggestions, each with a unique approach. + Return your response in the following JSON format: + { + "suggestions": [ + { + "message": "type(scope): description", + "explanation": "Brief explanation of why this format was chosen", + "type": "commit type used", + "scope": "scope used", + "description": "main message" + } + ] + } + Ensure each suggestion has a different focus or perspective.""" + + # Try Ollama if configured + if config.get("ai_provider") == "ollama": + client = OllamaClient( + host=config.get("ollama_host"), + model=config.get("ollama_model") + ) + suggestions = client.generate(system_prompt, prompt) + if suggestions: + return suggestions + + # Try Azure OpenAI if available + if HAS_OPENAI: + api_key = config.get("azure_api_key") or os.getenv("AZURE_OPENAI_API_KEY") + if not api_key: + print("\nāš ļø No Azure OpenAI API key found in config or environment") + return None + + # Create new client for each attempt to ensure clean state + def create_client(api_version: str) -> AzureOpenAI: + return AzureOpenAI( + api_key=api_key, + api_version=api_version, + azure_endpoint=config.get("azure_endpoint"), + ) + + # Try primary model + try: + if config.get("debug"): + print(f"\nšŸ”§ Using primary model: {config.get('azure_deployment')}") + print(f"API Version: {config.get('azure_api_version')}") + print(f"Endpoint: {config.get('azure_endpoint')}") + + client = create_client(config.get("azure_api_version")) + suggestions = get_azure_suggestions( + client, + config, + system_prompt, + prompt, + config.get("azure_deployment"), + config.get("azure_api_version") + ) + if suggestions: + return suggestions + + except Exception as primary_error: + # Try fallback model + if config.get("azure_fallback_deployment"): + try: + if config.get("debug"): + print(f"\nšŸ”„ Trying fallback model: {config.get('azure_fallback_deployment')}") + print(f"Fallback API Version: {config.get('azure_fallback_api_version', config.get('azure_api_version'))}") + + fallback_api_version = config.get("azure_fallback_api_version", config.get("azure_api_version")) + client = create_client(fallback_api_version) + suggestions = get_azure_suggestions( + client, + config, + system_prompt, + prompt, + config.get("azure_fallback_deployment"), + fallback_api_version + ) + if suggestions: + return suggestions + + except Exception as fallback_error: + if config.get("debug"): + print(f"\nāš ļø Fallback model failed: {str(fallback_error)}") + else: + if config.get("debug"): + print("\nāš ļø No fallback model configured") + + return None + + def detect_change_types(files: List[str]) -> Set[str]: """Detect change types based on files modified.""" types = set() - + for file in files: file_lower = file.lower() name = Path(file).name.lower() - - if any(pattern in file_lower for pattern in ['.test.', '.spec.', '/tests/']): - types.add('test') - elif any(pattern in file_lower for pattern in ['.md', 'readme', 'docs/']): - types.add('docs') - elif any(pattern in file_lower for pattern in ['.css', '.scss', '.styled.']): - types.add('style') - elif any(pattern in name for pattern in ['package.json', '.config.', 'tsconfig']): - types.add('chore') - elif any(word in file_lower for word in ['fix', 'bug', 'patch']): - types.add('fix') - + + if any(pattern in file_lower for pattern in [".test.", ".spec.", "/tests/"]): + types.add("test") + elif any(pattern in file_lower for pattern in [".md", "readme", "docs/"]): + types.add("docs") + elif any(pattern in file_lower for pattern in [".css", ".scss", ".styled."]): + types.add("style") + elif any( + pattern in name for pattern in ["package.json", ".config.", "tsconfig"] + ): + types.add("chore") + elif any(word in file_lower for word in ["fix", "bug", "patch"]): + types.add("fix") + if not types: - types.add('feat') - + types.add("feat") + return types + 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' + pkg_json_path = Path.cwd() / package_path / "package.json" if pkg_json_path.exists(): - return json.loads(pkg_json_path.read_text()).get('name') + return json.loads(pkg_json_path.read_text()).get("name") except: return None return None + def get_changed_packages() -> List[Dict]: """Get all packages with changes in the current commit.""" - changed_files = check_output(['git', 'diff', '--cached', '--name-only']) - changed_files = changed_files.decode('utf-8').strip().split('\n') - + changed_files = check_output(["git", "diff", "--cached", "--name-only"]) + changed_files = changed_files.decode("utf-8").strip().split("\n") + packages = {} for file in changed_files: if not file: continue - - if file.startswith('packages/'): - parts = file.split('/') + + 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) - + 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' + 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] + scope = pkg_name.split("/")[-1] else: - name = scope = pkg_path.split('/')[-1] - - results.append({ - 'name': name, - 'scope': scope, - 'files': files, - 'types': detect_change_types(files) - }) - + name = scope = pkg_path.split("/")[-1] + + results.append( + { + "name": name, + "scope": scope, + "files": files, + "types": detect_change_types(files), + } + ) + return results -def format_commit_message(original_msg: str, package: Dict, commit_type: Optional[str] = None) -> str: + +def format_commit_message( + original_msg: str, package: Dict, commit_type: Optional[str] = None +) -> str: """Format commit message for a single package.""" - if ':' in original_msg: - type_part, msg = original_msg.split(':', 1) + if ":" in original_msg: + type_part, msg = original_msg.split(":", 1) msg = msg.strip() - if '(' in type_part and ')' in type_part: - commit_type = type_part.split('(')[0] + if "(" in type_part and ")" in type_part: + commit_type = type_part.split("(")[0] else: commit_type = type_part else: msg = original_msg if not commit_type: - commit_type = next(iter(package['types'])) + commit_type = next(iter(package["types"])) return f"{commit_type}({package['scope']}): {msg}" + def generate_ai_prompt(packages: List[Dict], original_msg: str) -> str: """Generate a detailed prompt for AI assistance.""" try: - diff = check_output(['git', 'diff', '--cached']).decode('utf-8') + diff = check_output(["git", "diff", "--cached"]).decode("utf-8") except: diff = "Failed to get diff" @@ -209,61 +416,17 @@ def generate_ai_prompt(packages: List[Dict], original_msg: str) -> str: return prompt + def prompt_user(question: str) -> bool: """Prompt user for yes/no question using /dev/tty.""" try: - with open('/dev/tty', 'r') as tty: - print(f"{question} [Y/n]", end=' ', flush=True) + 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: - return True - -def get_ai_suggestion(prompt: str) -> Optional[List[Dict[str, str]]]: - """Get structured commit message suggestions from Azure OpenAI.""" - if not HAS_OPENAI: - return None - - config = Config() - try: - client = AzureOpenAI( - api_key=config.get('azure_api_key') or os.getenv("AZURE_OPENAI_API_KEY"), - api_version=config.get('azure_api_version'), - azure_endpoint=config.get('azure_endpoint') - ) - - system_prompt = """You are a helpful git commit message assistant. - Provide 3 different conventional commit messages that are clear and concise. - Return your response in the following JSON format: - { - "suggestions": [ - { - "message": "type(scope): description", - "explanation": "Brief explanation of why this format was chosen", - "type": "commit type used", - "scope": "scope used", - "description": "main message" - } - ] - }""" - - response = client.chat.completions.create( - model=config.get('azure_deployment'), - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": prompt} - ], - temperature=0.7, - max_tokens=1000, - response_format={ "type": "json_object" } - ) - - result = json.loads(response.choices[0].message.content) - return result.get('suggestions', []) - + return response == "" or response != "n" except Exception as e: - print(f"\nāš ļø AI suggestion failed: {e}") - return None + print(f"Warning: Could not get user input: {str(e)}") + return True def display_suggestions(suggestions: List[Dict[str, str]]) -> Optional[str]: @@ -276,82 +439,103 @@ def display_suggestions(suggestions: List[Dict[str, str]]) -> Optional[str]: print(f"Type: {suggestion['type']}") print(f"Scope: {suggestion['scope']}") print(f"Explanation: {suggestion['explanation']}") - print('=' * 50) + print("=" * 50) try: - while True: - choice = input('\nChoose suggestion (1-3) or press Enter to skip: ').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") + 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 + return suggestions[0]["message"] if suggestions else None + -def main(): +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": [], "types": {"feat"}} + + # Sort packages by number of files changed + sorted_packages = sorted(packages, key=lambda x: len(x["files"]), reverse=True) + return sorted_packages[0] + + +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') as f: + with open(commit_msg_file, "r", encoding="utf-8") as f: original_msg = f.read().strip() - if original_msg.startswith('Merge'): + if original_msg.startswith("Merge"): sys.exit(0) packages = get_changed_packages() if not packages: sys.exit(0) - print('\nšŸ” Analyzing changes...') - print('Original message:', original_msg) + print("\nšŸ” Analyzing changes...") + print("Original message:", original_msg) # Handle multiple packages first if len(packages) > 1: - print('\nšŸ“¦ Changes in multiple packages:') + print("\nšŸ“¦ Changes in multiple packages:") for pkg in packages: print(f"ā€¢ {pkg['name']} ({', '.join(pkg['types'])})") - for file in pkg['files']: + for file in pkg["files"]: print(f" - {file}") print("\nāš ļø Consider splitting this commit for better readability!") # AI suggestion flow - only if user wants it - if prompt_user('\nWould you like AI suggestions?'): + if prompt_user("\nWould you like AI suggestions?"): print("\nšŸ¤– Getting AI suggestions...") prompt = generate_ai_prompt(packages, original_msg) - suggestions = get_ai_suggestion(prompt) - + suggestions = get_ai_suggestion(prompt, original_msg) + if suggestions: chosen_message = display_suggestions(suggestions) if chosen_message: - with open(commit_msg_file, 'w') as f: + with open(commit_msg_file, "w", encoding="utf-8") as f: f.write(chosen_message) - print('āœ… Commit message updated!\n') + print("āœ… Commit message updated!\n") return - + # Fallback to automatic formatting if len(packages) > 1: - main_pkg = packages[0] - main_type = next(iter(main_pkg['types'])) + main_pkg = get_main_package(packages) # Use the package with most changes + main_type = next(iter(main_pkg["types"])) new_msg = format_commit_message(original_msg, main_pkg, main_type) - new_msg += '\n\nAffected packages:\n' + '\n'.join(f"- {p['name']}" for p in packages) + new_msg += "\n\nAffected packages:\n" + "\n".join( + f"- {p['name']}" for p in packages + ) else: pkg = packages[0] - main_type = next(iter(pkg['types'])) + main_type = next(iter(pkg["types"])) new_msg = format_commit_message(original_msg, pkg, main_type) - print(f'\nāœØ Suggested message: {new_msg}') - - if prompt_user('\nUse suggested message?'): - with open(commit_msg_file, 'w') as f: + print(f"\nāœØ Suggested message: {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') + print("āœ… Commit message updated!\n") except Exception as e: - print(f"āŒ Error: {e}") + print(f"āŒ Error: {str(e)}") sys.exit(1) -if __name__ == '__main__': + +if __name__ == "__main__": main() From eca8e97ab15b33ef008c4c3b1bb1e4c5567269a6 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Fri, 1 Nov 2024 00:12:34 +0800 Subject: [PATCH 23/32] feat: more gitguard improv --- packages/gitguard/.publisher/hooks/index.ts | 21 + packages/gitguard/CHANGELOG.md | 7 + packages/gitguard/IDEA.md | 210 +------- packages/gitguard/cleanup-samples.sh | 56 ++ packages/gitguard/gitguard-prepare copy.py | 541 ++++++++++++++++++++ packages/gitguard/gitguard-prepare.py | 529 ++++++++++--------- packages/gitguard/package.json | 7 + packages/gitguard/publisher.config.ts | 41 ++ yarn.lock | 8 + 9 files changed, 983 insertions(+), 437 deletions(-) create mode 100644 packages/gitguard/.publisher/hooks/index.ts create mode 100644 packages/gitguard/CHANGELOG.md create mode 100755 packages/gitguard/cleanup-samples.sh create mode 100644 packages/gitguard/gitguard-prepare copy.py create mode 100644 packages/gitguard/package.json create mode 100644 packages/gitguard/publisher.config.ts 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 index c068ae35..62049f76 100644 --- a/packages/gitguard/IDEA.md +++ b/packages/gitguard/IDEA.md @@ -1,197 +1,23 @@ -# @siteed/guardian +# @siteed/gitguard -A smart git commit hook that uses AI to improve your commit messages and optionally split commits in monorepos. +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. -## Quick Start +## Core Features -```bash -# Install -npm install -D @siteed/guardian +- šŸ¤– **Smart Git Wrapper** + - Intercepts git commits to suggest improvements + - Preserves existing git workflow + - Zero config needed for basic usage + - Automatic repository structure detection -# Initialize (adds the commit hook automatically) -npx guardian init -``` +- šŸ” **Intelligent Change Analysis** + - Parses and categorizes git diffs + - Groups related changes + - Detects breaking changes + - Creates meaningful summaries -That's it! Now just commit as usual, and Guardian will help improve your commits. - -## How it Works - -```bash -# Your normal git workflow -git add . -git commit -m "update auth stuff" - -# Guardian intercepts via prepare-commit-msg hook: -šŸ” Analyzing changes... - -šŸ“ Suggested message: -feat(auth): implement OAuth2 authentication flow -- Add Google OAuth integration -- Update user session handling -- Add authentication types - -? Accept this message? [Y/n] -``` - -## Monorepo Detection - -```bash -# When changes span multiple packages: -git add . -git commit -m "add login" - -šŸ” Detected changes in multiple packages - -šŸ“¦ Suggested splits: -1. feat(auth): implement OAuth service -2. feat(ui): add login component -3. feat(api): create auth endpoints - -? Create separate commits? [Y/n] -``` - -## Configuration - -`.guardianrc.json` (optional): -```json -{ - "ai": { - // Required: Your OpenAI API key - "apiKey": "sk-xxx", - - // Optional: Custom prompts - "prompts": { - "analyze": "Custom analysis prompt", - "improve": "Custom improvement prompt" - } - }, - - // Optional: Hook behavior - "hooks": { - "autoAccept": false, // Accept suggestions without asking - "allowSkip": true, // Allow skipping with --no-verify - "splitCommits": true // Enable auto-split for monorepos - } -} -``` - -Or use environment variables: -```bash -GUARDIAN_AI_KEY=sk-xxx -GUARDIAN_AUTO_ACCEPT=true -``` - -## Core Git Hooks Used - -```bash -# .git/hooks/prepare-commit-msg -#!/bin/sh -npx guardian improve "$1" - -# .git/hooks/pre-commit (optional, for split detection) -#!/bin/sh -npx guardian check-split -``` - -## Examples - -### Single Package Changes -```bash -# Before: -$ git commit -m "fix bug" - -# Guardian suggests: -fix(auth): resolve token expiration validation -- Fix JWT verification logic -- Add proper error handling -- Update error messages - -# After user accepts: -[main abc123d] fix(auth): resolve token expiration validation - 3 files changed, 25 insertions(+), 10 deletions(-) -``` - -### Multi-Package Changes -```bash -# Before: -$ git commit -m "add login" - -# Guardian analyzes: -šŸ“¦ Changes detected in: -- packages/auth/src/oauth.ts -- packages/ui/components/Login.tsx -- packages/api/routes/auth.ts - -Suggested commits: -1. feat(auth): implement OAuth authentication -2. feat(ui): add login component -3. feat(api): create auth endpoints - -# After user accepts: -[main def456e] feat(auth): implement OAuth authentication -[main ghi789f] feat(ui): add login component -[main jkl012m] feat(api): create auth endpoints -``` - -## Key Features - -- šŸŽÆ **Simple Installation**: Just a commit hook -- šŸ¤– **Smart Analysis**: AI-powered commit improvements -- šŸ“¦ **Monorepo Aware**: Automatic package detection -- šŸ”„ **Split Commits**: Optional multi-package commit splitting -- āš” **Fast**: Quick analysis and suggestions -- šŸŽØ **Customizable**: Configure prompts and behavior - -## Commit Analysis Prompt - -```typescript -const defaultAnalysisPrompt = ` -Analyze these git changes and suggest a conventional commit message: - -DIFF: -{{diff}} - -GUIDELINES: -1. Use conventional commit format (type(scope): description) -2. Be specific and concise -3. List main changes as bullet points -4. Detect breaking changes -5. Keep subject under 72 chars - -{{#if isMonorepo}} -MONOREPO CONTEXT: -- Changed packages: {{changedPackages}} -- Package structure: {{packagePaths}} -{{/if}} -`; -``` - -## Advanced Usage - -### Skip Guardian for a commit -```bash -git commit -m "quick fix" --no-verify -``` - -### Custom prompts -```json -{ - "ai": { - "prompts": { - "analyze": "Custom prompt that handles {{diff}}" - } - } -} -``` - -### CI/CD Usage -```bash -# Disable interactivity in CI -GUARDIAN_CI=true git commit -m "..." -``` - -Would you like me to: -1. Add more hook implementation details? -2. Show more prompt examples? -3. Expand the configuration options? -4. Add debugging information? +- šŸ“¦ **Monorepo Support** + - Automatic package detection + - Smart commit splitting + - Dependency-aware commit ordering + - Scope suggestions 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/gitguard-prepare copy.py b/packages/gitguard/gitguard-prepare copy.py new file mode 100644 index 00000000..b9b2e3e5 --- /dev/null +++ b/packages/gitguard/gitguard-prepare copy.py @@ -0,0 +1,541 @@ +#!/usr/bin/env python3 +"""GitGuard - A tool to help maintain consistent git commit messages.""" + +import sys +import os +from pathlib import Path +from subprocess import check_output +import json +from typing import Dict, List, Optional, Set +import requests + +# Try to import optional dependencies +try: + from openai import AzureOpenAI + + HAS_OPENAI = True +except ImportError: + HAS_OPENAI = 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://consensys-ai.openai.azure.com/", + "azure_deployment": "gpt-4o", + "azure_fallback_deployment": "gpt-35-turbo-16k", # Fallback model + "azure_api_version": "2024-02-15-preview", + "azure_fallback_api_version": "2024-02-15-preview", # Fallback API version + "ollama_host": "http://localhost:11434", + "ollama_model": "codellama", + "debug": True, + } + + 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, system_prompt: str, user_prompt: 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": f"{system_prompt}\n\n{user_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 get_azure_suggestions( + client: AzureOpenAI, + config: Config, + system_prompt: str, + user_prompt: str, + deployment: str, + api_version: str +) -> Optional[List[Dict[str, str]]]: + """Get suggestions from Azure OpenAI with specific deployment and API version.""" + try: + # Update client with specific API version + client.api_version = api_version + + response = client.chat.completions.create( + model=deployment, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + temperature=0.9, + max_tokens=1500, + n=3, + response_format={"type": "json_object"}, + ) + + all_suggestions = [] + for choice in response.choices: + result = json.loads(choice.message.content) + all_suggestions.extend(result.get("suggestions", [])) + + return all_suggestions[:3] + + except Exception as e: + if config.get("debug"): + print(f"\nāš ļø Azure OpenAI error with {deployment} (API version {api_version}): {str(e)}") + raise # Re-raise the exception to handle it in the calling function + + +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() + + if config.get("debug"): + print("\nšŸ”§ Active configuration:", json.dumps(config._config, indent=2)) + + system_prompt = """You are a helpful git commit message assistant. + Provide exactly 3 different conventional commit message suggestions, each with a unique approach. + Return your response in the following JSON format: + { + "suggestions": [ + { + "message": "type(scope): description", + "explanation": "Brief explanation of why this format was chosen", + "type": "commit type used", + "scope": "scope used", + "description": "main message" + } + ] + } + Ensure each suggestion has a different focus or perspective.""" + + # Try Ollama if configured + if config.get("ai_provider") == "ollama": + client = OllamaClient( + host=config.get("ollama_host"), + model=config.get("ollama_model") + ) + suggestions = client.generate(system_prompt, prompt) + if suggestions: + return suggestions + + # Try Azure OpenAI if available + if HAS_OPENAI: + api_key = config.get("azure_api_key") or os.getenv("AZURE_OPENAI_API_KEY") + if not api_key: + print("\nāš ļø No Azure OpenAI API key found in config or environment") + return None + + # Create new client for each attempt to ensure clean state + def create_client(api_version: str) -> AzureOpenAI: + return AzureOpenAI( + api_key=api_key, + api_version=api_version, + azure_endpoint=config.get("azure_endpoint"), + ) + + # Try primary model + try: + if config.get("debug"): + print(f"\nšŸ”§ Using primary model: {config.get('azure_deployment')}") + print(f"API Version: {config.get('azure_api_version')}") + print(f"Endpoint: {config.get('azure_endpoint')}") + + client = create_client(config.get("azure_api_version")) + suggestions = get_azure_suggestions( + client, + config, + system_prompt, + prompt, + config.get("azure_deployment"), + config.get("azure_api_version") + ) + if suggestions: + return suggestions + + except Exception as primary_error: + # Try fallback model + if config.get("azure_fallback_deployment"): + try: + if config.get("debug"): + print(f"\nšŸ”„ Trying fallback model: {config.get('azure_fallback_deployment')}") + print(f"Fallback API Version: {config.get('azure_fallback_api_version', config.get('azure_api_version'))}") + + fallback_api_version = config.get("azure_fallback_api_version", config.get("azure_api_version")) + client = create_client(fallback_api_version) + suggestions = get_azure_suggestions( + client, + config, + system_prompt, + prompt, + config.get("azure_fallback_deployment"), + fallback_api_version + ) + if suggestions: + return suggestions + + except Exception as fallback_error: + if config.get("debug"): + print(f"\nāš ļø Fallback model failed: {str(fallback_error)}") + else: + if config.get("debug"): + print("\nāš ļø No fallback model configured") + + return None + + +def detect_change_types(files: List[str]) -> Set[str]: + """Detect change types based on files modified.""" + types = set() + + for file in files: + file_lower = file.lower() + name = Path(file).name.lower() + + if any(pattern in file_lower for pattern in [".test.", ".spec.", "/tests/"]): + types.add("test") + elif any(pattern in file_lower for pattern in [".md", "readme", "docs/"]): + types.add("docs") + elif any(pattern in file_lower for pattern in [".css", ".scss", ".styled."]): + types.add("style") + elif any( + pattern in name for pattern in ["package.json", ".config.", "tsconfig"] + ): + types.add("chore") + elif any(word in file_lower for word in ["fix", "bug", "patch"]): + types.add("fix") + + if not types: + types.add("feat") + + return types + + +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 + + +def get_changed_packages() -> List[Dict]: + """Get all packages with changes in the current commit.""" + changed_files = check_output(["git", "diff", "--cached", "--name-only"]) + changed_files = changed_files.decode("utf-8").strip().split("\n") + + 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, + "types": detect_change_types(files), + } + ) + + return results + + +def format_commit_message( + original_msg: str, package: Dict, commit_type: Optional[str] = None +) -> str: + """Format commit message for a single package.""" + if ":" in original_msg: + type_part, msg = original_msg.split(":", 1) + msg = msg.strip() + if "(" in type_part and ")" in type_part: + commit_type = type_part.split("(")[0] + else: + commit_type = type_part + else: + msg = original_msg + if not commit_type: + commit_type = next(iter(package["types"])) + + return f"{commit_type}({package['scope']}): {msg}" + + +def generate_ai_prompt(packages: List[Dict], original_msg: str) -> str: + """Generate a detailed prompt for AI assistance.""" + try: + diff = check_output(["git", "diff", "--cached"]).decode("utf-8") + except: + diff = "Failed to get diff" + + prompt = f"""Please suggest a git commit message following conventional commits format. + +Original message: "{original_msg}" + +Changed packages: +{'-' * 40}""" + + for pkg in packages: + prompt += f""" + +šŸ“¦ Package: {pkg['name']} +Detected change types: {', '.join(pkg['types'])} +Files changed: +{chr(10).join(f'- {file}' for file in pkg['files'])}""" + + prompt += f""" +{'-' * 40} + +Git diff: +```diff +{diff} +``` + +Please provide a single commit message that: +1. Follows the format: type(scope): description +2. Uses the most significant package as scope +3. Lists other affected packages if any +4. Includes brief bullet points for significant changes + +Use one of: feat|fix|docs|style|refactor|perf|test|chore +Keep the description clear and concise""" + + return prompt + + +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, defaults to the first suggestion on EOF.""" + 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 get_main_package(packages: List[Dict]) -> Optional[Dict]: + """Get the package with the most changes.""" + if not packages: + return {"name": "default", "scope": "default", "files": [], "types": {"feat"}} + + # Sort packages by number of files changed + sorted_packages = sorted(packages, key=lambda x: len(x["files"]), reverse=True) + return sorted_packages[0] + + +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...") + print("Original message:", original_msg) + + # Handle multiple packages first + if len(packages) > 1: + print("\nšŸ“¦ Changes in multiple packages:") + for pkg in packages: + print(f"ā€¢ {pkg['name']} ({', '.join(pkg['types'])})") + for file in pkg["files"]: + print(f" - {file}") + print("\nāš ļø Consider splitting this commit for better readability!") + + # AI suggestion flow - only if user wants it + if prompt_user("\nWould you like AI suggestions?"): + print("\nšŸ¤– Getting AI suggestions...") + prompt = generate_ai_prompt(packages, original_msg) + 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 + if len(packages) > 1: + main_pkg = get_main_package(packages) # Use the package with most changes + main_type = next(iter(main_pkg["types"])) + new_msg = format_commit_message(original_msg, main_pkg, main_type) + new_msg += "\n\nAffected packages:\n" + "\n".join( + f"- {p['name']}" for p in packages + ) + else: + pkg = packages[0] + main_type = next(iter(pkg["types"])) + new_msg = format_commit_message(original_msg, pkg, main_type) + + print(f"\nāœØ Suggested message: {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/gitguard-prepare.py b/packages/gitguard/gitguard-prepare.py index b9b2e3e5..6c4528fd 100644 --- a/packages/gitguard/gitguard-prepare.py +++ b/packages/gitguard/gitguard-prepare.py @@ -6,8 +6,10 @@ from pathlib import Path from subprocess import check_output import json -from typing import Dict, List, Optional, Set +from typing import Dict, List, Optional, Set, Any import requests +from collections import defaultdict +import re # Try to import optional dependencies try: @@ -25,14 +27,12 @@ class Config: "auto_mode": False, "use_ai": True, "ai_provider": "azure", # Can be 'azure' or 'ollama' - "azure_endpoint": "https://consensys-ai.openai.azure.com/", - "azure_deployment": "gpt-4o", - "azure_fallback_deployment": "gpt-35-turbo-16k", # Fallback model + "azure_endpoint": "https://your-endpoint.openai.azure.com/", + "azure_deployment": "gpt-4", "azure_api_version": "2024-02-15-preview", - "azure_fallback_api_version": "2024-02-15-preview", # Fallback API version "ollama_host": "http://localhost:11434", "ollama_model": "codellama", - "debug": True, + "debug": False, } def __init__(self): @@ -92,7 +92,7 @@ def __init__(self, host: str, model: str): self.model = model def generate( - self, system_prompt: str, user_prompt: str + self, prompt: str, original_message: str ) -> Optional[List[Dict[str, str]]]: """Generate commit message suggestions using Ollama.""" try: @@ -100,7 +100,7 @@ def generate( f"{self.host}/api/generate", json={ "model": self.model, - "prompt": f"{system_prompt}\n\n{user_prompt}", + "prompt": prompt, "stream": False, }, ) @@ -136,74 +136,160 @@ def generate( return None -def get_azure_suggestions( - client: AzureOpenAI, - config: Config, - system_prompt: str, - user_prompt: str, - deployment: str, - api_version: str -) -> Optional[List[Dict[str, str]]]: - """Get suggestions from Azure OpenAI with specific deployment and API version.""" +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 + + 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: - # Update client with specific API version - client.api_version = api_version - - response = client.chat.completions.create( - model=deployment, - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - temperature=0.9, - max_tokens=1500, - n=3, - response_format={"type": "json_object"}, + 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} ) - all_suggestions = [] - for choice in response.choices: - result = json.loads(choice.message.content) - all_suggestions.extend(result.get("suggestions", [])) + prompt = f"""Analyze the following git changes and suggest a commit message. - return all_suggestions[:3] +Complexity Analysis: +- Score: {complexity['score']} (threshold for structured format: 5) +- Factors: {', '.join(complexity['reasons'])} - except Exception as e: - if config.get("debug"): - print(f"\nāš ļø Azure OpenAI error with {deployment} (API version {api_version}): {str(e)}") - raise # Re-raise the exception to handle it in the calling function +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}" -def get_ai_suggestion(prompt: str, original_message: str) -> Optional[List[Dict[str, str]]]: +Git diff: +```diff +{diff} +``` + +Please provide a commit message that:""" + + if complexity["needs_structure"]: + prompt += """ +1. Follows the format: type(scope): description +2. Includes a clear, detailed description paragraph +3. Lists affected packages with key changes +4. Groups related changes together +5. Highlights significant changes first +6. Keep it concise but informative""" + else: + prompt += """ +1. Follows the format: type(scope): description +2. Is concise and focused +3. Clearly conveys the main change""" + + prompt += f""" + +Response Format: +{{ + "suggestions": [ + {{ + "message": "complete commit message with all sections", + "explanation": "why this format and focus was chosen", + "type": "commit type used", + "scope": "scope used", + "description": "title description" + }} + ] +}}""" + + return prompt + + +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() - - if config.get("debug"): - print("\nšŸ”§ Active configuration:", json.dumps(config._config, indent=2)) - system_prompt = """You are a helpful git commit message assistant. - Provide exactly 3 different conventional commit message suggestions, each with a unique approach. - Return your response in the following JSON format: - { - "suggestions": [ - { - "message": "type(scope): description", - "explanation": "Brief explanation of why this format was chosen", - "type": "commit type used", - "scope": "scope used", - "description": "main message" - } - ] - } - Ensure each suggestion has a different focus or perspective.""" + if config.get("debug"): + print("\nšŸ¤– Sending AI Prompt:") + print("-" * 40) + print(prompt) + print("-" * 40) # Try Ollama if configured if config.get("ai_provider") == "ollama": client = OllamaClient( - host=config.get("ollama_host"), - model=config.get("ollama_model") + host=config.get("ollama_host"), model=config.get("ollama_model") ) - suggestions = client.generate(system_prompt, prompt) + suggestions = client.generate(prompt, original_message) if suggestions: return suggestions @@ -214,89 +300,103 @@ def get_ai_suggestion(prompt: str, original_message: str) -> Optional[List[Dict[ print("\nāš ļø No Azure OpenAI API key found in config or environment") return None - # Create new client for each attempt to ensure clean state - def create_client(api_version: str) -> AzureOpenAI: - return AzureOpenAI( + try: + client = AzureOpenAI( api_key=api_key, - api_version=api_version, + api_version=config.get("azure_api_version"), azure_endpoint=config.get("azure_endpoint"), ) - # Try primary model - try: - if config.get("debug"): - print(f"\nšŸ”§ Using primary model: {config.get('azure_deployment')}") - print(f"API Version: {config.get('azure_api_version')}") - print(f"Endpoint: {config.get('azure_endpoint')}") - - client = create_client(config.get("azure_api_version")) - suggestions = get_azure_suggestions( - client, - config, - system_prompt, - prompt, - config.get("azure_deployment"), - config.get("azure_api_version") + response = client.chat.completions.create( + model=config.get("azure_deployment"), + messages=[ + { + "role": "system", + "content": "You are a helpful git commit message assistant.", + }, + {"role": "user", "content": prompt}, + ], + temperature=0.7, + max_tokens=1500, + n=3, + response_format={"type": "json_object"}, ) - if suggestions: - return suggestions - except Exception as primary_error: - # Try fallback model - if config.get("azure_fallback_deployment"): + for choice in response.choices: try: + result = json.loads(choice.message.content) if config.get("debug"): - print(f"\nšŸ”„ Trying fallback model: {config.get('azure_fallback_deployment')}") - print(f"Fallback API Version: {config.get('azure_fallback_api_version', config.get('azure_api_version'))}") - - fallback_api_version = config.get("azure_fallback_api_version", config.get("azure_api_version")) - client = create_client(fallback_api_version) - suggestions = get_azure_suggestions( - client, - config, - system_prompt, - prompt, - config.get("azure_fallback_deployment"), - fallback_api_version - ) - if suggestions: - return suggestions - - except Exception as fallback_error: - if config.get("debug"): - print(f"\nāš ļø Fallback model failed: {str(fallback_error)}") - else: - if config.get("debug"): - print("\nāš ļø No fallback model configured") - + print("\nšŸ¤– AI Response:") + print(json.dumps(result, indent=2)) + return result.get("suggestions", [])[:3] + except json.JSONDecodeError: + continue + + except Exception as e: + if config.get("debug"): + print(f"\nāš ļø Azure OpenAI error: {str(e)}") + return None -def detect_change_types(files: List[str]) -> Set[str]: - """Detect change types based on files modified.""" - types = set() +def create_commit_message(commit_info: Dict, packages: List[Dict]) -> str: + """Create appropriate commit message based on complexity.""" + # Calculate complexity + complexity = calculate_commit_complexity(packages) - for file in files: - file_lower = file.lower() - name = Path(file).name.lower() + if Config().get("debug"): + print("\nšŸ” Commit Complexity Analysis:") + print(f"Score: {complexity['score']}") + print("Reasons:") + for reason in complexity["reasons"]: + print(f"- {reason}") - if any(pattern in file_lower for pattern in [".test.", ".spec.", "/tests/"]): - types.add("test") - elif any(pattern in file_lower for pattern in [".md", "readme", "docs/"]): - types.add("docs") - elif any(pattern in file_lower for pattern in [".css", ".scss", ".styled."]): - types.add("style") - elif any( - pattern in name for pattern in ["package.json", ".config.", "tsconfig"] - ): - types.add("chore") - elif any(word in file_lower for word in ["fix", "bug", "patch"]): - types.add("fix") + # For simple commits, just return the title + if not complexity["needs_structure"]: + return f"{commit_info['type']}({commit_info['scope']}): {commit_info['description']}" - if not types: - types.add("feat") + # For complex commits, use structured format + return create_structured_commit(commit_info, packages) - return types + +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]: @@ -310,6 +410,9 @@ def get_package_json_name(package_path: Path) -> Optional[str]: 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.""" changed_files = check_output(["git", "diff", "--cached", "--name-only"]) @@ -349,72 +452,20 @@ def get_changed_packages() -> List[Dict]: "name": name, "scope": scope, "files": files, - "types": detect_change_types(files), } ) return results -def format_commit_message( - original_msg: str, package: Dict, commit_type: Optional[str] = None -) -> str: - """Format commit message for a single package.""" - if ":" in original_msg: - type_part, msg = original_msg.split(":", 1) - msg = msg.strip() - if "(" in type_part and ")" in type_part: - commit_type = type_part.split("(")[0] - else: - commit_type = type_part - else: - msg = original_msg - if not commit_type: - commit_type = next(iter(package["types"])) - - return f"{commit_type}({package['scope']}): {msg}" - - -def generate_ai_prompt(packages: List[Dict], original_msg: str) -> str: - """Generate a detailed prompt for AI assistance.""" - try: - diff = check_output(["git", "diff", "--cached"]).decode("utf-8") - except: - diff = "Failed to get diff" - - prompt = f"""Please suggest a git commit message following conventional commits format. - -Original message: "{original_msg}" - -Changed packages: -{'-' * 40}""" - - for pkg in packages: - prompt += f""" - -šŸ“¦ Package: {pkg['name']} -Detected change types: {', '.join(pkg['types'])} -Files changed: -{chr(10).join(f'- {file}' for file in pkg['files'])}""" - - prompt += f""" -{'-' * 40} - -Git diff: -```diff -{diff} -``` - -Please provide a single commit message that: -1. Follows the format: type(scope): description -2. Uses the most significant package as scope -3. Lists other affected packages if any -4. Includes brief bullet points for significant changes - -Use one of: feat|fix|docs|style|refactor|perf|test|chore -Keep the description clear and concise""" +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": []} - return prompt + # 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: @@ -430,7 +481,7 @@ def prompt_user(question: str) -> bool: def display_suggestions(suggestions: List[Dict[str, str]]) -> Optional[str]: - """Display suggestions and get user choice, defaults to the first suggestion on EOF.""" + """Display suggestions and get user choice.""" print("\nāœØ AI Suggestions:") for i, suggestion in enumerate(suggestions, 1): @@ -460,14 +511,50 @@ def display_suggestions(suggestions: List[Dict[str, str]]) -> Optional[str]: return suggestions[0]["message"] if suggestions else None -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": [], "types": {"feat"}} +def write_commit_message(commit_file: str, message: str) -> None: + """Write the commit message to the specified file.""" + with open(commit_file, 'w', encoding='utf-8') as f: + f.write(message) - # Sort packages by number of files changed - sorted_packages = sorted(packages, key=lambda x: len(x["files"]), reverse=True) - return sorted_packages[0] + +def is_conventional_commit(message: str) -> bool: + """Check if message follows conventional commit format.""" + pattern = r'^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\([^)]+\))?: .+' + return bool(re.match(pattern, message)) + + +def format_conventional_commit(message: str) -> str: + """Format message to follow conventional commit format.""" + # Default to 'chore' if no type is detected + return f"chore: {message}" if not is_conventional_commit(message) else message + + +def get_ai_suggestions(packages: List[Dict[str, Any]], original_message: str) -> Optional[List[Dict[str, str]]]: + """Get AI-powered commit message suggestions.""" + prompt = enhance_ai_prompt(packages, original_message) + return get_ai_suggestion(prompt, original_message) + + +def handle_commit_message(commit_file: str, original_message: str, config: Dict[str, Any]) -> None: + """Main function to handle commit message processing.""" + packages = get_changed_packages() + + if config['use_ai']: + try: + suggestions = get_ai_suggestions(packages, original_message) + if suggestions and prompt_user("Use AI suggestion?"): + write_commit_message(commit_file, suggestions[0]['message']) + return + except Exception as e: + if config['debug']: + print(f"\nāš ļø AI error: {str(e)}") + + # Simple format checking without package listing + if not is_conventional_commit(original_message): + formatted_message = format_conventional_commit(original_message) + write_commit_message(commit_file, formatted_message) + else: + write_commit_message(commit_file, original_message) def main() -> None: @@ -482,55 +569,7 @@ def main() -> None: if original_msg.startswith("Merge"): sys.exit(0) - packages = get_changed_packages() - if not packages: - sys.exit(0) - - print("\nšŸ” Analyzing changes...") - print("Original message:", original_msg) - - # Handle multiple packages first - if len(packages) > 1: - print("\nšŸ“¦ Changes in multiple packages:") - for pkg in packages: - print(f"ā€¢ {pkg['name']} ({', '.join(pkg['types'])})") - for file in pkg["files"]: - print(f" - {file}") - print("\nāš ļø Consider splitting this commit for better readability!") - - # AI suggestion flow - only if user wants it - if prompt_user("\nWould you like AI suggestions?"): - print("\nšŸ¤– Getting AI suggestions...") - prompt = generate_ai_prompt(packages, original_msg) - 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 - if len(packages) > 1: - main_pkg = get_main_package(packages) # Use the package with most changes - main_type = next(iter(main_pkg["types"])) - new_msg = format_commit_message(original_msg, main_pkg, main_type) - new_msg += "\n\nAffected packages:\n" + "\n".join( - f"- {p['name']}" for p in packages - ) - else: - pkg = packages[0] - main_type = next(iter(pkg["types"])) - new_msg = format_commit_message(original_msg, pkg, main_type) - - print(f"\nāœØ Suggested message: {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") + handle_commit_message(commit_msg_file, original_msg, config._config) except Exception as e: print(f"āŒ Error: {str(e)}") diff --git a/packages/gitguard/package.json b/packages/gitguard/package.json new file mode 100644 index 00000000..228e95ff --- /dev/null +++ b/packages/gitguard/package.json @@ -0,0 +1,7 @@ +{ + "name": "gitguard", + "packageManager": "yarn@4.5.0", + "devDependencies": { + "@siteed/publisher": "workspace:^" + } +} 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/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" From a7d4b600c83f76e3835410d94ad30805b24cd6f9 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Fri, 1 Nov 2024 00:13:58 +0800 Subject: [PATCH 24/32] feat(gitguard): more gitguard improv --- packages/gitguard/gitguard-prepare.py | 275 ++++++++++++-------------- 1 file changed, 123 insertions(+), 152 deletions(-) diff --git a/packages/gitguard/gitguard-prepare.py b/packages/gitguard/gitguard-prepare.py index 6c4528fd..64394f0b 100644 --- a/packages/gitguard/gitguard-prepare.py +++ b/packages/gitguard/gitguard-prepare.py @@ -14,15 +14,12 @@ # Try to import optional dependencies try: from openai import AzureOpenAI - HAS_OPENAI = True except ImportError: HAS_OPENAI = False - class Config: """Configuration handler with global and local settings.""" - DEFAULT_CONFIG = { "auto_mode": False, "use_ai": True, @@ -55,9 +52,7 @@ def _load_configurations(self): # 2. Local configuration try: - git_root = Path( - check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip() - ) + 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: @@ -83,17 +78,13 @@ def _load_configurations(self): 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]]]: + def generate(self, prompt: str, original_message: str) -> Optional[List[Dict[str, str]]]: """Generate commit message suggestions using Ollama.""" try: response = requests.post( @@ -105,10 +96,10 @@ def generate( }, ) response.raise_for_status() - + result = response.json() response_text = result.get("response", "") - + try: # Find JSON object in the response start = response_text.find("{") @@ -119,100 +110,106 @@ def generate( 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, - } - ] - + 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 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} - + 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 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 - + 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": []} - + groups = { + "Tests": [], + "Config": [], + "Styles": [], + "Source": [] + } + for file in files: - if file.endswith((".test.ts", ".test.js", ".spec.ts", ".spec.js")): + if file.endswith(('.test.ts', '.test.js', '.spec.ts', '.spec.js')): groups["Tests"].append(file) - elif file.endswith((".json", ".yml", ".yaml", ".config.js")): + elif file.endswith(('.json', '.yml', '.yaml', '.config.js')): groups["Config"].append(file) - elif file.endswith((".css", ".scss", ".less")): + 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": [], + "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} - ) + 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. @@ -271,13 +268,10 @@ def enhance_ai_prompt(packages: List[Dict], original_msg: str) -> str: return prompt - -def get_ai_suggestion( - prompt: str, original_message: str -) -> Optional[List[Dict[str, str]]]: +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() - + if config.get("debug"): print("\nšŸ¤– Sending AI Prompt:") print("-" * 40) @@ -287,7 +281,8 @@ def get_ai_suggestion( # Try Ollama if configured if config.get("ai_provider") == "ollama": client = OllamaClient( - host=config.get("ollama_host"), model=config.get("ollama_model") + host=config.get("ollama_host"), + model=config.get("ollama_model") ) suggestions = client.generate(prompt, original_message) if suggestions: @@ -306,15 +301,12 @@ def get_ai_suggestion( api_version=config.get("azure_api_version"), azure_endpoint=config.get("azure_endpoint"), ) - + response = client.chat.completions.create( model=config.get("azure_deployment"), messages=[ - { - "role": "system", - "content": "You are a helpful git commit message assistant.", - }, - {"role": "user", "content": prompt}, + {"role": "system", "content": "You are a helpful git commit message assistant."}, + {"role": "user", "content": prompt} ], temperature=0.7, max_tokens=1500, @@ -338,48 +330,53 @@ def get_ai_suggestion( return None - -def create_commit_message(commit_info: Dict, packages: List[Dict]) -> str: +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: +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*" + 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) + description = re.sub(type_pattern, '', description) # Start with the commit title - message_parts = [f"{commit_info['type']}({commit_info['scope']}): {description}"] - + 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:") @@ -395,10 +392,9 @@ def create_structured_commit( 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: @@ -409,10 +405,8 @@ def get_package_json_name(package_path: Path) -> Optional[str]: 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.""" changed_files = check_output(["git", "diff", "--cached", "--name-only"]) @@ -447,17 +441,14 @@ def get_changed_packages() -> List[Dict]: else: name = scope = pkg_path.split("/")[-1] - results.append( - { - "name": name, - "scope": scope, - "files": files, - } - ) + 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: @@ -467,7 +458,6 @@ def get_main_package(packages: List[Dict]) -> Optional[Dict]: 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: @@ -479,7 +469,6 @@ def prompt_user(question: str) -> bool: 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:") @@ -495,11 +484,7 @@ def display_suggestions(suggestions: List[Dict[str, str]]) -> Optional[str]: 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, - ) + print("\nChoose suggestion (1-3) or press Enter to skip: ", end="", flush=True) choice = tty.readline().strip() if not choice: return None @@ -510,58 +495,11 @@ def display_suggestions(suggestions: List[Dict[str, str]]) -> Optional[str]: print("\nāš ļø Input not available. Defaulting to first suggestion.") return suggestions[0]["message"] if suggestions else None - -def write_commit_message(commit_file: str, message: str) -> None: - """Write the commit message to the specified file.""" - with open(commit_file, 'w', encoding='utf-8') as f: - f.write(message) - - -def is_conventional_commit(message: str) -> bool: - """Check if message follows conventional commit format.""" - pattern = r'^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\([^)]+\))?: .+' - return bool(re.match(pattern, message)) - - -def format_conventional_commit(message: str) -> str: - """Format message to follow conventional commit format.""" - # Default to 'chore' if no type is detected - return f"chore: {message}" if not is_conventional_commit(message) else message - - -def get_ai_suggestions(packages: List[Dict[str, Any]], original_message: str) -> Optional[List[Dict[str, str]]]: - """Get AI-powered commit message suggestions.""" - prompt = enhance_ai_prompt(packages, original_message) - return get_ai_suggestion(prompt, original_message) - - -def handle_commit_message(commit_file: str, original_message: str, config: Dict[str, Any]) -> None: - """Main function to handle commit message processing.""" - packages = get_changed_packages() - - if config['use_ai']: - try: - suggestions = get_ai_suggestions(packages, original_message) - if suggestions and prompt_user("Use AI suggestion?"): - write_commit_message(commit_file, suggestions[0]['message']) - return - except Exception as e: - if config['debug']: - print(f"\nāš ļø AI error: {str(e)}") - - # Simple format checking without package listing - if not is_conventional_commit(original_message): - formatted_message = format_conventional_commit(original_message) - write_commit_message(commit_file, formatted_message) - else: - write_commit_message(commit_file, original_message) - - 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() @@ -569,12 +507,45 @@ def main() -> None: if original_msg.startswith("Merge"): sys.exit(0) - handle_commit_message(commit_msg_file, original_msg, config._config) + packages = get_changed_packages() + if not packages: + sys.exit(0) + + print("\nšŸ” Analyzing changes...") + + # Get complexity-aware AI suggestions + if config.get("use_ai", True) and prompt_user("\nWould you like AI suggestions?"): + print("\nšŸ¤– Getting AI suggestions...") + prompt = enhance_ai_prompt(packages, original_msg) + 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() From 8d2a58f017a2c8aa8c9fdd4b1d4db15e3a1a89ca Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Fri, 1 Nov 2024 08:42:32 +0800 Subject: [PATCH 25/32] feat(gitguard): new PR --- packages/gitguard/gitguard-prepare.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gitguard/gitguard-prepare.py b/packages/gitguard/gitguard-prepare.py index 64394f0b..1f535360 100644 --- a/packages/gitguard/gitguard-prepare.py +++ b/packages/gitguard/gitguard-prepare.py @@ -26,7 +26,7 @@ class Config: "ai_provider": "azure", # Can be 'azure' or 'ollama' "azure_endpoint": "https://your-endpoint.openai.azure.com/", "azure_deployment": "gpt-4", - "azure_api_version": "2024-02-15-preview", + "azure_api_version": "2024-05-13", "ollama_host": "http://localhost:11434", "ollama_model": "codellama", "debug": False, From 3837ddf23e16e38256efb811cc5c010065d6ea8e Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Fri, 1 Nov 2024 08:43:15 +0800 Subject: [PATCH 26/32] feat(gitguard): ai --- packages/gitguard/gitguard-prepare.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gitguard/gitguard-prepare.py b/packages/gitguard/gitguard-prepare.py index 1f535360..c8f81071 100644 --- a/packages/gitguard/gitguard-prepare.py +++ b/packages/gitguard/gitguard-prepare.py @@ -11,7 +11,7 @@ from collections import defaultdict import re -# Try to import optional dependencies +# Try to import optional dependencies / check try: from openai import AzureOpenAI HAS_OPENAI = True From ee8f0c1e240cf3f718475178e255c590838b72c9 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Fri, 1 Nov 2024 09:23:26 +0800 Subject: [PATCH 27/32] feat(gitguard): add multi-AI suggestions and token count logging --- packages/gitguard/gitguard-prepare.py | 170 +++++++++++++++++++------- 1 file changed, 129 insertions(+), 41 deletions(-) diff --git a/packages/gitguard/gitguard-prepare.py b/packages/gitguard/gitguard-prepare.py index c8f81071..f06f6ebe 100644 --- a/packages/gitguard/gitguard-prepare.py +++ b/packages/gitguard/gitguard-prepare.py @@ -124,6 +124,18 @@ def generate(self, prompt: str, original_message: str) -> Optional[List[Dict[str 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 = { @@ -163,6 +175,13 @@ def calculate_commit_complexity(packages: List[Dict[str, Any]]) -> Dict[str, Any # 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]]: @@ -239,7 +258,7 @@ def enhance_ai_prompt(packages: List[Dict], original_msg: str) -> str: if complexity["needs_structure"]: prompt += """ -1. Follows the format: type(scope): description +1. Follows the conventional commit format: type(scope): description 2. Includes a clear, detailed description paragraph 3. Lists affected packages with key changes 4. Groups related changes together @@ -247,7 +266,7 @@ def enhance_ai_prompt(packages: List[Dict], original_msg: str) -> str: 6. Keep it concise but informative""" else: prompt += """ -1. Follows the format: type(scope): description +1. Follows the conventional commit format: type(scope): description 2. Is concise and focused 3. Clearly conveys the main change""" @@ -268,31 +287,31 @@ def enhance_ai_prompt(packages: List[Dict], original_msg: str) -> str: return prompt +def count_tokens(text: str) -> int: + """Estimate token count using a simple approximation.""" + # GPT models typically use ~4 chars per token on average + return len(text) // 4 + 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() - if config.get("debug"): - print("\nšŸ¤– Sending AI Prompt:") - print("-" * 40) - print(prompt) - print("-" * 40) + # 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 šŸ’°" + ) - # Try Ollama if configured - if config.get("ai_provider") == "ollama": - client = OllamaClient( - host=config.get("ollama_host"), - model=config.get("ollama_model") - ) - suggestions = client.generate(prompt, original_message) - if suggestions: - return suggestions + if config.get("debug"): + debug_log(prompt, "AI Prompt") # Try Azure OpenAI if available - if HAS_OPENAI: + 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: - print("\nāš ļø No Azure OpenAI API key found in config or environment") + debug_log("No Azure OpenAI API key found in config or environment", "Warning āš ļø") return None try: @@ -302,31 +321,91 @@ def get_ai_suggestion(prompt: str, original_message: str) -> Optional[List[Dict[ azure_endpoint=config.get("azure_endpoint"), ) - response = client.chat.completions.create( - model=config.get("azure_deployment"), - messages=[ - {"role": "system", "content": "You are a helpful git commit message assistant."}, - {"role": "user", "content": prompt} - ], - temperature=0.7, - max_tokens=1500, - n=3, - response_format={"type": "json_object"}, - ) - - for choice in response.choices: - try: - result = json.loads(choice.message.content) - if config.get("debug"): - print("\nšŸ¤– AI Response:") - print(json.dumps(result, indent=2)) - return result.get("suggestions", [])[:3] - except json.JSONDecodeError: - continue + # 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 helpful git commit message assistant. Always provide 3 distinct suggestions."}, + {"role": "user", "content": prompt} + ], + temperature=0.7, + max_tokens=1500, + n=1, # We'll get 3 suggestions in the JSON response + 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: - if config.get("debug"): - print(f"\nāš ļø Azure OpenAI error: {str(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 @@ -517,6 +596,15 @@ def main() -> None: if config.get("use_ai", True) and prompt_user("\nWould you like AI suggestions?"): print("\nšŸ¤– Getting AI suggestions...") prompt = enhance_ai_prompt(packages, original_msg) + + # Calculate and log token usage estimation before making the API call + 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 šŸ’°" + ) + suggestions = get_ai_suggestion(prompt, original_msg) if suggestions: From ba32fb764c6900e486659269a8e05f22f3ed72db Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Fri, 1 Nov 2024 09:31:17 +0800 Subject: [PATCH 28/32] feat(gitguard): tiktoken --- packages/gitguard/gitguard-prepare.py | 79 ++++++++++++++++++++------- 1 file changed, 60 insertions(+), 19 deletions(-) diff --git a/packages/gitguard/gitguard-prepare.py b/packages/gitguard/gitguard-prepare.py index f06f6ebe..d12fdec6 100644 --- a/packages/gitguard/gitguard-prepare.py +++ b/packages/gitguard/gitguard-prepare.py @@ -18,6 +18,12 @@ 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 = { @@ -288,9 +294,44 @@ def enhance_ai_prompt(packages: List[Dict], original_msg: str) -> str: return prompt def count_tokens(text: str) -> int: - """Estimate token count using a simple approximation.""" - # GPT models typically use ~4 chars per token on average - return len(text) // 4 + """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.""" @@ -592,28 +633,28 @@ def main() -> None: print("\nšŸ” Analyzing changes...") - # Get complexity-aware AI suggestions - if config.get("use_ai", True) and prompt_user("\nWould you like AI suggestions?"): - print("\nšŸ¤– Getting AI suggestions...") + # Check if AI is enabled in config + if config.get("use_ai", True): + # Generate prompt and calculate cost before asking user prompt = enhance_ai_prompt(packages, original_msg) - - # Calculate and log token usage estimation before making the API call 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)", + get_token_cost(token_count), "Token Usage šŸ’°" ) - 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 + # Now ask user if they want to proceed with AI suggestions + 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...") From fce677d0e8eeff3354aa67cbfbaadbdcbd96da73 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Fri, 1 Nov 2024 09:36:27 +0800 Subject: [PATCH 29/32] feat(gitguard): integrate tiktoken for commit message generation --- packages/gitguard/gitguard-prepare.py | 35 ++++++++------------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/packages/gitguard/gitguard-prepare.py b/packages/gitguard/gitguard-prepare.py index d12fdec6..d58db5c5 100644 --- a/packages/gitguard/gitguard-prepare.py +++ b/packages/gitguard/gitguard-prepare.py @@ -260,32 +260,14 @@ def enhance_ai_prompt(packages: List[Dict], original_msg: str) -> str: {diff} ``` -Please provide a commit message that:""" - - if complexity["needs_structure"]: - prompt += """ -1. Follows the conventional commit format: type(scope): description -2. Includes a clear, detailed description paragraph -3. Lists affected packages with key changes -4. Groups related changes together -5. Highlights significant changes first -6. Keep it concise but informative""" - else: - prompt += """ -1. Follows the conventional commit format: type(scope): description -2. Is concise and focused -3. Clearly conveys the main change""" - - prompt += f""" - -Response Format: +Please provide 3 conventional commit suggestions in this JSON format: {{ "suggestions": [ {{ - "message": "complete commit message with all sections", - "explanation": "why this format and focus was chosen", - "type": "commit type used", - "scope": "scope used", + "message": "complete commit message", + "explanation": "reasoning", + "type": "commit type", + "scope": "scope", "description": "title description" }} ] @@ -380,12 +362,15 @@ def get_ai_suggestion(prompt: str, original_message: str) -> Optional[List[Dict[ response = client.chat.completions.create( model=config.get("azure_deployment"), messages=[ - {"role": "system", "content": "You are a helpful git commit message assistant. Always provide 3 distinct suggestions."}, + { + "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, # We'll get 3 suggestions in the JSON response + n=1, response_format={"type": "json_object"}, ) From cf7675600bf890c26a2974f0a1c9fd650fe23f27 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Fri, 1 Nov 2024 10:52:30 +0800 Subject: [PATCH 30/32] feat(gitguard): add commit cohesion analysis and enhanced installation script --- packages/gitguard/gitguard-prepare.py | 141 +++++++++++++++++- packages/gitguard/install.sh | 134 +++++++++++++++-- packages/gitguard/package copy.json | 47 ++++++ packages/gitguard/package.json | 44 +++++- packages/gitguard/src/cli.ts | 0 packages/gitguard/src/config.ts | 0 .../gitguard/src/services/analysis.service.ts | 59 ++++++++ packages/gitguard/src/services/git.service.ts | 73 +++++++++ .../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 ++ 17 files changed, 640 insertions(+), 20 deletions(-) create mode 100644 packages/gitguard/package copy.json 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/packages/gitguard/gitguard-prepare.py b/packages/gitguard/gitguard-prepare.py index d58db5c5..62cd867e 100644 --- a/packages/gitguard/gitguard-prepare.py +++ b/packages/gitguard/gitguard-prepare.py @@ -514,8 +514,31 @@ def get_package_json_name(package_path: Path) -> Optional[str]: def get_changed_packages() -> List[Dict]: """Get all packages with changes in the current commit.""" - changed_files = check_output(["git", "diff", "--cached", "--name-only"]) - changed_files = changed_files.decode("utf-8").strip().split("\n") + 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: @@ -600,6 +623,108 @@ def display_suggestions(suggestions: List[Dict[str, str]]) -> Optional[str]: 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: @@ -618,9 +743,15 @@ def main() -> None: print("\nšŸ” Analyzing changes...") - # Check if AI is enabled in config + # 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): - # Generate prompt and calculate cost before asking user prompt = enhance_ai_prompt(packages, original_msg) token_count = count_tokens(prompt) debug_log( @@ -628,11 +759,9 @@ def main() -> None: "Token Usage šŸ’°" ) - # Now ask user if they want to proceed with AI suggestions 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: diff --git a/packages/gitguard/install.sh b/packages/gitguard/install.sh index 8ebfdcad..08a2ea02 100755 --- a/packages/gitguard/install.sh +++ b/packages/gitguard/install.sh @@ -6,28 +6,138 @@ 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 # Store the script's directory SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -# 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 +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 +} -# Create hooks directory if it doesn't exist -mkdir -p "$GIT_ROOT/.git/hooks" +install_hook() { + local target_dir="$1" + local hook_path="$target_dir/hooks/prepare-commit-msg" + + # Create hooks directory if it doesn't exist + mkdir -p "$target_dir/hooks" + + # Copy the hook + cp "$SCRIPT_DIR/gitguard-prepare.py" "$hook_path" + chmod +x "$hook_path" +} -# Copy the prepare-commit-msg hook from the correct location +# 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 -cp "$SCRIPT_DIR/gitguard-prepare.py" "$GIT_ROOT/.git/hooks/prepare-commit-msg" -chmod +x "$GIT_ROOT/.git/hooks/prepare-commit-msg" +# Function to handle installation choice +handle_installation() { + local git_dir="$1" + local location="$2" + local hook_path="$git_dir/hooks/prepare-commit-msg" + + local existing=$(check_existing_hook "$hook_path") + + case $existing in + "gitguard") + echo -e "${YELLOW}āš ļø GitGuard is already installed in $location location${NC}" + read -p "Do you want to overwrite it? (Y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Nn]$ ]]; then + install_hook "$git_dir" + echo -e "${GREEN}āœ… GitGuard hook updated in $location location${NC}" + fi + ;; + "other") + echo -e "${YELLOW}āš ļø Another prepare-commit-msg hook exists in $location location${NC}" + read -p "Do you want to overwrite it? (Y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Nn]$ ]]; then + install_hook "$git_dir" + echo -e "${GREEN}āœ… GitGuard hook installed in $location location${NC}" + fi + ;; + "none") + install_hook "$git_dir" + echo -e "${GREEN}āœ… GitGuard hook installed in $location location${NC}" + ;; + esac +} + +# Welcome message +echo -e "${BLUE}Welcome to GitGuard Installation!${NC}" +echo -e "This script can install GitGuard globally or for the current project.\n" + +# Check if we're in a git repository +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 + PROJECT_INSTALL=true + else + PROJECT_INSTALL=false + fi +else + echo -e "${YELLOW}āš ļø Not in a git repository - skipping project installation${NC}" + PROJECT_INSTALL=false +fi + +# Ask about global installation +read -p "Do you want to install GitGuard globally for all future git projects? (y/N) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + GLOBAL_INSTALL=true +else + GLOBAL_INSTALL=false +fi + +# Perform installations based on user choices +if [ "$GLOBAL_INSTALL" = true ]; then + GLOBAL_GIT_DIR="$(git config --global core.hooksPath)" + if [ -z "$GLOBAL_GIT_DIR" ]; then + GLOBAL_GIT_DIR="$HOME/.git/hooks" + # Set global hooks path + git config --global core.hooksPath "$GLOBAL_GIT_DIR" + fi + handle_installation "$GLOBAL_GIT_DIR" "global" +fi + +if [ "$PROJECT_INSTALL" = true ]; then + handle_installation "$GIT_PROJECT_DIR" "project" +fi + +# Final instructions +echo -e "\n${BLUE}Installation Complete!${NC}" +if [ "$GLOBAL_INSTALL" = true ]; then + echo -e "šŸŒ Global installation: Hooks will be applied to all new git projects" +fi +if [ "$PROJECT_INSTALL" = true ]; then + echo -e "šŸ“‚ Project installation: Hook installed for current project" +fi -echo -e "${GREEN}āœ… GitGuard hook installed successfully!${NC}" +# Configuration instructions +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" +echo -e "\nFor more information, visit: https://github.com/yourusername/gitguard#readme" 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 index 228e95ff..65cdd483 100644 --- a/packages/gitguard/package.json +++ b/packages/gitguard/package.json @@ -1,7 +1,47 @@ { - "name": "gitguard", + "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:^" + "@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/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..e0797574 --- /dev/null +++ b/packages/gitguard/src/services/analysis.service.ts @@ -0,0 +1,59 @@ +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 { + // Implementation + return { + totalCommits: 0, + filesChanged: 0, + additions: 0, + deletions: 0 + }; + } + + private generateWarnings(params: { + commits: CommitInfo[]; + stats: AnalysisStats; + }): AnalysisWarning[] { + // Implementation + return []; + } + } diff --git a/packages/gitguard/src/services/git.service.ts b/packages/gitguard/src/services/git.service.ts new file mode 100644 index 00000000..774bfd7b --- /dev/null +++ b/packages/gitguard/src/services/git.service.ts @@ -0,0 +1,73 @@ +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; + + constructor(private params: { config: GitConfig }) { + this.parser = new CommitParser(); + } + + 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"] + } From 44ad2567cc87d8e72ec252a7fb948450a26ba51c Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Fri, 1 Nov 2024 10:54:53 +0800 Subject: [PATCH 31/32] feat(publisher): enable source maps in tsconfig --- packages/publisher/tsconfig.json | 1 + 1 file changed, 1 insertion(+) 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"] From e1047f1a77bde8d8497d8a9ce401cc4260b24a18 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Fri, 1 Nov 2024 11:24:19 +0800 Subject: [PATCH 32/32] feat(gitguard): enhance installation and analysis functionalities --- packages/gitguard/README.md | 51 +- packages/gitguard/gitguard-prepare copy.py | 541 ------------------ packages/gitguard/gitguard-prepare.py | 7 +- packages/gitguard/install.sh | 202 +++---- .../gitguard/src/services/analysis.service.ts | 70 ++- packages/gitguard/src/services/git.service.ts | 17 +- 6 files changed, 217 insertions(+), 671 deletions(-) delete mode 100644 packages/gitguard/gitguard-prepare copy.py diff --git a/packages/gitguard/README.md b/packages/gitguard/README.md index e5b16b83..09cf1b43 100644 --- a/packages/gitguard/README.md +++ b/packages/gitguard/README.md @@ -1,6 +1,36 @@ -# GitGuard Commit Hook +# GitGuard -A smart Git commit hook that helps maintain high-quality, consistent commit messages across your monorepo. +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 @@ -38,23 +68,6 @@ git commit -m "update theme colors" # - @siteed/mobile-components ``` -## Installation - -1. Install the package in your monorepo: -```bash -yarn add -D @siteed/gitguard -``` - -2. Add to your git hooks (using husky or direct installation): -```bash -# Using husky -yarn husky add .husky/prepare-commit-msg 'yarn gitguard $1' - -# Direct installation -cp node_modules/@siteed/gitguard/gitguard-prepare.py .git/hooks/prepare-commit-msg -chmod +x .git/hooks/prepare-commit-msg -``` - ## Configuration GitGuard can be configured using: diff --git a/packages/gitguard/gitguard-prepare copy.py b/packages/gitguard/gitguard-prepare copy.py deleted file mode 100644 index b9b2e3e5..00000000 --- a/packages/gitguard/gitguard-prepare copy.py +++ /dev/null @@ -1,541 +0,0 @@ -#!/usr/bin/env python3 -"""GitGuard - A tool to help maintain consistent git commit messages.""" - -import sys -import os -from pathlib import Path -from subprocess import check_output -import json -from typing import Dict, List, Optional, Set -import requests - -# Try to import optional dependencies -try: - from openai import AzureOpenAI - - HAS_OPENAI = True -except ImportError: - HAS_OPENAI = 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://consensys-ai.openai.azure.com/", - "azure_deployment": "gpt-4o", - "azure_fallback_deployment": "gpt-35-turbo-16k", # Fallback model - "azure_api_version": "2024-02-15-preview", - "azure_fallback_api_version": "2024-02-15-preview", # Fallback API version - "ollama_host": "http://localhost:11434", - "ollama_model": "codellama", - "debug": True, - } - - 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, system_prompt: str, user_prompt: 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": f"{system_prompt}\n\n{user_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 get_azure_suggestions( - client: AzureOpenAI, - config: Config, - system_prompt: str, - user_prompt: str, - deployment: str, - api_version: str -) -> Optional[List[Dict[str, str]]]: - """Get suggestions from Azure OpenAI with specific deployment and API version.""" - try: - # Update client with specific API version - client.api_version = api_version - - response = client.chat.completions.create( - model=deployment, - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - temperature=0.9, - max_tokens=1500, - n=3, - response_format={"type": "json_object"}, - ) - - all_suggestions = [] - for choice in response.choices: - result = json.loads(choice.message.content) - all_suggestions.extend(result.get("suggestions", [])) - - return all_suggestions[:3] - - except Exception as e: - if config.get("debug"): - print(f"\nāš ļø Azure OpenAI error with {deployment} (API version {api_version}): {str(e)}") - raise # Re-raise the exception to handle it in the calling function - - -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() - - if config.get("debug"): - print("\nšŸ”§ Active configuration:", json.dumps(config._config, indent=2)) - - system_prompt = """You are a helpful git commit message assistant. - Provide exactly 3 different conventional commit message suggestions, each with a unique approach. - Return your response in the following JSON format: - { - "suggestions": [ - { - "message": "type(scope): description", - "explanation": "Brief explanation of why this format was chosen", - "type": "commit type used", - "scope": "scope used", - "description": "main message" - } - ] - } - Ensure each suggestion has a different focus or perspective.""" - - # Try Ollama if configured - if config.get("ai_provider") == "ollama": - client = OllamaClient( - host=config.get("ollama_host"), - model=config.get("ollama_model") - ) - suggestions = client.generate(system_prompt, prompt) - if suggestions: - return suggestions - - # Try Azure OpenAI if available - if HAS_OPENAI: - api_key = config.get("azure_api_key") or os.getenv("AZURE_OPENAI_API_KEY") - if not api_key: - print("\nāš ļø No Azure OpenAI API key found in config or environment") - return None - - # Create new client for each attempt to ensure clean state - def create_client(api_version: str) -> AzureOpenAI: - return AzureOpenAI( - api_key=api_key, - api_version=api_version, - azure_endpoint=config.get("azure_endpoint"), - ) - - # Try primary model - try: - if config.get("debug"): - print(f"\nšŸ”§ Using primary model: {config.get('azure_deployment')}") - print(f"API Version: {config.get('azure_api_version')}") - print(f"Endpoint: {config.get('azure_endpoint')}") - - client = create_client(config.get("azure_api_version")) - suggestions = get_azure_suggestions( - client, - config, - system_prompt, - prompt, - config.get("azure_deployment"), - config.get("azure_api_version") - ) - if suggestions: - return suggestions - - except Exception as primary_error: - # Try fallback model - if config.get("azure_fallback_deployment"): - try: - if config.get("debug"): - print(f"\nšŸ”„ Trying fallback model: {config.get('azure_fallback_deployment')}") - print(f"Fallback API Version: {config.get('azure_fallback_api_version', config.get('azure_api_version'))}") - - fallback_api_version = config.get("azure_fallback_api_version", config.get("azure_api_version")) - client = create_client(fallback_api_version) - suggestions = get_azure_suggestions( - client, - config, - system_prompt, - prompt, - config.get("azure_fallback_deployment"), - fallback_api_version - ) - if suggestions: - return suggestions - - except Exception as fallback_error: - if config.get("debug"): - print(f"\nāš ļø Fallback model failed: {str(fallback_error)}") - else: - if config.get("debug"): - print("\nāš ļø No fallback model configured") - - return None - - -def detect_change_types(files: List[str]) -> Set[str]: - """Detect change types based on files modified.""" - types = set() - - for file in files: - file_lower = file.lower() - name = Path(file).name.lower() - - if any(pattern in file_lower for pattern in [".test.", ".spec.", "/tests/"]): - types.add("test") - elif any(pattern in file_lower for pattern in [".md", "readme", "docs/"]): - types.add("docs") - elif any(pattern in file_lower for pattern in [".css", ".scss", ".styled."]): - types.add("style") - elif any( - pattern in name for pattern in ["package.json", ".config.", "tsconfig"] - ): - types.add("chore") - elif any(word in file_lower for word in ["fix", "bug", "patch"]): - types.add("fix") - - if not types: - types.add("feat") - - return types - - -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 - - -def get_changed_packages() -> List[Dict]: - """Get all packages with changes in the current commit.""" - changed_files = check_output(["git", "diff", "--cached", "--name-only"]) - changed_files = changed_files.decode("utf-8").strip().split("\n") - - 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, - "types": detect_change_types(files), - } - ) - - return results - - -def format_commit_message( - original_msg: str, package: Dict, commit_type: Optional[str] = None -) -> str: - """Format commit message for a single package.""" - if ":" in original_msg: - type_part, msg = original_msg.split(":", 1) - msg = msg.strip() - if "(" in type_part and ")" in type_part: - commit_type = type_part.split("(")[0] - else: - commit_type = type_part - else: - msg = original_msg - if not commit_type: - commit_type = next(iter(package["types"])) - - return f"{commit_type}({package['scope']}): {msg}" - - -def generate_ai_prompt(packages: List[Dict], original_msg: str) -> str: - """Generate a detailed prompt for AI assistance.""" - try: - diff = check_output(["git", "diff", "--cached"]).decode("utf-8") - except: - diff = "Failed to get diff" - - prompt = f"""Please suggest a git commit message following conventional commits format. - -Original message: "{original_msg}" - -Changed packages: -{'-' * 40}""" - - for pkg in packages: - prompt += f""" - -šŸ“¦ Package: {pkg['name']} -Detected change types: {', '.join(pkg['types'])} -Files changed: -{chr(10).join(f'- {file}' for file in pkg['files'])}""" - - prompt += f""" -{'-' * 40} - -Git diff: -```diff -{diff} -``` - -Please provide a single commit message that: -1. Follows the format: type(scope): description -2. Uses the most significant package as scope -3. Lists other affected packages if any -4. Includes brief bullet points for significant changes - -Use one of: feat|fix|docs|style|refactor|perf|test|chore -Keep the description clear and concise""" - - return prompt - - -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, defaults to the first suggestion on EOF.""" - 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 get_main_package(packages: List[Dict]) -> Optional[Dict]: - """Get the package with the most changes.""" - if not packages: - return {"name": "default", "scope": "default", "files": [], "types": {"feat"}} - - # Sort packages by number of files changed - sorted_packages = sorted(packages, key=lambda x: len(x["files"]), reverse=True) - return sorted_packages[0] - - -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...") - print("Original message:", original_msg) - - # Handle multiple packages first - if len(packages) > 1: - print("\nšŸ“¦ Changes in multiple packages:") - for pkg in packages: - print(f"ā€¢ {pkg['name']} ({', '.join(pkg['types'])})") - for file in pkg["files"]: - print(f" - {file}") - print("\nāš ļø Consider splitting this commit for better readability!") - - # AI suggestion flow - only if user wants it - if prompt_user("\nWould you like AI suggestions?"): - print("\nšŸ¤– Getting AI suggestions...") - prompt = generate_ai_prompt(packages, original_msg) - 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 - if len(packages) > 1: - main_pkg = get_main_package(packages) # Use the package with most changes - main_type = next(iter(main_pkg["types"])) - new_msg = format_commit_message(original_msg, main_pkg, main_type) - new_msg += "\n\nAffected packages:\n" + "\n".join( - f"- {p['name']}" for p in packages - ) - else: - pkg = packages[0] - main_type = next(iter(pkg["types"])) - new_msg = format_commit_message(original_msg, pkg, main_type) - - print(f"\nāœØ Suggested message: {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/gitguard-prepare.py b/packages/gitguard/gitguard-prepare.py index 62cd867e..54968dd2 100644 --- a/packages/gitguard/gitguard-prepare.py +++ b/packages/gitguard/gitguard-prepare.py @@ -3,13 +3,14 @@ import sys import os +import requests +import re +import json from pathlib import Path from subprocess import check_output -import json from typing import Dict, List, Optional, Set, Any -import requests from collections import defaultdict -import re + # Try to import optional dependencies / check try: diff --git a/packages/gitguard/install.sh b/packages/gitguard/install.sh index 08a2ea02..07fa5255 100755 --- a/packages/gitguard/install.sh +++ b/packages/gitguard/install.sh @@ -10,9 +10,50 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color -# Store the script's directory +# 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 @@ -29,115 +70,84 @@ check_existing_hook() { install_hook() { local target_dir="$1" local hook_path="$target_dir/hooks/prepare-commit-msg" - - # Create hooks directory if it doesn't exist mkdir -p "$target_dir/hooks" - - # Copy the hook cp "$SCRIPT_DIR/gitguard-prepare.py" "$hook_path" chmod +x "$hook_path" } -# 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 - -# Function to handle installation choice handle_installation() { - local git_dir="$1" - local location="$2" - local hook_path="$git_dir/hooks/prepare-commit-msg" + local target_dir="$1" + local install_type="$2" + local hook_path="$target_dir/hooks/prepare-commit-msg" - local existing=$(check_existing_hook "$hook_path") + # Check existing hook + local existing_hook=$(check_existing_hook "$hook_path") - case $existing in - "gitguard") - echo -e "${YELLOW}āš ļø GitGuard is already installed in $location location${NC}" - read -p "Do you want to overwrite it? (Y/n) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Nn]$ ]]; then - install_hook "$git_dir" - echo -e "${GREEN}āœ… GitGuard hook updated in $location location${NC}" - fi - ;; - "other") - echo -e "${YELLOW}āš ļø Another prepare-commit-msg hook exists in $location location${NC}" - read -p "Do you want to overwrite it? (Y/n) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Nn]$ ]]; then - install_hook "$git_dir" - echo -e "${GREEN}āœ… GitGuard hook installed in $location location${NC}" - fi - ;; - "none") - install_hook "$git_dir" - echo -e "${GREEN}āœ… GitGuard hook installed in $location location${NC}" - ;; - esac -} + 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 -# Welcome message -echo -e "${BLUE}Welcome to GitGuard Installation!${NC}" -echo -e "This script can install GitGuard globally or for the current project.\n" + # Install the hook + install_hook "$target_dir" + echo -e "${GREEN}āœ… GitGuard installed successfully for $install_type use!${NC}" +} -# Check if we're in a git repository -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)" +# Main installation logic +main() { + # Development installation flow + echo -e "${BLUE}Welcome to GitGuard Development Installation!${NC}" - read -p "Do you want to install GitGuard for this project? (Y/n) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Nn]$ ]]; then - PROJECT_INSTALL=true - else - PROJECT_INSTALL=false + # 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 -else - echo -e "${YELLOW}āš ļø Not in a git repository - skipping project installation${NC}" - PROJECT_INSTALL=false -fi -# Ask about global installation -read -p "Do you want to install GitGuard globally for all future git projects? (y/N) " -n 1 -r -echo -if [[ $REPLY =~ ^[Yy]$ ]]; then - GLOBAL_INSTALL=true -else - GLOBAL_INSTALL=false -fi - -# Perform installations based on user choices -if [ "$GLOBAL_INSTALL" = true ]; then - GLOBAL_GIT_DIR="$(git config --global core.hooksPath)" - if [ -z "$GLOBAL_GIT_DIR" ]; then - GLOBAL_GIT_DIR="$HOME/.git/hooks" - # Set global hooks path - git config --global core.hooksPath "$GLOBAL_GIT_DIR" + # 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 - handle_installation "$GLOBAL_GIT_DIR" "global" -fi -if [ "$PROJECT_INSTALL" = true ]; then - handle_installation "$GIT_PROJECT_DIR" "project" -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 +} -# Final instructions -echo -e "\n${BLUE}Installation Complete!${NC}" -if [ "$GLOBAL_INSTALL" = true ]; then - echo -e "šŸŒ Global installation: Hooks will be applied to all new git projects" -fi -if [ "$PROJECT_INSTALL" = true ]; then - echo -e "šŸ“‚ Project installation: Hook installed for current project" +# 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 - -# Configuration instructions -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" -echo -e "\nFor more information, visit: https://github.com/yourusername/gitguard#readme" diff --git a/packages/gitguard/src/services/analysis.service.ts b/packages/gitguard/src/services/analysis.service.ts index e0797574..70e276e4 100644 --- a/packages/gitguard/src/services/analysis.service.ts +++ b/packages/gitguard/src/services/analysis.service.ts @@ -40,12 +40,24 @@ import { private calculateStats(params: { commits: CommitInfo[] }): AnalysisStats { - // Implementation + 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: 0, - filesChanged: 0, - additions: 0, - deletions: 0 + totalCommits: commits.length, + filesChanged: filesChanged.size, + additions, + deletions }; } @@ -53,7 +65,51 @@ import { commits: CommitInfo[]; stats: AnalysisStats; }): AnalysisWarning[] { - // Implementation - return []; + 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 index 774bfd7b..a1e2a360 100644 --- a/packages/gitguard/src/services/git.service.ts +++ b/packages/gitguard/src/services/git.service.ts @@ -3,11 +3,18 @@ import { CommitInfo, FileChange } from '../types/commit.types'; import { CommitParser } from '../utils/commit-parser.util'; export class GitService { - private parser: CommitParser; - - constructor(private params: { config: GitConfig }) { - this.parser = new CommitParser(); - } + 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({