diff --git a/README.md b/README.md index d0de908..ec60b4d 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,13 +62,27 @@ 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. - `-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 +penifycli 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: @@ -111,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/__init__.py b/penify_hook/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/penify_hook/api_client.py b/penify_hook/api_client.py index 7172831..abeecf9 100644 --- a/penify_hook/api_client.py +++ b/penify_hook/api_client.py @@ -45,24 +45,23 @@ def send_file_for_docstring_generation(self, file_name, content, line_numbers, r print(f"Error: {response.text}") return content - def generate_commit_summary(self, git_diff, instruction: str = "", repo_details = None): - """Send file content and modified lines to the API and return modified - content. + def generate_commit_summary(self, git_diff, instruction: str = "", repo_details = None, jira_context: dict = None): + """Generate a commit summary by sending a POST request to the API endpoint. - This function constructs a payload containing the file path, content, - and modified line numbers, and sends it to a specified API endpoint for - processing. It handles the response from the API, returning the modified - content if the request is successful. If the request fails, it logs the - error details and returns the original content. + This function constructs a payload containing the git diff and any + additional instructions provided. It then sends this payload to a + specified API endpoint to generate a summary of the commit. If the + request is successful, it returns the response from the API; otherwise, + it returns None. Args: - file_name (str): The path to the file being sent. - content (str): The content of the file to be processed. - line_numbers (list): A list of line numbers that have been modified. + git_diff (str): The git diff of the commit. + instruction (str?): Additional instruction for the commit. Defaults to "". + repo_details (dict?): Details of the git repository. Defaults to None. + jira_context (dict?): JIRA issue details to enhance the commit summary. Defaults to None. Returns: - str: The modified content returned by the API, or the original content if the - request fails. + dict: The response from the API if the request is successful, None otherwise. """ payload = { 'git_diff': git_diff, @@ -70,15 +69,24 @@ def generate_commit_summary(self, git_diff, instruction: str = "", repo_details } if repo_details: payload['git_repo'] = repo_details + + # Add JIRA context if available + if jira_context: + payload['jira_context'] = jira_context url = self.api_url+"/v1/hook/commit/summary" - response = requests.post(url, json=payload,headers={"api-key": f"{self.AUTH_TOKEN}"}, timeout=60*10) - if response.status_code == 200: - response = response.json() - return response - else: - print(f"Response: {response.status_code}") - print(f"Error: {response.text}") + try: + response = requests.post(url, json=payload, headers + ={"api-key": f"{self.AUTH_TOKEN}"}, timeout=60*10) + if response.status_code == 200: + response = response.json() + return response + else: + print(f"Response: {response.status_code}") + print(f"Error: {response.text}") + return None + except Exception as e: + print(f"Error: {e}") return None def get_supported_file_types(self) -> list[str]: @@ -100,40 +108,27 @@ def get_supported_file_types(self) -> list[str]: return response else: return ["py", "js", "ts", "java", "kt", "cs", "c"] - - def generate_commit_summary(self, git_diff, instruction: str = "", repo_details = None): - """Generate a commit summary by sending a POST request to the API endpoint. - - This function constructs a payload containing the git diff and any - additional instructions provided. It then sends this payload to a - specified API endpoint to generate a summary of the commit. If the - request is successful, it returns the response from the API; otherwise, - it returns None. + def generate_commit_summary_with_llm(self, diff, message, repo_details, llm_client, jira_context=None): + """ + Generate a commit summary using a local LLM client instead of the API. + Args: - git_diff (str): The git diff of the commit. - instruction (str?): Additional instruction for the commit. Defaults to "". - repo_details (dict?): Details of the git repository. Defaults to None. - + diff: Git diff of changes + message: User-provided commit message or instructions + repo_details: Details about the repository + llm_client: Instance of LLMClient + jira_context: Optional JIRA issue context to enhance the summary + Returns: - dict: The response from the API if the request is successful, None otherwise. + Dict with title and description for the commit """ - payload = { - 'git_diff': git_diff, - 'additional_instruction': instruction - } - if repo_details: - payload['git_repo'] = repo_details - - url = self.api_url+"/v1/hook/commit/summary" - response = requests.post(url, json=payload,headers={"api-key": f"{self.AUTH_TOKEN}"}, timeout=60*10) - if response.status_code == 200: - response = response.json() - return response - else: - print(f"Response: {response.status_code}") - print(f"Error: {response.text}") - return None + try: + return llm_client.generate_commit_summary(diff, message, repo_details, jira_context) + 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, jira_context) def get_api_key(self): @@ -147,4 +142,3 @@ def get_api_key(self): print(f"Error: {response.text}") return None - \ No newline at end of file diff --git a/penify_hook/commands/__init__.py b/penify_hook/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/penify_hook/commit_analyzer.py b/penify_hook/commit_analyzer.py index 2146968..5b5e19d 100644 --- a/penify_hook/commit_analyzer.py +++ b/penify_hook/commit_analyzer.py @@ -2,15 +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): + 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() @@ -87,7 +89,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. @@ -99,11 +102,32 @@ def get_summary(self, instruction: str): Raises: Exception: If there are no changes staged for commit. """ - 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) + raise ValueError("No changes to commit") + + # Get JIRA context if available + jira_context = None + if self.jira_client and self.jira_client.is_connected(): + try: + # Check branch name for JIRA issues + current_branch = self.repo.active_branch.name + issue_keys = self.jira_client.extract_issue_keys_from_branch(current_branch) + + # If issues found in branch, get context + if issue_keys: + jira_context = self.jira_client.get_commit_context_from_issues(issue_keys) + print(f"Adding JIRA context from issues: {', '.join(issue_keys)}") + except Exception as e: + print(f"Could not get JIRA context: {e}") + + # 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, jira_context + ) + else: + return self.api_client.generate_commit_summary(diff, instruction, self.repo_details, jira_context) def run(self, msg: Optional[str], edit_commit_message: bool): @@ -127,17 +151,76 @@ 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: + # Extract from message content + issue_keys = self.jira_client.extract_issue_keys(f"{title} {description} {msg}") + + # Also check the branch name (which often follows JIRA naming conventions) + try: + current_branch = self.repo.active_branch.name + branch_issue_keys = self.jira_client.extract_issue_keys_from_branch(current_branch) + + # Add any new keys found in branch name + for key in branch_issue_keys: + if key not in issue_keys: + issue_keys.append(key) + print(f"Added JIRA issue {key} from branch name: {current_branch}") + except Exception as e: + print(f"Could not extract JIRA issues from branch name: {e}") + + 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) + else: + print("No JIRA issues found in commit message or branch name") + + return title, description def _amend_commit(self): """Open the default git editor for editing the commit message. @@ -160,4 +243,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/jira_client.py b/penify_hook/jira_client.py new file mode 100644 index 0000000..c4f5927 --- /dev/null +++ b/penify_hook/jira_client.py @@ -0,0 +1,418 @@ +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_from_branch(self, branch_name: str) -> List[str]: + """ + Extract JIRA issue keys from a branch name. + + Many teams follow conventions like: + - feature/ABC-123-description + - bugfix/ABC-123-fix-something + - hotfix/ABC-123/short-desc + + Args: + branch_name: The git branch name + + 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, branch_name) + if matches: + logging.warning(f"Found JIRA issue in branch name: {matches[0]}") + return list(set(matches)) # Remove duplicates + + 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) + print(f"Matches: {matches}") + 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 + + def get_detailed_issue_context(self, issue_key: str) -> Dict[str, Any]: + """ + Get comprehensive details about a JIRA issue including context for better commit messages. + + Args: + issue_key: JIRA issue key (e.g., "PROJECT-123") + + Returns: + Dict containing business and technical context from the issue + """ + if not self.is_connected(): + logging.warning("JIRA client not connected") + return {} + + try: + issue = self.jira_client.issue(issue_key) + + # Get issue history and comments for context + comments = [] + try: + for comment in self.jira_client.comments(issue): + comments.append(comment.body) + except Exception as e: + logging.warning(f"Could not fetch comments for {issue_key}: {e}") + + # Build a comprehensive context object + context = { + 'key': issue.key, + 'summary': issue.fields.summary, + 'description': issue.fields.description or "", + 'type': issue.fields.issuetype.name, + 'status': issue.fields.status.name, + 'priority': issue.fields.priority.name if hasattr(issue.fields, 'priority') and issue.fields.priority else "None", + 'comments': comments[:3], # Limit to latest 3 comments + 'url': f"{self.jira_url}/browse/{issue.key}" + } + + # Add acceptance criteria if available (common custom fields) + # Field names may vary by JIRA instance + acceptance_criteria = None + try: + for field_name in ['customfield_10001', 'acceptance_criteria', 'customfield_10207']: + if hasattr(issue.fields, field_name): + field_value = getattr(issue.fields, field_name) + if field_value: + acceptance_criteria = field_value + break + except Exception: + pass + + if acceptance_criteria: + context['acceptance_criteria'] = acceptance_criteria + + # Try to extract sprint information + try: + sprint_field = None + for field_name in dir(issue.fields): + if 'sprint' in field_name.lower(): + sprint_field = field_name + break + + if sprint_field: + sprint_value = getattr(issue.fields, sprint_field) + if sprint_value: + if isinstance(sprint_value, list) and len(sprint_value) > 0: + context['sprint'] = sprint_value[0] + else: + context['sprint'] = str(sprint_value) + except Exception as e: + logging.debug(f"Could not extract sprint information: {e}") + + return context + + except Exception as e: + logging.error(f"Error fetching detailed information for {issue_key}: {e}") + return {} + + def get_commit_context_from_issues(self, issue_keys: List[str]) -> Dict[str, Any]: + """ + Gather contextual information from JIRA issues to improve commit messages. + + Args: + issue_keys: List of JIRA issue keys to gather information from + + Returns: + Dict containing business and technical context from the issues + """ + if not issue_keys or not self.is_connected(): + return {} + + # Get the primary issue (first in the list) + primary_issue = self.get_detailed_issue_context(issue_keys[0]) + + # Get basic info for related issues + related_issues = [] + for key in issue_keys[1:]: # Skip the first one as it's the primary + details = self.get_issue_details(key) + if details: + related_issues.append(details) + + # Build context dictionary for commit message enhancement + context = { + 'primary_issue': primary_issue, + 'related_issues': related_issues, + 'all_keys': issue_keys + } + + return context + + def enhance_commit_message(self, title: str, description: str, issue_keys: List[str]) -> tuple: + """ + Enhance commit message with business and technical context from JIRA issues. + + Args: + title: Original commit title + description: Original commit description + issue_keys: List of JIRA issue keys to include + + Returns: + tuple: (enhanced_title, enhanced_description) with JIRA context + """ + if not issue_keys or not self.is_connected(): + return title, description + + # Get context information from issues + context = self.get_commit_context_from_issues(issue_keys) + if not context or not context.get('primary_issue'): + return self.format_commit_message_with_jira_info(title, description, issue_keys) + + # Get primary issue + primary = context['primary_issue'] + + # Enhance title with primary issue key and summary if not already included + enhanced_title = title + if not any(key in title for key in issue_keys): + key = primary['key'] + # Keep original title, but prefix with issue key + enhanced_title = f"{key}: {title}" + + # Enhance description with business and technical context + enhanced_description = description + + # Add business context section + business_section = "\n\n## Business Context\n\n" + business_section += f"**Issue**: [{primary['key']}]({primary['url']}) - {primary['summary']}\n" + business_section += f"**Type**: {primary['type']}\n" + business_section += f"**Status**: {primary['status']}\n" + business_section += f"**Priority**: {primary['priority']}\n" + + if 'sprint' in primary: + business_section += f"**Sprint**: {primary['sprint']}\n" + + if 'acceptance_criteria' in primary: + business_section += f"\n**Acceptance Criteria**:\n{primary['acceptance_criteria']}\n" + + if primary.get('description'): + # Include a condensed version of the description if it's not too long + desc = primary['description'] + if len(desc) > 300: + desc = desc[:300] + "..." + business_section += f"\n**Issue Description**:\n{desc}\n" + + # Add technical context from comments if available + if primary.get('comments'): + tech_section = "\n## Technical Notes\n\n" + + # Extract technical details from comments (often devs discuss implementation details here) + for comment in primary['comments']: + if len(comment) > 200: # Only include shorter technical notes + comment = comment[:200] + "..." + tech_section += f"- {comment}\n\n" + + if len(tech_section) > 50: # Only add if there's substantial content + enhanced_description += business_section + tech_section + else: + enhanced_description += business_section + else: + enhanced_description += business_section + + # Add related issues section + if context.get('related_issues'): + related_section = "\n## Related Issues\n\n" + for issue in context['related_issues']: + related_section += f"- [{issue['key']}]({issue['url']}): {issue['summary']} ({issue['status']})\n" + + enhanced_description += related_section + + return enhanced_title, enhanced_description diff --git a/penify_hook/llm_client.py b/penify_hook/llm_client.py new file mode 100644 index 0000000..e2c37e9 --- /dev/null +++ b/penify_hook/llm_client.py @@ -0,0 +1,145 @@ +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, jira_context: Dict = None) -> 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 + jira_context: Optional JIRA issue context to enhance the summary + + 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} + """ + + # Add JIRA context if available + if jira_context and jira_context.get('primary_issue'): + primary = jira_context['primary_issue'] + prompt += f""" + + JIRA ISSUE INFORMATION: + Issue Key: {primary['key']} + Summary: {primary['summary']} + Type: {primary['type']} + Status: {primary['status']} + """ + + if 'description' in primary and primary['description']: + # Include a condensed version of the description + description = primary['description'] + if len(description) > 500: + description = description[:500] + "..." + prompt += f"Description: {description}\n" + + if 'acceptance_criteria' in primary: + prompt += f"Acceptance Criteria: {primary['acceptance_criteria']}\n" + + prompt += """ + + Please make sure your commit message addresses the business requirements in the JIRA issue + while accurately describing the technical changes in the diff. + """ + + prompt += f""" + + Git diff: + ``` + {diff} + ``` + + Please provide: + 1. A short, focused commit title (50-72 characters) + 2. A more detailed description of the changes that addresses both business and technical aspects + + 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=800 # Increased token limit to accommodate detailed descriptions + ) + + 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..3a34fbe 100644 --- a/penify_hook/main.py +++ b/penify_hook/main.py @@ -9,6 +9,8 @@ import socketserver import urllib.parse from threading import Thread +import logging +import pkg_resources from penify_hook.utils import find_git_parent @@ -17,12 +19,23 @@ 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 + +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. +# 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" @@ -102,16 +115,51 @@ 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, + jira_url=None, jira_user=None, jira_api_token=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") + + # 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: - analyzer = CommitDocGenHook(gf_path, api_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}") sys.exit(1) - # You can add actual Git commit logic here using subprocess or GitPython, etc. def save_credentials(api_key): """ @@ -130,6 +178,98 @@ 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 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. + """ + 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 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. @@ -209,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): """ @@ -248,6 +566,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.") @@ -269,11 +590,38 @@ 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 + 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") + 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") + + # 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)") + 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") + + # 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.") @@ -299,9 +647,70 @@ 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 + + # 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-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) + 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 == "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..e0af1a5 --- /dev/null +++ b/penify_hook/templates/llm_config.html @@ -0,0 +1,190 @@ + + + + LLM Configuration + + + + +
+

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
+
+ + +
+ +
+
+ + + + diff --git a/setup.py b/setup.py index b586b09..0d9fcd9 100644 --- a/setup.py +++ b/setup.py @@ -1,17 +1,19 @@ 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=[ "requests", "tqdm", - "GitPython" + "GitPython", + "litellm", + "jira" # Add JIRA as a dependency ], entry_points={ "console_scripts": [ - "penify-cli=penify_hook.main:main", + "penifycli=penify_hook.main:main", ], }, author="Suman Saurabh", @@ -19,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",