From bf17aa220571039098b7b85b8dda3bfe05a9f78c Mon Sep 17 00:00:00 2001 From: sumansaurabh Date: Sun, 2 Mar 2025 14:25:26 +0530 Subject: [PATCH 01/10] feat: Add LLM client for generating commit summaries and update dependencies --- penify_hook/api_client.py | 21 +++++- penify_hook/commit_analyzer.py | 16 +++-- penify_hook/llm_client.py | 113 +++++++++++++++++++++++++++++++++ penify_hook/main.py | 108 +++++++++++++++++++++++++++++-- setup.py | 3 +- 5 files changed, 249 insertions(+), 12 deletions(-) create mode 100644 penify_hook/llm_client.py diff --git a/penify_hook/api_client.py b/penify_hook/api_client.py index 7172831..b5eec86 100644 --- a/penify_hook/api_client.py +++ b/penify_hook/api_client.py @@ -135,6 +135,26 @@ def generate_commit_summary(self, git_diff, instruction: str = "", repo_details print(f"Error: {response.text}") return None + def generate_commit_summary_with_llm(self, diff, message, repo_details, llm_client): + """ + Generate a commit summary using a local LLM client instead of the API. + + Args: + diff: Git diff of changes + message: User-provided commit message or instructions + repo_details: Details about the repository + llm_client: Instance of LLMClient + + Returns: + Dict with title and description for the commit + """ + try: + return llm_client.generate_commit_summary(diff, message, repo_details) + except Exception as e: + print(f"Error using local LLM: {e}") + # Fall back to API for commit summary + return self.generate_commit_summary(diff, message, repo_details) + def get_api_key(self): url = self.api_url+"/v1/apiToken/get" @@ -147,4 +167,3 @@ def get_api_key(self): print(f"Error: {response.text}") return None - \ No newline at end of file diff --git a/penify_hook/commit_analyzer.py b/penify_hook/commit_analyzer.py index 2146968..b1757f2 100644 --- a/penify_hook/commit_analyzer.py +++ b/penify_hook/commit_analyzer.py @@ -8,9 +8,10 @@ from .api_client import APIClient class CommitDocGenHook: - def __init__(self, repo_path: str, api_client: APIClient): + def __init__(self, repo_path: str, api_client: APIClient, llm_client=None): self.repo_path = repo_path self.api_client = api_client + self.llm_client = llm_client # Add LLM client as an optional parameter self.repo = Repo(repo_path) self.supported_file_types = set(self.api_client.get_supported_file_types()) self.repo_details = self.get_repo_details() @@ -87,7 +88,8 @@ def get_summary(self, instruction: str): This function retrieves the differences of the staged changes in the repository and generates a commit summary using the provided instruction. If there are no changes staged for commit, an exception is - raised. + raised. If an LLM client is provided, it will use that for generating + the summary, otherwise it will use the API client. Args: instruction (str): A string containing instructions for generating the commit summary. @@ -103,7 +105,14 @@ def get_summary(self, instruction: str): diff = self.repo.git.diff('--cached') if not diff: raise Exception("No changes to commit") - return self.api_client.generate_commit_summary(diff, instruction, self.repo_details) + + # Use LLM client if provided, otherwise use API client + if self.llm_client: + return self.api_client.generate_commit_summary_with_llm( + diff, instruction, self.repo_details, self.llm_client + ) + else: + return self.api_client.generate_commit_summary(diff, instruction, self.repo_details) def run(self, msg: Optional[str], edit_commit_message: bool): @@ -160,4 +169,3 @@ def _amend_commit(self): finally: # Change back to the original directory os.chdir(os.path.dirname(os.path.abspath(__file__))) - \ No newline at end of file diff --git a/penify_hook/llm_client.py b/penify_hook/llm_client.py new file mode 100644 index 0000000..44fac67 --- /dev/null +++ b/penify_hook/llm_client.py @@ -0,0 +1,113 @@ +import json +import os +from typing import Dict, Optional, List, Any, Union +import litellm + +class LLMClient: + """ + Client for interacting with LLM models using LiteLLM. + """ + + def __init__(self, model: str = None, api_base: str = None, api_key: str = None): + """ + Initialize the LLM client. + + Args: + model: LLM model to use (e.g., "gpt-4", "ollama/llama2", etc.) + api_base: Base URL for API requests (e.g., "http://localhost:11434" for Ollama) + api_key: API key for the LLM service + """ + self.model = model + self.api_base = api_base + self.api_key = api_key + + # Configure litellm if parameters are provided + if api_base: + os.environ["OPENAI_API_BASE"] = api_base + if api_key: + os.environ["OPENAI_API_KEY"] = api_key + + def generate_commit_summary(self, diff: str, message: str, repo_details: Dict) -> Dict: + """ + Generate a commit summary using the LLM. + + Args: + diff: Git diff of changes + message: User-provided commit message or instructions + repo_details: Details about the repository + + Returns: + Dict with title and description for the commit + """ + if not self.model: + raise ValueError("LLM model not configured. Please provide a model when initializing LLMClient.") + + # Limit diff size to avoid token limits + max_diff_chars = 10000 + if len(diff) > max_diff_chars: + diff = diff[:max_diff_chars] + f"\n... (diff truncated, total {len(diff)} characters)" + + # Create prompt for the LLM + prompt = f""" + Based on the Git diff below, generate a concise and descriptive commit summary. + + Repository: {repo_details.get('organization_name')}/{repo_details.get('repo_name')} + Hosted on: {repo_details.get('vendor', 'Unknown')} + + User instructions: {message} + + Git diff: + ``` + {diff} + ``` + + Please provide: + 1. A short, focused commit title (50-72 characters) + 2. A more detailed description of the changes + + Format your response as valid JSON with 'title' and 'description' keys. + """ + + try: + # Call the LLM using litellm + response = litellm.completion( + model=self.model, + messages=[{"role": "user", "content": prompt}], + temperature=0.2, + max_tokens=500 + ) + + content = response.choices[0].message.content + + # Extract JSON from the response + try: + # Try to parse the entire content as JSON + result = json.loads(content) + if not isinstance(result, dict) or 'title' not in result or 'description' not in result: + raise ValueError("Invalid JSON structure") + + except json.JSONDecodeError: + # If that fails, try to extract JSON from the content + import re + json_match = re.search(r'```json\s*(.*?)\s*```', content, re.DOTALL) + if json_match: + result = json.loads(json_match.group(1)) + else: + # Last resort: extract title and description directly + lines = content.split('\n') + title = next((line for line in lines if line.strip()), "Generated commit").strip() + description = "\n".join(line for line in lines[1:] if line.strip()) + result = { + "title": title, + "description": description + } + + return result + + except Exception as e: + # Fallback to a basic summary if LLM fails + print(f"Error generating commit summary with LLM: {e}") + return { + "title": "Update code", + "description": f"Changes were made to the repository.\n\nUser message: {message}" + } diff --git a/penify_hook/main.py b/penify_hook/main.py index 42a30d0..b4ba587 100644 --- a/penify_hook/main.py +++ b/penify_hook/main.py @@ -17,6 +17,11 @@ from .file_analyzer import FileAnalyzerGenHook from .api_client import APIClient from .git_analyzer import GitDocGenHook +try: + from .llm_client import LLMClient +except ImportError: + # Handle case where litellm is not installed + LLMClient = None HOOK_FILENAME = "post-commit" HOOK_TEMPLATE = """#!/bin/sh @@ -102,16 +107,31 @@ def generate_doc(token, file_path=None, complete_folder_path=None, git_folder_pa print(f"Error: {e}") sys.exit(1) -def commit_code(gf_path: str, token: str, message: str, open_terminal: bool): - # Implement the logic to perform a commit with a message +def commit_code(gf_path: str, token: str, message: str, open_terminal: bool, llm_model=None, llm_api_base=None, llm_api_key=None): + # Create API client api_client = APIClient(api_url, token) + + # Initialize LLM client if LLM parameters are provided and LLMClient is available + llm_client = None + if LLMClient is not None and llm_model: + try: + llm_client = LLMClient( + model=llm_model, + api_base=llm_api_base, + api_key=llm_api_key + ) + print(f"Using LLM model: {llm_model}") + except Exception as e: + print(f"Error initializing LLM client: {e}") + print("Falling back to API for commit summary generation") + try: - analyzer = CommitDocGenHook(gf_path, api_client) + # Pass the LLM client to CommitDocGenHook + analyzer = CommitDocGenHook(gf_path, api_client, llm_client) analyzer.run(message, open_terminal) except Exception as e: print(f"Error: {e}") sys.exit(1) - # You can add actual Git commit logic here using subprocess or GitPython, etc. def save_credentials(api_key): """ @@ -130,6 +150,52 @@ def save_credentials(api_key): except Exception as e: print(f"Error saving credentials: {str(e)}") +def save_llm_config(model, api_base, api_key): + """ + Save LLM configuration settings in the .penify file in the user's home directory. + """ + home_dir = Path.home() + penify_file = home_dir / '.penify' + + config = {} + if penify_file.exists(): + try: + with open(penify_file, 'r') as f: + config = json.load(f) + except json.JSONDecodeError: + pass + + # Update or add LLM configuration + config['llm'] = { + 'model': model, + 'api_base': api_base, + 'api_key': api_key + } + + try: + with open(penify_file, 'w') as f: + json.dump(config, f) + print(f"LLM configuration saved to {penify_file}") + except Exception as e: + print(f"Error saving LLM configuration: {str(e)}") + +def get_llm_config(): + """ + Get LLM configuration from the .penify file. + """ + config_file = Path.home() / '.penify' + if config_file.exists(): + try: + with open(config_file, 'r') as f: + config = json.load(f) + return config.get('llm', {}) + except json.JSONDecodeError: + print("Error reading .penify config file. File may be corrupted.") + except Exception as e: + print(f"Error reading .penify config file: {str(e)}") + + return {} + def login(): """ Open the login page in a web browser and listen for the redirect URL to capture the token. @@ -274,6 +340,16 @@ def main(): commit_parser.add_argument("-gf", "--git_folder_path", help="Path to the folder, with git, to scan for modified files. Defaults to the current folder.", default=os.getcwd()) commit_parser.add_argument("-m", "--message", required=False, help="Commit message.", default="N/A") commit_parser.add_argument("-e", "--terminal", required=False, help="Open edit terminal", default="False") + # Add LLM options for the commit subcommand + commit_parser.add_argument("--llm", "--llm-model", dest="llm_model", help="LLM model to use for commit message generation (e.g., ollama/llama2, gpt-3.5-turbo)") + commit_parser.add_argument("--llm-api-base", help="API base URL for the LLM service (e.g., http://localhost:11434 for Ollama)") + commit_parser.add_argument("--llm-api-key", help="API key for the LLM service") + + # Add a new subcommand: config-llm + llm_config_parser = subparsers.add_parser("config-llm", help="Configure LLM settings for commit message generation") + llm_config_parser.add_argument("--model", required=True, help="LLM model to use (e.g., ollama/llama2, gpt-3.5-turbo)") + llm_config_parser.add_argument("--api-base", help="API base URL for the LLM service (e.g., http://localhost:11434 for Ollama)") + llm_config_parser.add_argument("--api-key", help="API key for the LLM service") # Subcommand: login login_parser = subparsers.add_parser("login", help="Log in to Penify and obtain an API token.") @@ -299,9 +375,29 @@ def main(): if not token: print("Error: API token is required. Please provide it using -t option, PENIFY_API_TOKEN environment variable, or log in first.") sys.exit(1) + open_terminal = args.terminal.lower() == "true" - args.git_folder_path = find_git_parent(args.git_folder_path) - commit_code(args.git_folder_path, token, args.message, open_terminal) + + # Get LLM configuration - first from command line args, then from config file + llm_model = args.llm_model + llm_api_base = args.llm_api_base + llm_api_key = args.llm_api_key + + if not llm_model: + # Try to get from config + llm_config = get_llm_config() + llm_model = llm_config.get('model') + llm_api_base = llm_config.get('api_base') if not llm_api_base else llm_api_base + llm_api_key = llm_config.get('api_key') if not llm_api_key else llm_api_key + + commit_code(args.git_folder_path, token, args.message, open_terminal, + llm_model, llm_api_base, llm_api_key) + + elif args.subcommand == "config-llm": + # Save LLM configuration + save_llm_config(args.model, args.api_base, args.api_key) + print(f"LLM configuration set: Model={args.model}, API Base={args.api_base or 'default'}") + elif args.subcommand == "login": login() else: diff --git a/setup.py b/setup.py index b586b09..9f0135f 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,8 @@ install_requires=[ "requests", "tqdm", - "GitPython" + "GitPython", + "litellm" # Add litellm as a dependency ], entry_points={ "console_scripts": [ From b56450fad1993d9c1b5f1f09242b757d696bcb22 Mon Sep 17 00:00:00 2001 From: sumansaurabh Date: Sun, 2 Mar 2025 14:50:25 +0530 Subject: [PATCH 02/10] feat: Add JIRA integration and update dependencies in commit analyzer --- README.md | 14 +++ penify_hook/commit_analyzer.py | 56 ++++++++- penify_hook/jira_client.py | 206 +++++++++++++++++++++++++++++++++ penify_hook/main.py | 134 +++++++++++++++++++-- setup.py | 3 +- 5 files changed, 402 insertions(+), 11 deletions(-) create mode 100644 penify_hook/jira_client.py diff --git a/README.md b/README.md index d0de908..5e8c2cd 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,20 @@ penify-cli commit -gf /path/to/git/repo [-m "Optional message"] [-e True/False] - `-m, --message`: Optional commit message. If not provided, a default message will be used. - `-e, --terminal`: Set to "True" to open the terminal for editing the commit message. Defaults to "False". +### JIRA Integration + +To integrate with JIRA and automate issue tracking: + +```bash +penify-cli jira [options] +``` + +Options: +- `-u, --url`: JIRA instance URL. +- `-p, --project`: JIRA project key. +- `-i, --issue`: JIRA issue key. +- `-a, --assignee`: Assignee for the JIRA issue. + ## Authentication Penify CLI uses an API token for authentication. The token is obtained and used in the following priority: diff --git a/penify_hook/commit_analyzer.py b/penify_hook/commit_analyzer.py index b1757f2..49d2e95 100644 --- a/penify_hook/commit_analyzer.py +++ b/penify_hook/commit_analyzer.py @@ -2,16 +2,17 @@ import re import subprocess import tempfile -from typing import Optional +from typing import Optional, List from git import Repo from tqdm import tqdm from .api_client import APIClient class CommitDocGenHook: - def __init__(self, repo_path: str, api_client: APIClient, llm_client=None): + def __init__(self, repo_path: str, api_client: APIClient, llm_client=None, jira_client=None): self.repo_path = repo_path self.api_client = api_client self.llm_client = llm_client # Add LLM client as an optional parameter + self.jira_client = jira_client # Add JIRA client as an optional parameter self.repo = Repo(repo_path) self.supported_file_types = set(self.api_client.get_supported_file_types()) self.repo_details = self.get_repo_details() @@ -136,17 +137,66 @@ def run(self, msg: Optional[str], edit_commit_message: bool): summary: dict = self.get_summary(msg) if not summary: raise Exception("Error generating commit summary") + title = summary.get('title', "") description = summary.get('description', "") - + + # If JIRA client is available, integrate JIRA information + if self.jira_client and self.jira_client.is_connected(): + # Add JIRA information to commit message + title, description = self.process_jira_integration(title, description, msg) + # commit the changes to the repository with above details commit_msg = f"{title}\n\n{description}" self.repo.git.commit('-m', commit_msg) + if edit_commit_message: # Open the git commit edit terminal print("Opening git commit edit terminal...") self._amend_commit() + + def process_jira_integration(self, title: str, description: str, msg: str) -> tuple: + """ + Process JIRA integration for the commit message. + Args: + title: Generated commit title + description: Generated commit description + msg: Original user message that might contain JIRA references + + Returns: + tuple: (updated_title, updated_description) with JIRA information + """ + # Look for JIRA issue keys in commit message, title, description and user message + issue_keys = [] + if self.jira_client: + issue_keys = self.jira_client.extract_issue_keys(f"{title} {description} {msg}") + + if issue_keys: + print(f"Found JIRA issues: {', '.join(issue_keys)}") + + # Format commit message with JIRA info + title, description = self.jira_client.format_commit_message_with_jira_info( + title, description, issue_keys + ) + + # Add comments to JIRA issues + for issue_key in issue_keys: + comment = ( + f"Commit related to this issue:\n\n" + f"**{title}**\n\n" + f"{description}\n\n" + f"Repository: {self.repo_details.get('organization_name')}/{self.repo_details.get('repo_name')}" + ) + self.jira_client.add_comment(issue_key, comment) + + # Optionally update issue status (commented out by default) + # Uncomment and customize the status if needed + # self.jira_client.update_issue_status(issue_key, "In Progress") + else: + print("No JIRA issues found in commit message") + + return title, description def _amend_commit(self): """Open the default git editor for editing the commit message. diff --git a/penify_hook/jira_client.py b/penify_hook/jira_client.py new file mode 100644 index 0000000..1a6eb42 --- /dev/null +++ b/penify_hook/jira_client.py @@ -0,0 +1,206 @@ +import re +import logging +from typing import Optional, Dict, List, Any +try: + from jira import JIRA + JIRA_AVAILABLE = True +except ImportError: + JIRA_AVAILABLE = False + +class JiraClient: + """ + Client for interacting with JIRA API + """ + + def __init__(self, jira_url: str = None, jira_user: str = None, jira_api_token: str = None): + """ + Initialize the JIRA client. + + Args: + jira_url: Base URL for JIRA instance (e.g., "https://your-domain.atlassian.net") + jira_user: JIRA username or email + jira_api_token: JIRA API token + """ + self.jira_url = jira_url + self.jira_user = jira_user + self.jira_api_token = jira_api_token + self.jira_client = None + + if not JIRA_AVAILABLE: + logging.warning("JIRA package not available. JIRA integration will not work.") + return + + if jira_url and jira_user and jira_api_token: + try: + self.jira_client = JIRA( + server=jira_url, + basic_auth=(jira_user, jira_api_token) + ) + logging.info("JIRA client initialized successfully") + except Exception as e: + logging.error(f"Failed to initialize JIRA client: {e}") + self.jira_client = None + + def is_connected(self) -> bool: + """ + Check if the JIRA client is connected. + + Returns: + bool: True if connected, False otherwise + """ + return self.jira_client is not None + + def extract_issue_keys(self, text: str) -> List[str]: + """ + Extract JIRA issue keys from text. + + Args: + text: Text to search for JIRA issue keys + + Returns: + List of JIRA issue keys found + """ + # Common JIRA issue key pattern: PROJECT-123 + pattern = r'[A-Z][A-Z0-9_]+-[0-9]+' + matches = re.findall(pattern, text) + return list(set(matches)) # Remove duplicates + + def get_issue_details(self, issue_key: str) -> Optional[Dict[str, Any]]: + """ + Get details of a JIRA issue. + + Args: + issue_key: JIRA issue key (e.g., "PROJECT-123") + + Returns: + Dict with issue details or None if not found + """ + if not self.is_connected(): + logging.warning("JIRA client not connected") + return None + + try: + issue = self.jira_client.issue(issue_key) + return { + 'key': issue.key, + 'summary': issue.fields.summary, + 'status': issue.fields.status.name, + 'description': issue.fields.description, + 'assignee': issue.fields.assignee.displayName if issue.fields.assignee else None, + 'reporter': issue.fields.reporter.displayName if issue.fields.reporter else None, + 'type': issue.fields.issuetype.name, + 'priority': issue.fields.priority.name if hasattr(issue.fields, 'priority') and issue.fields.priority else None, + 'url': f"{self.jira_url}/browse/{issue.key}" + } + except Exception as e: + logging.error(f"Error fetching issue {issue_key}: {e}") + return None + + def add_comment(self, issue_key: str, comment: str) -> bool: + """ + Add a comment to a JIRA issue. + + Args: + issue_key: JIRA issue key (e.g., "PROJECT-123") + comment: Comment text to add + + Returns: + bool: True if comment was added successfully, False otherwise + """ + if not self.is_connected(): + logging.warning("JIRA client not connected") + return False + + try: + self.jira_client.add_comment(issue_key, comment) + logging.info(f"Comment added to {issue_key}") + return True + except Exception as e: + logging.error(f"Error adding comment to {issue_key}: {e}") + return False + + def update_issue_status(self, issue_key: str, transition_name: str) -> bool: + """ + Update the status of a JIRA issue. + + Args: + issue_key: JIRA issue key (e.g., "PROJECT-123") + transition_name: Name of the transition (e.g., "In Progress", "Done") + + Returns: + bool: True if status was updated successfully, False otherwise + """ + if not self.is_connected(): + logging.warning("JIRA client not connected") + return False + + try: + # Get available transitions + transitions = self.jira_client.transitions(issue_key) + + # Find the transition ID based on name + transition_id = None + for t in transitions: + if t['name'].lower() == transition_name.lower(): + transition_id = t['id'] + break + + if transition_id: + self.jira_client.transition_issue(issue_key, transition_id) + logging.info(f"Updated {issue_key} status to {transition_name}") + return True + else: + logging.warning(f"Transition '{transition_name}' not found for {issue_key}") + return False + + except Exception as e: + logging.error(f"Error updating status for {issue_key}: {e}") + return False + + def format_commit_message_with_jira_info(self, commit_title: str, commit_description: str, issue_keys: List[str] = None) -> tuple: + """ + Format commit message with JIRA issue information. + + Args: + commit_title: Original commit title + commit_description: Original commit description + issue_keys: List of JIRA issue keys to include (optional, will extract from title/description if not provided) + + Returns: + tuple: (updated_title, updated_description) with JIRA information included + """ + # If no issue keys provided, extract them from title and description + if not issue_keys: + title_keys = self.extract_issue_keys(commit_title) + desc_keys = self.extract_issue_keys(commit_description) + issue_keys = list(set(title_keys + desc_keys)) + + if not issue_keys or not self.is_connected(): + return commit_title, commit_description + + # Format the title to include the issue key if not already there + updated_title = commit_title + if issue_keys and not any(key in commit_title for key in issue_keys): + # Add the first issue key to the title + updated_title = f"{issue_keys[0]}: {commit_title}" + + # Add issue details to the description + updated_description = commit_description + + issue_details_section = "\n\n## Related JIRA Issues\n\n" + has_issue_details = False + + for issue_key in issue_keys: + details = self.get_issue_details(issue_key) + if details: + has_issue_details = True + issue_details_section += ( + f"* **[{details['key']}]({details['url']})**: {details['summary']}\n" + f" * Status: {details['status']}\n" + f" * Type: {details['type']}\n" + ) + + if has_issue_details: + updated_description += issue_details_section + + return updated_title, updated_description diff --git a/penify_hook/main.py b/penify_hook/main.py index b4ba587..f422839 100644 --- a/penify_hook/main.py +++ b/penify_hook/main.py @@ -9,6 +9,7 @@ import socketserver import urllib.parse from threading import Thread +import logging from penify_hook.utils import find_git_parent @@ -23,6 +24,12 @@ # Handle case where litellm is not installed LLMClient = None +try: + from .jira_client import JiraClient +except ImportError: + # Handle case where jira is not installed + JiraClient = None + HOOK_FILENAME = "post-commit" HOOK_TEMPLATE = """#!/bin/sh # This is a post-commit hook generated by penify-cli. @@ -107,7 +114,9 @@ def generate_doc(token, file_path=None, complete_folder_path=None, git_folder_pa print(f"Error: {e}") sys.exit(1) -def commit_code(gf_path: str, token: str, message: str, open_terminal: bool, llm_model=None, llm_api_base=None, llm_api_key=None): +def commit_code(gf_path: str, token: str, message: str, open_terminal: bool, + llm_model=None, llm_api_base=None, llm_api_key=None, + jira_url=None, jira_user=None, jira_api_token=None): # Create API client api_client = APIClient(api_url, token) @@ -125,9 +134,27 @@ def commit_code(gf_path: str, token: str, message: str, open_terminal: bool, llm print(f"Error initializing LLM client: {e}") print("Falling back to API for commit summary generation") + # Initialize JIRA client if parameters are provided and JiraClient is available + jira_client = None + if JiraClient is not None and jira_url and jira_user and jira_api_token: + try: + jira_client = JiraClient( + jira_url=jira_url, + jira_user=jira_user, + jira_api_token=jira_api_token + ) + if jira_client.is_connected(): + print(f"Connected to JIRA: {jira_url}") + else: + print(f"Failed to connect to JIRA: {jira_url}") + jira_client = None + except Exception as e: + print(f"Error initializing JIRA client: {e}") + jira_client = None + try: - # Pass the LLM client to CommitDocGenHook - analyzer = CommitDocGenHook(gf_path, api_client, llm_client) + # Pass the LLM client and JIRA client to CommitDocGenHook + analyzer = CommitDocGenHook(gf_path, api_client, llm_client, jira_client) analyzer.run(message, open_terminal) except Exception as e: print(f"Error: {e}") @@ -179,6 +206,35 @@ def save_llm_config(model, api_base, api_key): except Exception as e: print(f"Error saving LLM configuration: {str(e)}") +def save_jira_config(url, username, api_token): + """ + Save JIRA configuration settings in the .penify file in the user's home directory. + """ + home_dir = Path.home() + penify_file = home_dir / '.penify' + + config = {} + if penify_file.exists(): + try: + with open(penify_file, 'r') as f: + config = json.load(f) + except json.JSONDecodeError: + pass + + # Update or add JIRA configuration + config['jira'] = { + 'url': url, + 'username': username, + 'api_token': api_token + } + + try: + with open(penify_file, 'w') as f: + json.dump(config, f) + print(f"JIRA configuration saved to {penify_file}") + except Exception as e: + print(f"Error saving JIRA configuration: {str(e)}") + def get_llm_config(): """ Get LLM configuration from the .penify file. @@ -196,6 +252,23 @@ def get_llm_config(): return {} +def get_jira_config(): + """ + Get JIRA configuration from the .penify file. + """ + config_file = Path.home() / '.penify' + if config_file.exists(): + try: + with open(config_file, 'r') as f: + config = json.load(f) + return config.get('jira', {}) + except json.JSONDecodeError: + print("Error reading .penify config file. File may be corrupted.") + except Exception as e: + print(f"Error reading .penify config file: {str(e)}") + + return {} + def login(): """ Open the login page in a web browser and listen for the redirect URL to capture the token. @@ -314,6 +387,9 @@ def main(): token based on user input or environment variables and executes the appropriate subcommand based on user selection. """ + # Configure logging + logging.basicConfig(level=logging.WARNING, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') parser = argparse.ArgumentParser(description="Penify CLI tool for managing Git hooks and generating documentation.") @@ -335,15 +411,19 @@ def main(): doc_gen_parser.add_argument("-cf", "--complete_folder_path", help="Generate documentation for the entire folder.") doc_gen_parser.add_argument("-gf", "--git_folder_path", help="Path to the folder, with git, to scan for modified files. Defaults to the current folder.", default=os.getcwd()) - # Subcommand: commit + # Subcommand: commit - update with LLM and JIRA options commit_parser = subparsers.add_parser("commit", help="Commit with a message.") commit_parser.add_argument("-gf", "--git_folder_path", help="Path to the folder, with git, to scan for modified files. Defaults to the current folder.", default=os.getcwd()) commit_parser.add_argument("-m", "--message", required=False, help="Commit message.", default="N/A") commit_parser.add_argument("-e", "--terminal", required=False, help="Open edit terminal", default="False") - # Add LLM options for the commit subcommand + # Add LLM options commit_parser.add_argument("--llm", "--llm-model", dest="llm_model", help="LLM model to use for commit message generation (e.g., ollama/llama2, gpt-3.5-turbo)") commit_parser.add_argument("--llm-api-base", help="API base URL for the LLM service (e.g., http://localhost:11434 for Ollama)") commit_parser.add_argument("--llm-api-key", help="API key for the LLM service") + # Add JIRA options + commit_parser.add_argument("--jira-url", help="JIRA base URL (e.g., https://your-domain.atlassian.net)") + commit_parser.add_argument("--jira-user", help="JIRA username or email") + commit_parser.add_argument("--jira-api-token", help="JIRA API token") # Add a new subcommand: config-llm llm_config_parser = subparsers.add_parser("config-llm", help="Configure LLM settings for commit message generation") @@ -351,6 +431,13 @@ def main(): llm_config_parser.add_argument("--api-base", help="API base URL for the LLM service (e.g., http://localhost:11434 for Ollama)") llm_config_parser.add_argument("--api-key", help="API key for the LLM service") + # Add a new subcommand: config-jira + jira_config_parser = subparsers.add_parser("config-jira", help="Configure JIRA settings for commit integration") + jira_config_parser.add_argument("--url", required=True, help="JIRA base URL (e.g., https://your-domain.atlassian.net)") + jira_config_parser.add_argument("--username", required=True, help="JIRA username or email") + jira_config_parser.add_argument("--api-token", required=True, help="JIRA API token") + jira_config_parser.add_argument("--verify", action="store_true", help="Verify JIRA connection after configuration") + # Subcommand: login login_parser = subparsers.add_parser("login", help="Log in to Penify and obtain an API token.") @@ -390,14 +477,47 @@ def main(): llm_api_base = llm_config.get('api_base') if not llm_api_base else llm_api_base llm_api_key = llm_config.get('api_key') if not llm_api_key else llm_api_key - commit_code(args.git_folder_path, token, args.message, open_terminal, - llm_model, llm_api_base, llm_api_key) + # Get JIRA configuration - first from command line args, then from config file + jira_url = args.jira_url + jira_user = args.jira_user + jira_api_token = args.jira_api_token + + if not jira_url or not jira_user or not jira_api_token: + # Try to get from config + jira_config = get_jira_config() + jira_url = jira_url or jira_config.get('url') + jira_user = jira_user or jira_config.get('username') + jira_api_token = jira_api_token or jira_config.get('api_token') + + commit_code(args.git_folder_path, token, args.message, open_terminal, + llm_model, llm_api_base, llm_api_key, + jira_url, jira_user, jira_api_token) elif args.subcommand == "config-llm": # Save LLM configuration save_llm_config(args.model, args.api_base, args.api_key) print(f"LLM configuration set: Model={args.model}, API Base={args.api_base or 'default'}") + elif args.subcommand == "config-jira": + # Save JIRA configuration + save_jira_config(args.url, args.username, args.api_token) + print(f"JIRA configuration set: URL={args.url}, Username={args.username}") + + # Verify connection if requested + if args.verify: + if JiraClient: + jira_client = JiraClient( + jira_url=args.url, + jira_user=args.username, + jira_api_token=args.api_token + ) + if jira_client.is_connected(): + print("JIRA connection verified successfully!") + else: + print("Failed to connect to JIRA. Please check your credentials.") + else: + print("JIRA package not installed. Cannot verify connection.") + elif args.subcommand == "login": login() else: diff --git a/setup.py b/setup.py index 9f0135f..00e3d32 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,8 @@ "requests", "tqdm", "GitPython", - "litellm" # Add litellm as a dependency + "litellm", + "jira" # Add JIRA as a dependency ], entry_points={ "console_scripts": [ From 6c12d761478cce7a71010444ee31639b0efb43f4 Mon Sep 17 00:00:00 2001 From: sumansaurabh Date: Wed, 5 Mar 2025 07:56:36 +0530 Subject: [PATCH 03/10] fix: Update references from penify-cli to penifycli in README and setup files --- README.md | 18 +++++++++--------- penify_hook/main.py | 4 ++-- setup.py | 6 +++--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5e8c2cd..b99825b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Penify CLI is a command-line tool for managing Git hooks, generating documentati You can install Penify CLI using pip: ```bash -pip install penify-cli +pip install penifycli ``` ## Usage @@ -19,7 +19,7 @@ Penify CLI provides several subcommands for different functionalities: To log in and obtain an API token: ```bash -penify-cli login +penifycli login ``` This command will open a browser window for authentication. After successful login, the API key will be saved locally for future use. @@ -29,7 +29,7 @@ This command will open a browser window for authentication. After successful log To install the Git post-commit hook: ```bash -penify-cli install-hook -l /path/to/git/repo +penifycli install-hook -l /path/to/git/repo ``` - `-l, --location`: The path to the Git repository where you want to install the hook. @@ -39,7 +39,7 @@ penify-cli install-hook -l /path/to/git/repo To uninstall the Git post-commit hook: ```bash -penify-cli uninstall-hook -l /path/to/git/repo +penifycli uninstall-hook -l /path/to/git/repo ``` - `-l, --location`: The path to the Git repository from which you want to uninstall the hook. @@ -49,7 +49,7 @@ penify-cli uninstall-hook -l /path/to/git/repo To generate documentation for files or folders: ```bash -penify-cli doc-gen [options] +penifycli doc-gen [options] ``` Options: @@ -62,7 +62,7 @@ Options: To commit code with an automatically generated commit message: ```bash -penify-cli commit -gf /path/to/git/repo [-m "Optional message"] [-e True/False] +penifycli commit -gf /path/to/git/repo [-m "Optional message"] [-e True/False] ``` - `-gf, --git_folder_path`: Path to the Git repository. Defaults to the current directory. @@ -74,7 +74,7 @@ penify-cli commit -gf /path/to/git/repo [-m "Optional message"] [-e True/False] To integrate with JIRA and automate issue tracking: ```bash -penify-cli jira [options] +penifycli jira [options] ``` Options: @@ -103,7 +103,7 @@ To set up the development environment: 1. Clone the repository: ```bash - git clone https://github.com/SingularityX-ai/penify-cli.git + git clone https://github.com/SingularityX-ai/penifycli.git ``` 2. Install the package in editable mode: @@ -125,7 +125,7 @@ Contributions are welcome! Please feel free to submit a Pull Request. ## Issues -If you encounter any problems or have suggestions, please file an issue on the [GitHub repository](https://github.com/SingularityX-ai/penify-cli/issues). +If you encounter any problems or have suggestions, please file an issue on the [GitHub repository](https://github.com/SingularityX-ai/penifycli/issues). ## Support diff --git a/penify_hook/main.py b/penify_hook/main.py index f422839..a98ae7a 100644 --- a/penify_hook/main.py +++ b/penify_hook/main.py @@ -32,9 +32,9 @@ HOOK_FILENAME = "post-commit" HOOK_TEMPLATE = """#!/bin/sh -# This is a post-commit hook generated by penify-cli. +# This is a post-commit hook generated by penifycli. -penify-cli -t {token} -gf {git_folder_path} +penifycli -t {token} -gf {git_folder_path} """ api_url = 'https://production-gateway.snorkell.ai/api' dashboard_url = "https://dashboard.penify.dev/auth/localhost/login" diff --git a/setup.py b/setup.py index 00e3d32..0d9fcd9 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from setuptools import setup, find_packages setup( - name="penify-cli", + name="penifycli", version="0.1.5", # Increment the version number packages=['penify_hook'], # Explicitly include the penify_hook package install_requires=[ @@ -13,7 +13,7 @@ ], entry_points={ "console_scripts": [ - "penify-cli=penify_hook.main:main", + "penifycli=penify_hook.main:main", ], }, author="Suman Saurabh", @@ -21,7 +21,7 @@ description="A penify cli tool to generate Documentation, Commit-summary and Hooks to automate git workflows.", long_description=open("README.md").read(), long_description_content_type="text/markdown", - url="https://github.com/SingularityX-ai/penify-cli", + url="https://github.com/SingularityX-ai/penifycli", classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", From 627e6e60030c9dd296990250b91787f481efc6d1 Mon Sep 17 00:00:00 2001 From: sumansaurabh Date: Wed, 5 Mar 2025 08:25:20 +0530 Subject: [PATCH 04/10] feat: Add JIRA configuration template and update README for cloning instructions --- README.md | 2 +- penify_hook/__init__.py | 0 penify_hook/main.py | 193 +++++++++++++++++++++++++ penify_hook/templates/jira_config.html | 169 ++++++++++++++++++++++ penify_hook/templates/llm_config.html | 190 ++++++++++++++++++++++++ 5 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 penify_hook/__init__.py create mode 100644 penify_hook/templates/jira_config.html create mode 100644 penify_hook/templates/llm_config.html diff --git a/README.md b/README.md index b99825b..ec60b4d 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ To set up the development environment: 1. Clone the repository: ```bash - git clone https://github.com/SingularityX-ai/penifycli.git + git clone https://github.com/SingularityX-ai/penify-cli.git ``` 2. Install the package in editable mode: diff --git a/penify_hook/__init__.py b/penify_hook/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/penify_hook/main.py b/penify_hook/main.py index a98ae7a..3a34fbe 100644 --- a/penify_hook/main.py +++ b/penify_hook/main.py @@ -10,6 +10,7 @@ import urllib.parse from threading import Thread import logging +import pkg_resources from penify_hook.utils import find_git_parent @@ -348,6 +349,184 @@ def log_message(self, format, *args): print("Login process completed. You can now use other commands with your API token.") +def config_llm_web(): + """ + Open a web browser interface for configuring LLM settings. + """ + redirect_port = random.randint(30000, 50000) + server_url = f"http://localhost:{redirect_port}" + + print(f"Starting configuration server on {server_url}") + + class ConfigHandler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + if self.path == "/": + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + + # Read the template HTML file + template_path = pkg_resources.resource_filename( + "penify_hook", "templates/llm_config.html" + ) + + with open(template_path, 'r') as f: + content = f.read() + + self.wfile.write(content.encode()) + else: + self.send_response(404) + self.send_header("Content-type", "text/plain") + self.end_headers() + self.wfile.write(b"Not Found") + + def do_POST(self): + if self.path == "/save": + content_length = int(self.headers['Content-Length']) + post_data = self.rfile.read(content_length) + data = json.loads(post_data.decode()) + + model = data.get('model') + api_base = data.get('api_base') + api_key = data.get('api_key') + + try: + save_llm_config(model, api_base, api_key) + + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + response = { + "success": True, + "message": f"LLM configuration saved successfully. Using model: {model}" + } + self.wfile.write(json.dumps(response).encode()) + + # Schedule the server shutdown + thread = Thread(target=self.server.shutdown) + thread.daemon = True + thread.start() + + except Exception as e: + self.send_response(500) + self.send_header("Content-type", "application/json") + self.end_headers() + response = {"success": False, "message": f"Error saving configuration: {str(e)}"} + self.wfile.write(json.dumps(response).encode()) + else: + self.send_response(404) + self.send_header("Content-type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"success": False, "message": "Not Found"}).encode()) + + def log_message(self, format, *args): + # Suppress log messages + return + + with socketserver.TCPServer(("", redirect_port), ConfigHandler) as httpd: + print(f"Opening configuration page in your browser...") + webbrowser.open(server_url) + print(f"Waiting for configuration to be submitted...") + httpd.serve_forever() + + print("Configuration completed.") + +def config_jira_web(): + """ + Open a web browser interface for configuring JIRA settings. + """ + redirect_port = random.randint(30000, 50000) + server_url = f"http://localhost:{redirect_port}" + + print(f"Starting configuration server on {server_url}") + + class ConfigHandler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + if self.path == "/": + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + + # Read the template HTML file + template_path = pkg_resources.resource_filename( + "penify_hook", "templates/jira_config.html" + ) + + with open(template_path, 'r') as f: + content = f.read() + + self.wfile.write(content.encode()) + else: + self.send_response(404) + self.send_header("Content-type", "text/plain") + self.end_headers() + self.wfile.write(b"Not Found") + + def do_POST(self): + if self.path == "/save": + content_length = int(self.headers['Content-Length']) + post_data = self.rfile.read(content_length) + data = json.loads(post_data.decode()) + + url = data.get('url') + username = data.get('username') + api_token = data.get('api_token') + verify = data.get('verify', False) + + try: + # Save the configuration + save_jira_config(url, username, api_token) + + # Verify the connection if requested + verify_message = "" + if verify and JiraClient is not None: + jira_client = JiraClient( + jira_url=url, + jira_user=username, + jira_api_token=api_token + ) + if jira_client.is_connected(): + verify_message = " Connection to JIRA verified successfully!" + else: + verify_message = " Warning: Could not connect to JIRA with these credentials." + + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + response = { + "success": True, + "message": f"JIRA configuration saved successfully.{verify_message}" + } + self.wfile.write(json.dumps(response).encode()) + + # Schedule the server shutdown + thread = Thread(target=self.server.shutdown) + thread.daemon = True + thread.start() + + except Exception as e: + self.send_response(500) + self.send_header("Content-type", "application/json") + self.end_headers() + response = {"success": False, "message": f"Error saving configuration: {str(e)}"} + self.wfile.write(json.dumps(response).encode()) + else: + self.send_response(404) + self.send_header("Content-type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"success": False, "message": "Not Found"}).encode()) + + def log_message(self, format, *args): + # Suppress log messages + return + + with socketserver.TCPServer(("", redirect_port), ConfigHandler) as httpd: + print(f"Opening configuration page in your browser...") + webbrowser.open(server_url) + print(f"Waiting for configuration to be submitted...") + httpd.serve_forever() + + print("Configuration completed.") def get_token(passed_token): """ @@ -431,6 +610,9 @@ def main(): llm_config_parser.add_argument("--api-base", help="API base URL for the LLM service (e.g., http://localhost:11434 for Ollama)") llm_config_parser.add_argument("--api-key", help="API key for the LLM service") + # Add a new subcommand: config-llm-web + llm_config_web_parser = subparsers.add_parser("config-llm-web", help="Configure LLM settings through a web interface") + # Add a new subcommand: config-jira jira_config_parser = subparsers.add_parser("config-jira", help="Configure JIRA settings for commit integration") jira_config_parser.add_argument("--url", required=True, help="JIRA base URL (e.g., https://your-domain.atlassian.net)") @@ -438,6 +620,9 @@ def main(): jira_config_parser.add_argument("--api-token", required=True, help="JIRA API token") jira_config_parser.add_argument("--verify", action="store_true", help="Verify JIRA connection after configuration") + # Add a new subcommand: config-jira-web + jira_config_web_parser = subparsers.add_parser("config-jira-web", help="Configure JIRA settings through a web interface") + # Subcommand: login login_parser = subparsers.add_parser("login", help="Log in to Penify and obtain an API token.") @@ -498,6 +683,10 @@ def main(): save_llm_config(args.model, args.api_base, args.api_key) print(f"LLM configuration set: Model={args.model}, API Base={args.api_base or 'default'}") + elif args.subcommand == "config-llm-web": + # Open web interface for LLM configuration + config_llm_web() + elif args.subcommand == "config-jira": # Save JIRA configuration save_jira_config(args.url, args.username, args.api_token) @@ -518,6 +707,10 @@ def main(): else: print("JIRA package not installed. Cannot verify connection.") + elif args.subcommand == "config-jira-web": + # Open web interface for JIRA configuration + config_jira_web() + elif args.subcommand == "login": login() else: diff --git a/penify_hook/templates/jira_config.html b/penify_hook/templates/jira_config.html new file mode 100644 index 0000000..4ba4803 --- /dev/null +++ b/penify_hook/templates/jira_config.html @@ -0,0 +1,169 @@ + + + + Penify JIRA Configuration + + + + +
+

Penify JIRA Configuration

+

Configure your JIRA integration for Penify CLI.

+ +
+
+ + +

The base URL of your JIRA instance

+
+ +
+ + +

Your JIRA account email or username

+
+ +
+ + +

Generate an API token from your Atlassian account: Atlassian API tokens

+
+ +
+
+ + +
+
+ + +
+ +
+
+ + + + diff --git a/penify_hook/templates/llm_config.html b/penify_hook/templates/llm_config.html new file mode 100644 index 0000000..a3c6761 --- /dev/null +++ b/penify_hook/templates/llm_config.html @@ -0,0 +1,190 @@ + + + + Penify LLM Configuration + + + + +
+

Penify LLM Configuration

+

Configure your preferred LLM (Large Language Model) for generating commit messages.

+ +
+
+ + +

Specify the model name for the LLM service you want to use

+
+ +
+ + +

For local LLMs like Ollama, specify the API endpoint

+
+ +
+ + +

Required for commercial LLMs like OpenAI's GPT models

+
+ +
+

Quick Config Presets:

+
OpenAI GPT-3.5
+
OpenAI GPT-4
+
Ollama Llama 2
+
Ollama Mistral
+
Ollama CodeLlama
+
+ + +
+ +
+
+ + + + From b613123c3fe4271b56a0891960caba52f7bf6235 Mon Sep 17 00:00:00 2001 From: sumansaurabh Date: Wed, 5 Mar 2025 08:28:22 +0530 Subject: [PATCH 05/10] fix: Update LLM configuration template title for consistency --- penify_hook/templates/llm_config.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/penify_hook/templates/llm_config.html b/penify_hook/templates/llm_config.html index a3c6761..e0af1a5 100644 --- a/penify_hook/templates/llm_config.html +++ b/penify_hook/templates/llm_config.html @@ -1,7 +1,7 @@ - Penify LLM Configuration + LLM Configuration