diff --git a/.coverage b/.coverage index 8686e8f..67a8607 100644 Binary files a/.coverage and b/.coverage differ diff --git a/.gitignore b/.gitignore index e095838..6dffe7a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,10 @@ __pycache__/ build/ .penify/ .penify/* +.env/ +*.env +*.DS_Store +*.log +*.sqlite3 +*.db +.env \ No newline at end of file diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..8e6917b --- /dev/null +++ b/Pipfile @@ -0,0 +1,19 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +requests = ">=2.25.0" +gitpython = ">=3.1.0" +tqdm = ">=4.62.0" +python-dotenv = ">=1.0.0" +pytest = ">=7.0.0" +pytest-cov = ">=4.0.0" +coverage = ">=6.0.0" +coverage-badge = ">=1.1.0" + +[dev-packages] + +[requires] +python_version = "3.13" diff --git a/coverage.xml b/coverage.xml index 056bb8b..03434f3 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /Users/sumansaurabh/Documents/my-startup/penify-cli/penify_hook - + @@ -21,60 +21,116 @@ - - - - - + + + - - - - + + - - + + + + + + + - - - - - - - + - - - - + + + + + - - - - - + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -112,112 +168,382 @@ - + - + - - - + + - - + + - - - - + + - - - - - - + + + + + + + + + + + + + - - + + + - + + - - - - - + + + + + + + - - + + - - - - - + + + + + - - - + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + - - + + - - + + + - - + - - - + + - + + - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -840,7 +1166,7 @@ - + @@ -884,9 +1210,9 @@ - - - + + + @@ -901,7 +1227,7 @@ - + @@ -920,59 +1246,86 @@ - - - - - - + + + - - + + + + - - + - - + - - + + + + + + + + + - - - + - + + + + + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -1051,7 +1404,7 @@ - + @@ -1065,70 +1418,60 @@ - - - - - - - - - + + + + + + + + - - - + + + + + - - - - - + + + + + + + + + + - - - - - - - - - + + + + + - - - + + + + + - - - - - + - - - - - - - - - - - - - - - - - + + + + + + - - + + + @@ -1136,148 +1479,652 @@ - - + + + + + + - - - + + - + + + - - - - - + + + + + - - + + - + + - - - + + + + - - + - + - - - - - - - + - - + + + + + + - - + + + + + - - - + + + - - - - - - - - - - + + + + + + + + - + + + - + + + + - - - + + - - + - - - - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/penify_hook/api_client.py b/penify_hook/api_client.py index a333a6a..7633a49 100644 --- a/penify_hook/api_client.py +++ b/penify_hook/api_client.py @@ -1,13 +1,20 @@ import json import os import requests +from typing import Dict, Any, Optional + +from penify_hook.ui_utils import print_info from .llm_client import LLMClient class APIClient: - def __init__(self, api_url, api_token: str = None, bearer_token: str = None): - self.api_url = api_url - self.AUTH_TOKEN = api_token - self.BEARER_TOKEN = bearer_token + def __init__(self, api_key: str = None): + """Initialize the API client with an API key. + + Args: + api_key (str): API key for authentication. + """ + self.api_key = api_key or os.environ.get('PENIFY_API_KEY') + self.base_url = os.environ.get('PENIFY_API_URL', 'https://api.penify.ai') def send_file_for_docstring_generation(self, file_name, content, line_numbers, repo_details = None): """Send file content and modified lines to the API and return modified @@ -36,8 +43,8 @@ def send_file_for_docstring_generation(self, file_name, content, line_numbers, r } if repo_details: payload['git_repo'] = repo_details - url = self.api_url+"/v1/hook/file/generate/doc" - response = requests.post(url, json=payload,headers={"api-key": f"{self.AUTH_TOKEN}"}, timeout=60*10) + url = self.base_url+"/v1/hook/file/generate/doc" + response = requests.post(url, json=payload,headers={"Authorization": f"Bearer {self.api_key}"}, timeout=60*10) if response.status_code == 200: response = response.json() return response.get('modified_content') @@ -47,50 +54,47 @@ def send_file_for_docstring_generation(self, file_name, content, line_numbers, r error_message = response.text raise Exception(f"API Error: {error_message}") - - 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 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(self, diff: str, instruction: str, repo_details: Dict[str, str], ticket_context: Optional[Dict[str, Any]] = None) -> Dict[str, str]: + """Generate a commit summary using the provided diff and instruction. 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. - jira_context (dict?): JIRA issue details to enhance the commit summary. Defaults to None. + diff (str): The diff of the staged changes. + instruction (str): Custom instruction for generating the commit summary. + repo_details (Dict[str, str]): Repository details. + ticket_context (Optional[Dict[str, Any]]): Context from project management tools. Returns: - dict: The response from the API if the request is successful, None otherwise. + Dict[str, str]: Dictionary containing 'title' and optionally 'description'. """ - payload = { - 'git_diff': git_diff, - 'additional_instruction': instruction + url = f"{self.base_url}/v1/commit/summary" + + # Prepare the request body with enhanced context + data = { + "diff": diff, + "instruction": instruction, + "repoDetails": repo_details, } - if repo_details: - payload['git_repo'] = repo_details - - # Add JIRA context if available - if jira_context: - payload['jira_context'] = jira_context + + # Add ticket context if available + if ticket_context: + data["ticketContext"] = ticket_context + print_info("Adding ticket context from project management tools") - url = self.api_url+"/v1/hook/commit/summary" 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}") - raise Exception(f"API Error: {response.text}") - except Exception as e: - print(f"Error: {e}") - return None + response = requests.post( + url, + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + }, + json=data + ) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + print(f"Error sending request to API: {e}") + return {} def get_supported_file_types(self) -> list[str]: """Retrieve the supported file types from the API. @@ -104,7 +108,7 @@ def get_supported_file_types(self) -> list[str]: list[str]: A list of supported file types, either from the API or a default set. """ - url = self.api_url+"/v1/file/supported_languages" + url = self.base_url+"/v1/file/supported_languages" response = requests.get(url) if response.status_code == 200: response = response.json() @@ -112,31 +116,170 @@ def get_supported_file_types(self) -> list[str]: else: return ["py", "js", "ts", "java", "kt", "cs", "c"] - def generate_commit_summary_with_llm(self, diff, message, generate_description: bool, repo_details, llm_client : LLMClient, jira_context=None): + def generate_commit_summary_with_llm(self, diff: str, instruction: str, generate_description: bool, + repo_details: Dict[str, str], llm_client: Any, + ticket_context: Optional[Dict[str, Any]] = None) -> Dict[str, str]: + """Generate a commit summary using an LLM client. + + Args: + diff (str): The diff of the staged changes. + instruction (str): Custom instruction for generating the commit summary. + generate_description (bool): Whether to generate a detailed description. + repo_details (Dict[str, str]): Repository details. + llm_client (Any): LLM client instance. + ticket_context (Optional[Dict[str, Any]]): Context from project management tools. + + Returns: + Dict[str, str]: Dictionary containing 'title' and optionally 'description'. """ - Generate a commit summary using a local LLM client instead of the API. + # Format the ticket context for the prompt + ticket_context_str = "" + if ticket_context: + print_info("Adding ticket context from project management tools to LLM prompt") + ticket_context_str = "Additional context from project management tools:\n" + + # Format JIRA ticket context + if 'jira' in ticket_context: + jira_ctx = ticket_context['jira'] + ticket_context_str += "\nJIRA Issues:\n" + if 'issues' in jira_ctx: + for issue in jira_ctx['issues']: + ticket_context_str += f"- {issue.get('key', 'Unknown')}: {issue.get('summary', 'No summary')}\n" + if 'description' in issue and issue['description']: + ticket_context_str += f" Description: {issue['description'][:150]}...\n" + + # Format Azure DevOps ticket context + if 'azure_devops' in ticket_context: + azdo_ctx = ticket_context['azure_devops'] + ticket_context_str += "\nAzure DevOps Work Items:\n" + if 'work_item_ids' in azdo_ctx: + for item_id in azdo_ctx['work_item_ids']: + ticket_context_str += f"- Work Item #{item_id}\n" + + # Format GitHub ticket context + if 'github' in ticket_context: + github_ctx = ticket_context['github'] + ticket_context_str += "\nGitHub Issues:\n" + if 'issue_numbers' in github_ctx: + for issue_num in github_ctx['issue_numbers']: + ticket_context_str += f"- Issue #{issue_num}\n" + + # Format Asana ticket context + if 'asana' in ticket_context: + asana_ctx = ticket_context['asana'] + ticket_context_str += "\nAsana Tasks:\n" + if 'task_ids' in asana_ctx: + for task_id in asana_ctx['task_ids']: + ticket_context_str += f"- Task ID: {task_id}\n" + # Create the prompt with system and user messages + system_prompt = ( + "You are a helpful commit message generator. Given code changes, you will generate a concise, " + "informative commit message title and optionally a detailed description. " + "Follow common commit message conventions with a short subject line (<72 chars) and optional detailed body." + ) + + user_prompt = ( + f"Here are the code changes (diff):\n\n{diff}\n\n" + f"Repository details:\n{json.dumps(repo_details, indent=2)}\n\n" + f"{ticket_context_str}\n" + f"Instructions: {instruction}\n\n" + f"Please generate a {'commit message with title and detailed description' if generate_description else 'concise commit title only'}." + ) + + # Get response from LLM + response = llm_client.get_completion( + system_prompt=system_prompt, + user_prompt=user_prompt + ) + + # Parse response to extract title and description + if generate_description: + # Try to parse title and description from the response + lines = response.split('\n') + if lines: + title = lines[0].strip() + # If there are more lines, join them as description + description = '\n'.join(lines[1:]).strip() if len(lines) > 1 else "" + return {'title': title, 'description': description} + else: + return {'title': response, 'description': ""} + else: + # Just return the first line as title + return {'title': response.strip().split('\n')[0], 'description': ""} + + def analyze_folder(self, folder_path: str, readme_content: str = None) -> Dict[str, str]: + """Analyze a folder and generate documentation. + Args: - 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 + folder_path (str): Path to the folder to analyze. + readme_content (str, optional): Existing README content. Defaults to None. + + Returns: + Dict[str, str]: Dictionary containing generated documentation. + """ + url = f"{self.base_url}/v1/doc/folder" + + # Prepare the request body + data = { + "folderPath": folder_path, + } + + if readme_content: + data["readmeContent"] = readme_content + try: + response = requests.post( + url, + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + }, + json=data + ) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + print(f"Error sending request to API: {e}") + return {} + + def analyze_file(self, file_path: str, file_content: str) -> Dict[str, str]: + """Analyze a file and generate documentation. + + Args: + file_path (str): Path to the file to analyze. + file_content (str): Content of the file. + Returns: - Dict with title and description for the commit + Dict[str, str]: Dictionary containing generated documentation. """ + url = f"{self.base_url}/v1/doc/file" + + # Prepare the request body + data = { + "filePath": file_path, + "fileContent": file_content, + } + try: - return llm_client.generate_commit_summary(diff, message, generate_description, 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) + response = requests.post( + url, + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + }, + json=data + ) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + print(f"Error sending request to API: {e}") + return {} def get_api_key(self): - url = self.api_url+"/v1/apiToken/get" - response = requests.get(url, headers={"Authorization": f"Bearer {self.BEARER_TOKEN}"}, timeout=60*10) + url = self.base_url+"/v1/apiToken/get" + response = requests.get(url, headers={"Authorization": f"Bearer {self.api_key}"}, timeout=60*10) if response.status_code == 200: response = response.json() return response.get('key') diff --git a/penify_hook/commands/auth_commands.py b/penify_hook/commands/auth_commands.py index 8c41041..40f0f8b 100644 --- a/penify_hook/commands/auth_commands.py +++ b/penify_hook/commands/auth_commands.py @@ -4,19 +4,63 @@ import socketserver import urllib.parse import random +import os from threading import Thread from pathlib import Path -from ..api_client import APIClient def save_credentials(api_key): """ - Save the token and API keys in the .penify file in the user's home directory. + Save the token and API keys based on priority: + 1. .env file in Git repo root (if in a git repo) + 2. .penify file in home directory (global fallback) + + Args: + api_key: The API key to save + + Returns: + bool: True if saved successfully, False otherwise """ + # Try to save in .env file in git repo first + try: + from ..utils import recursive_search_git_folder + current_dir = os.getcwd() + repo_root = recursive_search_git_folder(current_dir) + + if repo_root: + # We're in a git repo, save to .env file + env_file = Path(repo_root) / '.env' + try: + # Read existing .env content + env_content = {} + if env_file.exists(): + with open(env_file, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_content[key.strip()] = value.strip() + + # Update API token + env_content['PENIFY_API_TOKEN'] = api_key + + # Write back to .env file + with open(env_file, 'w') as f: + for key, value in env_content.items(): + f.write(f"{key}={value}\n") + + print(f"API token saved to {env_file}") + return True + except Exception as e: + print(f"Error saving to .env file: {str(e)}") + # Fall back to saving in .penify global config + except Exception as e: + print(f"Error finding git repository: {str(e)}") + + # Fall back to global .penify file in home directory home_dir = Path.home() penify_file = home_dir / '.penify' - - # if the file already exists, add the new api key to the existing file + # If the file already exists, add the new api key to the existing file if penify_file.exists(): with open(penify_file, 'r') as f: credentials = json.load(f) @@ -29,6 +73,7 @@ def save_credentials(api_key): try: with open(penify_file, 'w') as f: json.dump(credentials, f) + print(f"API token saved to global config {penify_file}") return True except Exception as e: print(f"Error saving credentials: {str(e)}") @@ -74,6 +119,7 @@ def do_GET(self): self.wfile.write(response.encode()) print(f"\nLogin successful! Fetching API keys...") + from ..api_client import APIClient api_key = APIClient(api_url, None, token).get_api_key() if api_key: save_credentials(api_key) diff --git a/penify_hook/commands/config_commands.py b/penify_hook/commands/config_commands.py index 4d27c42..7c4c325 100644 --- a/penify_hook/commands/config_commands.py +++ b/penify_hook/commands/config_commands.py @@ -8,7 +8,61 @@ from pathlib import Path from threading import Thread import logging -from penify_hook.utils import recursive_search_git_folder +import sys +from typing import Dict, Any, Optional, Union + +# Try to import dotenv, but don't fail if it's not available +try: + from dotenv import load_dotenv + DOTENV_AVAILABLE = True +except ImportError: + DOTENV_AVAILABLE = False + + +def load_env_files() -> None: + """ + Load environment variables from .env files in various locations, + with proper priority (later files override earlier ones): + 1. User home directory .env (lowest priority) + 2. Git repo root directory .env (if in a git repo) + 3. Current directory .env (highest priority) + + This function is called when the module is imported, ensuring env variables + are available throughout the application lifecycle. + """ + if not DOTENV_AVAILABLE: + logging.warning("python-dotenv is not installed. .env file loading is disabled.") + logging.warning("Run 'pip install python-dotenv' to enable .env file support.") + return + + # Load from user home directory (lowest priority) + try: + home_env = Path.home() / '.env' + if home_env.exists(): + load_dotenv(dotenv_path=home_env, override=False) + except Exception as e: + logging.warning(f"Failed to load .env from home directory: {str(e)}") + + # Load from Git repo root (medium priority) + try: + from penify_hook.utils import recursive_search_git_folder + current_dir = os.getcwd() + repo_root = recursive_search_git_folder(current_dir) + if repo_root and repo_root != str(Path.home()): + repo_env = Path(repo_root) / '.env' + if repo_env.exists() and repo_env != home_env: + load_dotenv(dotenv_path=repo_env, override=True) + except Exception as e: + logging.warning(f"Failed to load .env from Git repo: {str(e)}") + + # Load from current directory (highest priority) + current_env = Path(os.getcwd()) / '.env' + if current_env.exists() and (not repo_root or current_env != Path(repo_root) / '.env'): + load_dotenv(dotenv_path=current_env, override=True) + + +# Load environment variables when module is imported +load_env_files() def get_penify_config() -> Path: @@ -18,6 +72,7 @@ def get_penify_config() -> Path: and its parent directories until it finds it or reaches the home directory. """ current_dir = os.getcwd() + from penify_hook.utils import recursive_search_git_folder home_dir = recursive_search_git_folder(current_dir) @@ -40,102 +95,547 @@ def get_penify_config() -> Path: with open(penify_dir / 'config.json', 'w') as f: json.dump({}, f) return penify_dir / 'config.json' + + +def get_env_var_or_default(env_var: str, default: Any = None) -> Any: + """ + Get environment variable or return default value. + Args: + env_var: The environment variable name + default: Default value if environment variable is not set + + Returns: + Value of the environment variable or default + """ + return os.environ.get(env_var, default) + def save_llm_config(model, api_base, api_key): """ - Save LLM configuration settings in the .penify file. + Save LLM configuration settings to .env file. + + This function saves LLM configuration in the following priority: + 1. Git repo root .env (if inside a git repo) + 2. User home directory .env """ - - penify_file = get_penify_config() + from pathlib import Path + import os - config = {} - if penify_file.exists(): - try: - with open(penify_file, 'r') as f: - config = json.load(f) - except (json.JSONDecodeError, IOError, OSError) as e: - print(f"Error reading configuration file: {str(e)}") - # Continue with empty config - - # Update or add LLM configuration - config['llm'] = { - 'model': model, - 'api_base': api_base, - 'api_key': api_key - } + if not DOTENV_AVAILABLE: + print("python-dotenv is not installed. Run 'pip install python-dotenv' to enable .env file support.") + return False + + # Try to find Git repo root + try: + from penify_hook.utils import recursive_search_git_folder + current_dir = os.getcwd() + repo_root = recursive_search_git_folder(current_dir) + env_file = Path(repo_root) / '.env' if repo_root else Path.home() / '.env' + except Exception as e: + print(f"Failed to determine Git repo root: {str(e)}") + env_file = Path.home() / '.env' + # Read existing .env content + env_content = {} + if env_file.exists(): + with open(env_file, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_content[key.strip()] = value.strip() + + # Update LLM configuration + env_content['PENIFY_LLM_MODEL'] = model + env_content['PENIFY_LLM_API_BASE'] = api_base + env_content['PENIFY_LLM_API_KEY'] = api_key + + # Write back to .env file try: - with open(penify_file, 'w') as f: - json.dump(config, f) - print(f"LLM configuration saved to {penify_file}") + with open(env_file, 'w') as f: + for key, value in env_content.items(): + f.write(f"{key}={value}\n") + print(f"LLM configuration saved to {env_file}") + + # Reload environment variables to make changes immediately available + if DOTENV_AVAILABLE: + from dotenv import load_dotenv + load_dotenv(dotenv_path=env_file, override=True) + return True except Exception as e: print(f"Error saving LLM configuration: {str(e)}") return False + def save_jira_config(url, username, api_token): """ - Save JIRA configuration settings in the .penify file. + Save JIRA configuration settings to .env file. + + This function saves JIRA configuration in the following priority: + 1. Git repo root .env (if inside a git repo) + 2. User home directory .env """ - from penify_hook.utils import recursive_search_git_folder - - home_dir = Path.home() - penify_file = home_dir / '.penify' + from pathlib import Path + import os - 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 - } + if not DOTENV_AVAILABLE: + print("python-dotenv is not installed. Run 'pip install python-dotenv' to enable .env file support.") + return False + + # Try to find Git repo root + try: + from penify_hook.utils import recursive_search_git_folder + current_dir = os.getcwd() + repo_root = recursive_search_git_folder(current_dir) + env_file = Path(repo_root) / '.env' if repo_root else Path.home() / '.env' + except Exception as e: + print(f"Failed to determine Git repo root: {str(e)}") + env_file = Path.home() / '.env' + + # Read existing .env content + env_content = {} + if env_file.exists(): + with open(env_file, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_content[key.strip()] = value.strip() + # Update JIRA configuration + env_content['PENIFY_JIRA_URL'] = url + env_content['PENIFY_JIRA_USER'] = username + env_content['PENIFY_JIRA_TOKEN'] = api_token + + # Write back to .env file try: - with open(penify_file, 'w') as f: - json.dump(config, f) - print(f"JIRA configuration saved to {penify_file}") + with open(env_file, 'w') as f: + for key, value in env_content.items(): + f.write(f"{key}={value}\n") + print(f"JIRA configuration saved to {env_file}") + + # Reload environment variables to make changes immediately available + if DOTENV_AVAILABLE: + from dotenv import load_dotenv + load_dotenv(dotenv_path=env_file, override=True) + return True except Exception as e: print(f"Error saving JIRA configuration: {str(e)}") return False -def get_llm_config(): + +def save_azdo_config(url, project, pat_token): """ - Get LLM configuration from the .penify file. + Save Azure DevOps configuration settings to .env file. + + This function saves Azure DevOps configuration in the following priority: + 1. Git repo root .env (if inside a git repo) + 2. User home directory .env """ - config_file = get_penify_config() - if config_file.exists(): - try: - with open(config_file, 'r') as f: - config = json.load(f) - return config.get('llm', {}) - except (json.JSONDecodeError, Exception) as e: - print(f"Error reading .penify config file: {str(e)}") + from pathlib import Path + import os + + if not DOTENV_AVAILABLE: + print("python-dotenv is not installed. Run 'pip install python-dotenv' to enable .env file support.") + return False + + # Try to find Git repo root + try: + from penify_hook.utils import recursive_search_git_folder + current_dir = os.getcwd() + repo_root = recursive_search_git_folder(current_dir) + env_file = Path(repo_root) / '.env' if repo_root else Path.home() / '.env' + except Exception as e: + print(f"Failed to determine Git repo root: {str(e)}") + env_file = Path.home() / '.env' + + # Read existing .env content + env_content = {} + if env_file.exists(): + with open(env_file, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_content[key.strip()] = value.strip() - return {} + # Update Azure DevOps configuration + env_content['PENIFY_AZDO_URL'] = url + env_content['PENIFY_AZDO_PROJECT'] = project + env_content['PENIFY_AZDO_PAT_TOKEN'] = pat_token + + # Write back to .env file + try: + with open(env_file, 'w') as f: + for key, value in env_content.items(): + f.write(f"{key}={value}\n") + print(f"Azure DevOps configuration saved to {env_file}") + + # Reload environment variables to make changes immediately available + if DOTENV_AVAILABLE: + from dotenv import load_dotenv + load_dotenv(dotenv_path=env_file, override=True) + + return True + except Exception as e: + print(f"Error saving Azure DevOps configuration: {str(e)}") + return False -def get_jira_config(): + +def save_asana_config(token, workspace, project=None): """ - Get JIRA configuration from the .penify file. + Save Asana configuration settings to .env file. + + This function saves Asana configuration in the following priority: + 1. Git repo root .env (if inside a git repo) + 2. User home directory .env """ - config_file = get_penify_config() - if config_file.exists(): - try: - with open(config_file, 'r') as f: - config = json.load(f) - return config.get('jira', {}) - except (json.JSONDecodeError, Exception) as e: - print(f"Error reading .penify config file: {str(e)}") + from pathlib import Path + import os + + if not DOTENV_AVAILABLE: + print("python-dotenv is not installed. Run 'pip install python-dotenv' to enable .env file support.") + return False + + # Try to find Git repo root + try: + from penify_hook.utils import recursive_search_git_folder + current_dir = os.getcwd() + repo_root = recursive_search_git_folder(current_dir) + env_file = Path(repo_root) / '.env' if repo_root else Path.home() / '.env' + except Exception as e: + print(f"Failed to determine Git repo root: {str(e)}") + env_file = Path.home() / '.env' + + # Read existing .env content + env_content = {} + if env_file.exists(): + with open(env_file, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_content[key.strip()] = value.strip() + + # Update Asana configuration + env_content['PENIFY_ASANA_TOKEN'] = token + env_content['PENIFY_ASANA_WORKSPACE'] = workspace + if project: + env_content['PENIFY_ASANA_PROJECT'] = project + + # Write back to .env file + try: + with open(env_file, 'w') as f: + for key, value in env_content.items(): + f.write(f"{key}={value}\n") + print(f"Asana configuration saved to {env_file}") + + # Reload environment variables to make changes immediately available + if DOTENV_AVAILABLE: + from dotenv import load_dotenv + load_dotenv(dotenv_path=env_file, override=True) + + return True + except Exception as e: + print(f"Error saving Asana configuration: {str(e)}") + return False + + +def save_github_config(token, owner=None, repo=None): + """ + Save GitHub configuration settings to .env file. + + This function saves GitHub configuration in the following priority: + 1. Git repo root .env (if inside a git repo) + 2. User home directory .env + """ + from pathlib import Path + import os + + if not DOTENV_AVAILABLE: + print("python-dotenv is not installed. Run 'pip install python-dotenv' to enable .env file support.") + return False + + # Try to find Git repo root + try: + from penify_hook.utils import recursive_search_git_folder + current_dir = os.getcwd() + repo_root = recursive_search_git_folder(current_dir) + env_file = Path(repo_root) / '.env' if repo_root else Path.home() / '.env' + except Exception as e: + print(f"Failed to determine Git repo root: {str(e)}") + env_file = Path.home() / '.env' + + # Read existing .env content + env_content = {} + if env_file.exists(): + with open(env_file, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_content[key.strip()] = value.strip() + + # Update GitHub configuration + env_content['PENIFY_GITHUB_TOKEN'] = token + if owner: + env_content['PENIFY_GITHUB_OWNER'] = owner + if repo: + env_content['PENIFY_GITHUB_REPO'] = repo + + # Write back to .env file + try: + with open(env_file, 'w') as f: + for key, value in env_content.items(): + f.write(f"{key}={value}\n") + print(f"GitHub configuration saved to {env_file}") + + # Reload environment variables to make changes immediately available + if DOTENV_AVAILABLE: + from dotenv import load_dotenv + load_dotenv(dotenv_path=env_file, override=True) + + return True + except Exception as e: + print(f"Error saving GitHub configuration: {str(e)}") + return False + + +def save_kanban_config(tool, board_id, columns=None): + """ + Save Kanban board configuration settings to .env file. + + This function saves Kanban board configuration in the following priority: + 1. Git repo root .env (if inside a git repo) + 2. User home directory .env + """ + from pathlib import Path + import os + + if not DOTENV_AVAILABLE: + print("python-dotenv is not installed. Run 'pip install python-dotenv' to enable .env file support.") + return False + + # Try to find Git repo root + try: + from penify_hook.utils import recursive_search_git_folder + current_dir = os.getcwd() + repo_root = recursive_search_git_folder(current_dir) + env_file = Path(repo_root) / '.env' if repo_root else Path.home() / '.env' + except Exception as e: + print(f"Failed to determine Git repo root: {str(e)}") + env_file = Path.home() / '.env' + + # Read existing .env content + env_content = {} + if env_file.exists(): + with open(env_file, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_content[key.strip()] = value.strip() + + # Update Kanban configuration + env_content['PENIFY_KANBAN_TOOL'] = tool + env_content['PENIFY_KANBAN_BOARD_ID'] = board_id + if columns: + env_content['PENIFY_KANBAN_COLUMNS'] = columns + + # Write back to .env file + try: + with open(env_file, 'w') as f: + for key, value in env_content.items(): + f.write(f"{key}={value}\n") + print(f"Kanban configuration saved to {env_file}") + + # Reload environment variables to make changes immediately available + if DOTENV_AVAILABLE: + from dotenv import load_dotenv + load_dotenv(dotenv_path=env_file, override=True) + + return True + except Exception as e: + print(f"Error saving Kanban configuration: {str(e)}") + return False + + +def get_llm_config() -> Dict[str, str]: + """ + Get LLM configuration from environment variables. + + Environment variables: + - PENIFY_LLM_MODEL: Model name + - PENIFY_LLM_API_BASE: API base URL + - PENIFY_LLM_API_KEY: API key + + Returns: + dict: Configuration dictionary with model, api_base, and api_key + """ + # Ensure environment variables are loaded + if DOTENV_AVAILABLE: + load_env_files() + + # Get values from environment variables + config = { + 'model': get_env_var_or_default('PENIFY_LLM_MODEL', ''), + 'api_base': get_env_var_or_default('PENIFY_LLM_API_BASE', ''), + 'api_key': get_env_var_or_default('PENIFY_LLM_API_KEY', '') + } + + # Remove empty values + config = {k: v for k, v in config.items() if v} + + return config + + +def get_jira_config() -> Dict[str, str]: + """ + Get JIRA configuration from environment variables. + + Environment variables: + - PENIFY_JIRA_URL: JIRA URL + - PENIFY_JIRA_USER: JIRA username + - PENIFY_JIRA_TOKEN: JIRA API token + + Returns: + dict: Configuration dictionary with url, username, and api_token + """ + # Ensure environment variables are loaded + if DOTENV_AVAILABLE: + load_env_files() + + # Get values from environment variables + config = { + 'url': get_env_var_or_default('PENIFY_JIRA_URL', ''), + 'username': get_env_var_or_default('PENIFY_JIRA_USER', ''), + 'api_token': get_env_var_or_default('PENIFY_JIRA_TOKEN', '') + } + + # Remove empty values + config = {k: v for k, v in config.items() if v} + + return config + + +def get_azdo_config() -> Dict[str, str]: + """ + Get Azure DevOps configuration from environment variables. + + Environment variables: + - PENIFY_AZDO_URL: Azure DevOps URL + - PENIFY_AZDO_PROJECT: Azure DevOps project name + - PENIFY_AZDO_PAT_TOKEN: Azure DevOps Personal Access Token + + Returns: + dict: Configuration dictionary with url, project, and pat_token + """ + # Ensure environment variables are loaded + if DOTENV_AVAILABLE: + load_env_files() + + # Get values from environment variables + config = { + 'url': get_env_var_or_default('PENIFY_AZDO_URL', ''), + 'project': get_env_var_or_default('PENIFY_AZDO_PROJECT', ''), + 'pat_token': get_env_var_or_default('PENIFY_AZDO_PAT_TOKEN', '') + } + + # Remove empty values + config = {k: v for k, v in config.items() if v} + + return config + + +def get_asana_config() -> Dict[str, str]: + """ + Get Asana configuration from environment variables. + + Environment variables: + - PENIFY_ASANA_TOKEN: Asana Personal Access Token + - PENIFY_ASANA_WORKSPACE: Asana workspace name or ID + - PENIFY_ASANA_PROJECT: Asana project name or ID (optional) + + Returns: + dict: Configuration dictionary with token, workspace, and project + """ + # Ensure environment variables are loaded + if DOTENV_AVAILABLE: + load_env_files() + + # Get values from environment variables + config = { + 'token': get_env_var_or_default('PENIFY_ASANA_TOKEN', ''), + 'workspace': get_env_var_or_default('PENIFY_ASANA_WORKSPACE', ''), + 'project': get_env_var_or_default('PENIFY_ASANA_PROJECT', '') + } + + # Remove empty values + config = {k: v for k, v in config.items() if v} + + return config + + +def get_github_config() -> Dict[str, str]: + """ + Get GitHub configuration from environment variables. + + Environment variables: + - PENIFY_GITHUB_TOKEN: GitHub Personal Access Token + - PENIFY_GITHUB_OWNER: GitHub repository owner (optional) + - PENIFY_GITHUB_REPO: GitHub repository name (optional) + + Returns: + dict: Configuration dictionary with token, owner, and repo + """ + # Ensure environment variables are loaded + if DOTENV_AVAILABLE: + load_env_files() + + # Get values from environment variables + config = { + 'token': get_env_var_or_default('PENIFY_GITHUB_TOKEN', ''), + 'owner': get_env_var_or_default('PENIFY_GITHUB_OWNER', ''), + 'repo': get_env_var_or_default('PENIFY_GITHUB_REPO', '') + } + + # Remove empty values + config = {k: v for k, v in config.items() if v} + + return config + + +def get_kanban_config() -> Dict[str, str]: + """ + Get Kanban board configuration from environment variables. + + Environment variables: + - PENIFY_KANBAN_TOOL: Kanban tool name (jira, azdo, trello, github, asana) + - PENIFY_KANBAN_BOARD_ID: ID or name of the Kanban board + - PENIFY_KANBAN_COLUMNS: Comma-separated list of column names (optional) + + Returns: + dict: Configuration dictionary with tool, board_id, and columns + """ + # Ensure environment variables are loaded + if DOTENV_AVAILABLE: + load_env_files() + + # Get values from environment variables + config = { + 'tool': get_env_var_or_default('PENIFY_KANBAN_TOOL', ''), + 'board_id': get_env_var_or_default('PENIFY_KANBAN_BOARD_ID', ''), + 'columns': get_env_var_or_default('PENIFY_KANBAN_COLUMNS', '') + } + + # Remove empty values + config = {k: v for k, v in config.items() if v} - return {} + return config + def config_llm_web(): """ @@ -239,6 +739,7 @@ def log_message(self, format, *args): print("Configuration completed.") + def config_jira_web(): """ Open a web browser interface for configuring JIRA settings. @@ -344,16 +845,449 @@ def log_message(self, format, *args): print("Configuration completed.") -def get_token(): + +def config_azdo_web(): """ - Get the token based on priority. + Open a web browser interface for configuring Azure DevOps settings. """ - import os - env_token = os.getenv('PENIFY_API_TOKEN') - if env_token: - return env_token + redirect_port = random.randint(30000, 50000) + server_url = f"http://localhost:{redirect_port}" - config_file = Path.home() / '.penify' + 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/azdo_config.html" + ) + + with open(template_path, 'r') as f: + content = f.read() + + self.wfile.write(content.encode()) + elif self.path == "/get_config": + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + + # Get current Azure DevOps configuration + current_config = get_azdo_config() + + if current_config: + response = { + "success": True, + "config": current_config + } + else: + response = { + "success": False, + "message": "No Azure DevOps configuration found" + } + + self.wfile.write(json.dumps(response).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') + project = data.get('project') + pat_token = data.get('pat_token') + verify = data.get('verify', False) + + try: + # Save the configuration + save_azdo_config(url, project, pat_token) + + # Verify connection option is handled in main.py + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + response = { + "success": True, + "message": f"Azure DevOps configuration saved successfully." + } + 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_asana_web(): + """ + Open a web browser interface for configuring Asana 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/asana_config.html" + ) + + with open(template_path, 'r') as f: + content = f.read() + + self.wfile.write(content.encode()) + elif self.path == "/get_config": + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + + # Get current Asana configuration + current_config = get_asana_config() + + if current_config: + response = { + "success": True, + "config": current_config + } + else: + response = { + "success": False, + "message": "No Asana configuration found" + } + + self.wfile.write(json.dumps(response).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()) + + token = data.get('token') + workspace = data.get('workspace') + project = data.get('project') + verify = data.get('verify', False) + + try: + # Save the configuration + save_asana_config(token, workspace, project) + + # Verify connection option is handled in main.py + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + response = { + "success": True, + "message": f"Asana configuration saved successfully." + } + 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_github_web(): + """ + Open a web browser interface for configuring GitHub 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/github_config.html" + ) + + with open(template_path, 'r') as f: + content = f.read() + + self.wfile.write(content.encode()) + elif self.path == "/get_config": + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + + # Get current GitHub configuration + current_config = get_github_config() + + if current_config: + response = { + "success": True, + "config": current_config + } + else: + response = { + "success": False, + "message": "No GitHub configuration found" + } + + self.wfile.write(json.dumps(response).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()) + + token = data.get('token') + owner = data.get('owner') + repo = data.get('repo') + verify = data.get('verify', False) + + try: + # Save the configuration + save_github_config(token, owner, repo) + + # Verify connection option is handled in main.py + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + response = { + "success": True, + "message": f"GitHub configuration saved successfully." + } + 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_kanban_web(): + """ + Open a web browser interface for configuring Kanban board 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/kanban_config.html" + ) + + with open(template_path, 'r') as f: + content = f.read() + + self.wfile.write(content.encode()) + elif self.path == "/get_config": + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + + # Get current Kanban configuration + current_config = get_kanban_config() + + if current_config: + response = { + "success": True, + "config": current_config + } + else: + response = { + "success": False, + "message": "No Kanban configuration found" + } + + self.wfile.write(json.dumps(response).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()) + + tool = data.get('tool') + board_id = data.get('board_id') + columns = data.get('columns') + + try: + # Save the configuration + save_kanban_config(tool, board_id, columns) + + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + response = { + "success": True, + "message": f"Kanban configuration saved successfully." + } + 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() -> Optional[str]: + """ + Get the API token based on priority: + 1. Environment variable PENIFY_API_TOKEN from any .env file + 2. Config file 'api_keys' value + + Returns: + str or None: API token if found, None otherwise + """ + # Ensure environment variables are loaded from all .env files + if DOTENV_AVAILABLE: + load_env_files() + + # Check environment variable first + env_token = get_env_var_or_default('PENIFY_API_TOKEN') + if env_token: + return env_token + + # Check config file + config_file = get_penify_config() if config_file.exists(): try: with open(config_file, 'r') as f: diff --git a/penify_hook/commit_analyzer.py b/penify_hook/commit_analyzer.py index 5b89c6d..4ed7b3b 100644 --- a/penify_hook/commit_analyzer.py +++ b/penify_hook/commit_analyzer.py @@ -2,13 +2,14 @@ import re import subprocess import tempfile -from typing import Optional, List +from typing import Optional, List, Dict, Any from git import Repo from tqdm import tqdm from penify_hook.base_analyzer import BaseAnalyzer from penify_hook.jira_client import JiraClient from penify_hook.ui_utils import print_info, print_success, print_warning +from penify_hook.commands.config_commands import get_jira_config, get_azdo_config, get_github_config, get_asana_config from .api_client import APIClient class CommitDocGenHook(BaseAnalyzer): @@ -29,6 +30,7 @@ def get_summary(self, instruction: str, generate_description: bool) -> dict: Args: instruction (str): A string containing instructions for generating the commit summary. + generate_description (bool): Whether to generate a detailed description Returns: str: The generated commit summary based on the staged changes and provided @@ -41,29 +43,223 @@ def get_summary(self, instruction: str, generate_description: bool) -> dict: if not diff: raise ValueError("No changes to commit") - # Get JIRA context if available - jira_context = None + # Get ticket context from all available project management tools + ticket_context = self._get_ticket_context() + + # Use LLM client if provided, otherwise use API client + print_info("Fetching commit summary from LLM...") + if self.llm_client: + return self.api_client.generate_commit_summary_with_llm( + diff, instruction, generate_description, self.repo_details, self.llm_client, ticket_context + ) + else: + return self.api_client.generate_commit_summary(diff, instruction, self.repo_details, ticket_context) + + def _get_ticket_context(self) -> Dict[str, Any]: + """Get ticket context from all available project management tools. + + Checks all configured project management tools for relevant ticket information + based on branch name, previous commits, etc. + + Returns: + Dict[str, Any]: Combined context from all available project management tools + """ + context = {} + + # Get current branch name for ticket extraction + try: + current_branch = self.repo.active_branch.name + except Exception as e: + print_warning(f"Could not get current branch: {e}") + current_branch = "" + + # 1. Get JIRA context if available + jira_context = self._get_jira_context(current_branch) + if jira_context: + context['jira'] = jira_context + + # 2. Get Azure DevOps context if available + azdo_context = self._get_azdo_context(current_branch) + if azdo_context: + context['azure_devops'] = azdo_context + + # 3. Get GitHub context if available + github_context = self._get_github_context(current_branch) + if github_context: + context['github'] = github_context + + # 4. Get Asana context if available + asana_context = self._get_asana_context(current_branch) + if asana_context: + context['asana'] = asana_context + + # If any context was found, log it + if context: + tools = list(context.keys()) + print_info(f"Found relevant ticket context in: {', '.join(tools)}") + else: + print_info("No ticket context found in any configured project management tools") + + return context + + def _get_jira_context(self, branch_name: str) -> Dict[str, Any]: + """Get JIRA context based on branch name and configuration. + + Args: + branch_name (str): Current git branch name + + Returns: + Dict[str, Any]: JIRA context information if available + """ + jira_context = {} + + # Check if JIRA is configured either through jira_client or config 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) + issue_keys = self.jira_client.extract_issue_keys_from_branch(branch_name) # If issues found in branch, get context if issue_keys: jira_context = self.jira_client.get_commit_context_from_issues(issue_keys) + print_info(f"Found JIRA 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 - print_info("Fetching commit summary from LLM...") - if self.llm_client: - return self.api_client.generate_commit_summary_with_llm( - diff, instruction, generate_description, self.repo_details, self.llm_client, jira_context - ) + print_warning(f"Could not get JIRA context: {e}") else: - return self.api_client.generate_commit_summary(diff, instruction, self.repo_details, jira_context) + # Check if JIRA is configured in the environment + jira_config = get_jira_config() + if jira_config and 'url' in jira_config and 'username' in jira_config and 'api_token' in jira_config: + try: + # Create a temporary JIRA client for this operation + temp_jira_client = JiraClient( + jira_url=jira_config['url'], + jira_user=jira_config['username'], + jira_api_token=jira_config['api_token'] + ) + + if temp_jira_client.is_connected(): + # Extract JIRA issues from branch name + issue_keys = temp_jira_client.extract_issue_keys_from_branch(branch_name) + if issue_keys: + jira_context = temp_jira_client.get_commit_context_from_issues(issue_keys) + print_info(f"Found JIRA issues using config: {', '.join(issue_keys)}") + except Exception as e: + print_warning(f"Error using JIRA configuration: {e}") + + return jira_context + def _get_azdo_context(self, branch_name: str) -> Dict[str, Any]: + """Get Azure DevOps context based on branch name and configuration. + + Args: + branch_name (str): Current git branch name + + Returns: + Dict[str, Any]: Azure DevOps context information if available + """ + azdo_context = {} + + # Check if Azure DevOps is configured + azdo_config = get_azdo_config() + if azdo_config and 'url' in azdo_config and 'project' in azdo_config and 'pat_token' in azdo_config: + try: + # Extract work item IDs from branch name (common format: feature/12345-description) + work_item_pattern = r'(?:^|\/)(\d+)(?:-|_|\s|$)' + work_item_matches = re.findall(work_item_pattern, branch_name) + + if work_item_matches: + # Here we would call Azure DevOps API to get work item details + # For now, just include the IDs + azdo_context['work_item_ids'] = work_item_matches + print_info(f"Found Azure DevOps work items: {', '.join(work_item_matches)}") + + # In a real implementation, we would use the Azure DevOps Python SDK: + # from azure.devops.connection import Connection + # from msrest.authentication import BasicAuthentication + # credentials = BasicAuthentication('', azdo_config['pat_token']) + # connection = Connection(base_url=azdo_config['url'], creds=credentials) + # work_item_client = connection.clients.get_work_item_tracking_client() + # work_item_details = work_item_client.get_work_item(int(work_item_matches[0])) + # azdo_context['work_items'] = [work_item_details] + except Exception as e: + print_warning(f"Error getting Azure DevOps context: {e}") + + return azdo_context + + def _get_github_context(self, branch_name: str) -> Dict[str, Any]: + """Get GitHub context based on branch name and configuration. + + Args: + branch_name (str): Current git branch name + + Returns: + Dict[str, Any]: GitHub context information if available + """ + github_context = {} + + # Check if GitHub is configured + github_config = get_github_config() + if github_config and 'token' in github_config: + try: + # Extract GitHub issue numbers from branch name (common format: feature/12-description) + issue_pattern = r'(?:^|\/)(?:issue-|issue\/|#)?(\d+)(?:-|_|\s|$)' + issue_matches = re.findall(issue_pattern, branch_name) + + if issue_matches: + # Here we would call GitHub API to get issue details + # For now, just include the issue numbers + github_context['issue_numbers'] = issue_matches + print_info(f"Found GitHub issues: {', '.join(issue_matches)}") + + # In a real implementation, we would use the GitHub API: + # import requests + # headers = {'Authorization': f'token {github_config["token"]}'} + # owner = github_config.get('owner', '') + # repo = github_config.get('repo', '') + # if owner and repo: + # response = requests.get(f'https://api.github.com/repos/{owner}/{repo}/issues/{issue_matches[0]}', headers=headers) + # if response.status_code == 200: + # github_context['issues'] = [response.json()] + except Exception as e: + print_warning(f"Error getting GitHub context: {e}") + + return github_context + + def _get_asana_context(self, branch_name: str) -> Dict[str, Any]: + """Get Asana context based on branch name and configuration. + + Args: + branch_name (str): Current git branch name + + Returns: + Dict[str, Any]: Asana context information if available + """ + asana_context = {} + + # Check if Asana is configured + asana_config = get_asana_config() + if asana_config and 'token' in asana_config and 'workspace' in asana_config: + try: + # Extract Asana task IDs from branch name (common Asana format uses UUIDs) + # Example: feature/1234567890123456-description + task_pattern = r'(?:^|\/)([0-9a-f]{16})(?:-|_|\s|$)' + task_matches = re.findall(task_pattern, branch_name) + + if task_matches: + # Here we would call Asana API to get task details + # For now, just include the task IDs + asana_context['task_ids'] = task_matches + print_info(f"Found Asana tasks: {', '.join(task_matches)}") + + # In a real implementation, we would use the Asana Python SDK: + # import asana + # client = asana.Client.access_token(asana_config['token']) + # task = client.tasks.find_by_id(task_matches[0]) + # asana_context['tasks'] = [task] + except Exception as e: + print_warning(f"Error getting Asana context: {e}") + + return asana_context def run(self, msg: Optional[str], edit_commit_message: bool, generate_description: bool): """Run the post-commit hook. @@ -79,22 +275,21 @@ def run(self, msg: Optional[str], edit_commit_message: bool, generate_descriptio msg (Optional[str]): An optional message to include in the commit. edit_commit_message (bool): A flag indicating whether to open the git commit edit terminal after committing. + generate_description (bool): Whether to generate a detailed description Raises: Exception: If there is an error generating the commit summary. """ - summary: dict = self.get_summary(msg, True) + summary: dict = self.get_summary(msg, generate_description) 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 - self.process_jira_integration(title, description, msg) - + # Integrate information from all available project management tools + title, description = self._integrate_ticket_information(title, description, msg) + # commit the changes to the repository with above details commit_msg = f"{title}\n\n{description}" if generate_description else title self.repo.git.commit('-m', commit_msg) @@ -105,7 +300,57 @@ def run(self, msg: Optional[str], edit_commit_message: bool, generate_descriptio print_info("Opening git commit edit terminal...") self._amend_commit() - def process_jira_integration(self, title: str, description: str, msg: str) -> tuple: + def _integrate_ticket_information(self, title: str, description: str, msg: str) -> tuple: + """ + Integrate ticket information from all available project management tools into the commit message. + + Args: + title: Generated commit title + description: Generated commit description + msg: Original user message + + Returns: + tuple: (updated_title, updated_description) with ticket information + """ + # Collect ticket references from all available tools + ticket_references = [] + + # 1. Process JIRA integration if available + if self.jira_client and self.jira_client.is_connected(): + jira_tickets = self._process_jira_integration(title, description, msg) + ticket_references.extend([f"JIRA: {ticket}" for ticket in jira_tickets]) + + # 2. Process Azure DevOps integration if available + azdo_tickets = self._process_azdo_integration(title, description, msg) + ticket_references.extend([f"AB#{ticket}" for ticket in azdo_tickets]) # AB# is Azure Boards prefix + + # 3. Process GitHub integration if available + github_tickets = self._process_github_integration(title, description, msg) + ticket_references.extend([f"#{ticket}" for ticket in github_tickets]) # # is GitHub issue prefix + + # 4. Process Asana integration if available + asana_tickets = self._process_asana_integration(title, description, msg) + if asana_tickets: + ticket_references.extend([f"Asana: {ticket}" for ticket in asana_tickets]) + + # Format the ticket references in the commit message if any found + if ticket_references: + # Update title if no ticket reference is already in the title + has_reference_in_title = any(ref.split(':')[0].strip() in title for ref in ticket_references) + if not has_reference_in_title and ticket_references: + # Add first reference to the title + title = f"{ticket_references[0]} {title}" + + # Add all references to the description + references_text = "Related tickets: " + ", ".join(ticket_references) + if description: + description = f"{description}\n\n{references_text}" + else: + description = references_text + + return title, description + + def _process_jira_integration(self, title: str, description: str, msg: str) -> List[str]: """ Process JIRA integration for the commit message. @@ -115,9 +360,8 @@ def process_jira_integration(self, title: str, description: str, msg: str) -> tu msg: Original user message that might contain JIRA references Returns: - tuple: (updated_title, updated_description) with JIRA information + List[str]: List of JIRA issue keys """ - # Look for JIRA issue keys in commit message, title, description and user message issue_keys = [] if self.jira_client: # Extract from message content @@ -139,8 +383,6 @@ def process_jira_integration(self, title: str, description: str, msg: str) -> tu if issue_keys: print_info(f"Found JIRA issues: {', '.join(issue_keys)}") - # Format commit message with JIRA info - # Add comments to JIRA issues for issue_key in issue_keys: comment = ( @@ -152,7 +394,139 @@ def process_jira_integration(self, title: str, description: str, msg: str) -> tu else: print_warning("No JIRA issues found in commit message or branch name") - return title, description + return issue_keys + + def _process_azdo_integration(self, title: str, description: str, msg: str) -> List[str]: + """ + Process Azure DevOps integration for the commit message. + + Args: + title: Generated commit title + description: Generated commit description + msg: Original user message + + Returns: + List[str]: List of Azure DevOps work item IDs + """ + # Check Azure DevOps configuration + work_item_ids = [] + azdo_config = get_azdo_config() + + if azdo_config and 'url' in azdo_config and 'project' in azdo_config: + # Look for work item IDs in commit message and branch name + # Azure DevOps uses format: #123, AB#123, or workitems/123 + work_item_pattern = r'(?:AB#|#|workitems\/|work items\/|items\/)(\d+)' + + # Extract from commit content + content_to_search = f"{title} {description} {msg}" + work_item_ids.extend(re.findall(work_item_pattern, content_to_search)) + + # Also check the branch name + try: + current_branch = self.repo.active_branch.name + branch_work_items = re.findall(r'(?:^|\/)(\d+)(?:-|_|\s|$)', current_branch) + + # Add any new IDs found in branch name + for item_id in branch_work_items: + if item_id not in work_item_ids: + work_item_ids.append(item_id) + print_info(f"Added Azure DevOps work item #{item_id} from branch name: {current_branch}") + except Exception as e: + print_warning(f"Could not extract Azure DevOps work items from branch name: {e}") + + if work_item_ids: + print_info(f"Found Azure DevOps work items: {', '.join(work_item_ids)}") + # In a full implementation, we would update work items here + + return work_item_ids + + def _process_github_integration(self, title: str, description: str, msg: str) -> List[str]: + """ + Process GitHub integration for the commit message. + + Args: + title: Generated commit title + description: Generated commit description + msg: Original user message + + Returns: + List[str]: List of GitHub issue numbers + """ + # Check GitHub configuration + issue_numbers = [] + github_config = get_github_config() + + if github_config and 'token' in github_config: + # Look for issue numbers in commit message and branch name + # GitHub uses format: #123, GH-123, or issues/123 + issue_pattern = r'(?:#|GH-|issues\/|issue\/|pull\/|pulls\/)(\d+)' + + # Extract from commit content + content_to_search = f"{title} {description} {msg}" + issue_numbers.extend(re.findall(issue_pattern, content_to_search)) + + # Also check the branch name + try: + current_branch = self.repo.active_branch.name + branch_issues = re.findall(r'(?:issue-|issue\/|#)(\d+)', current_branch) + + # Add any new numbers found in branch name + for issue_num in branch_issues: + if issue_num not in issue_numbers: + issue_numbers.append(issue_num) + print_info(f"Added GitHub issue #{issue_num} from branch name: {current_branch}") + except Exception as e: + print_warning(f"Could not extract GitHub issues from branch name: {e}") + + if issue_numbers: + print_info(f"Found GitHub issues: {', '.join(issue_numbers)}") + # In a full implementation, we would add comments to GitHub issues here + + return issue_numbers + + def _process_asana_integration(self, title: str, description: str, msg: str) -> List[str]: + """ + Process Asana integration for the commit message. + + Args: + title: Generated commit title + description: Generated commit description + msg: Original user message + + Returns: + List[str]: List of Asana task IDs + """ + # Check Asana configuration + task_ids = [] + asana_config = get_asana_config() + + if asana_config and 'token' in asana_config: + # Look for task IDs in commit message and branch name + # Asana task IDs are typically 16-character hexadecimal strings + task_pattern = r'(?:asana\/|task\/|tasks\/)([0-9a-f]{16})' + + # Extract from commit content + content_to_search = f"{title} {description} {msg}" + task_ids.extend(re.findall(task_pattern, content_to_search)) + + # Also check the branch name + try: + current_branch = self.repo.active_branch.name + branch_tasks = re.findall(r'(?:^|\/)([0-9a-f]{16})(?:-|_|\s|$)', current_branch) + + # Add any new IDs found in branch name + for task_id in branch_tasks: + if task_id not in task_ids: + task_ids.append(task_id) + print_info(f"Added Asana task {task_id} from branch name: {current_branch}") + except Exception as e: + print_warning(f"Could not extract Asana tasks from branch name: {e}") + + if task_ids: + print_info(f"Found Asana tasks: {', '.join(task_ids)}") + # In a full implementation, we would add comments to Asana tasks here + + return task_ids def _amend_commit(self): """Open the default git editor for editing the commit message. diff --git a/penify_hook/config_command.py b/penify_hook/config_command.py index 6f76894..8b79d93 100644 --- a/penify_hook/config_command.py +++ b/penify_hook/config_command.py @@ -1,47 +1,86 @@ - - - def setup_config_parser(parent_parser): # Config subcommand: Create subparsers for config types parser = parent_parser.add_subparsers(title="config_type", dest="config_type") # Config subcommand: llm - llm_config_parser = parser.add_parser("llm", help="Configure LLM settings.") + llm_config_parser = parser.add_parser("llm-cmd", help="Configure LLM settings.") llm_config_parser.add_argument("--model", required=True, help="LLM model to use") llm_config_parser.add_argument("--api-base", help="API base URL for the LLM service") llm_config_parser.add_argument("--api-key", help="API key for the LLM service") # Config subcommand: llm-web - parser.add_parser("llm-web", help="Configure LLM settings through a web interface") + parser.add_parser("llm", help="Configure LLM settings through a web interface") # Config subcommand: jira - jira_config_parser = parser.add_parser("jira", help="Configure JIRA settings.") + jira_config_parser = parser.add_parser("jira-cmd", help="Configure JIRA settings.") jira_config_parser.add_argument("--url", required=True, help="JIRA base URL") 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") # Config subcommand: jira-web - parser.add_parser("jira-web", help="Configure JIRA settings through a web interface") + parser.add_parser("jira", help="Configure JIRA settings through a web interface") + + # Config subcommand: azure-devops + azdo_config_parser = parser.add_parser("azdo-cmd", help="Configure Azure DevOps settings.") + azdo_config_parser.add_argument("--url", required=True, help="Azure DevOps organization URL (e.g., https://dev.azure.com/organization)") + azdo_config_parser.add_argument("--project", required=True, help="Azure DevOps project name") + azdo_config_parser.add_argument("--pat-token", required=True, help="Azure DevOps Personal Access Token") + azdo_config_parser.add_argument("--verify", action="store_true", help="Verify Azure DevOps connection") + + # Config subcommand: azure-devops-web + parser.add_parser("azdo", help="Configure Azure DevOps settings through a web interface") + + # Config subcommand: asana + asana_config_parser = parser.add_parser("asana-cmd", help="Configure Asana settings.") + asana_config_parser.add_argument("--token", required=True, help="Asana Personal Access Token") + asana_config_parser.add_argument("--workspace", required=True, help="Asana workspace name or ID") + asana_config_parser.add_argument("--project", help="Asana project name or ID") + asana_config_parser.add_argument("--verify", action="store_true", help="Verify Asana connection") + + # Config subcommand: asana-web + parser.add_parser("asana", help="Configure Asana settings through a web interface") + + # Config subcommand: kanban + kanban_config_parser = parser.add_parser("kanban-cmd", help="Configure Kanban board settings.") + kanban_config_parser.add_argument("--tool", required=True, choices=["jira", "azdo", "trello", "github", "asana"], help="Kanban tool to use") + kanban_config_parser.add_argument("--board-id", required=True, help="ID or name of the Kanban board") + kanban_config_parser.add_argument("--columns", help="Comma-separated list of column names") + + # Config subcommand: kanban-web + parser.add_parser("kanban", help="Configure Kanban board settings through a web interface") + + # Config subcommand: github + github_config_parser = parser.add_parser("github-cmd", help="Configure GitHub settings.") + github_config_parser.add_argument("--token", required=True, help="GitHub Personal Access Token") + github_config_parser.add_argument("--owner", help="GitHub repository owner (username or organization)") + github_config_parser.add_argument("--repo", help="GitHub repository name") + github_config_parser.add_argument("--verify", action="store_true", help="Verify GitHub connection") + + # Config subcommand: github-web + parser.add_parser("github", help="Configure GitHub settings through a web interface") # Add all other necessary arguments for config command def handle_config(args): # Only import dependencies needed for config functionality here - from penify_hook.commands.config_commands import save_llm_config - from penify_hook.jira_client import JiraClient # Import moved here - from penify_hook.commands.config_commands import config_jira_web, config_llm_web, save_jira_config + + - if args.config_type == "llm": + if args.config_type == "llm-cmd": + from penify_hook.commands.config_commands import save_llm_config 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.config_type == "llm-web": + elif args.config_type == "llm": + from penify_hook.commands.config_commands import config_llm_web config_llm_web() - elif args.config_type == "jira": + elif args.config_type == "jira-cmd": + from penify_hook.commands.config_commands import save_jira_config save_jira_config(args.url, args.username, args.api_token) print(f"JIRA configuration set: URL={args.url}, Username={args.username}") + from penify_hook.jira_client import JiraClient # Import moved here # Verify connection if requested if args.verify: @@ -58,11 +97,148 @@ def handle_config(args): else: print("JIRA package not installed. Cannot verify connection.") - elif args.config_type == "jira-web": + elif args.config_type == "jira": + from penify_hook.commands.config_commands import config_jira_web config_jira_web() + + elif args.config_type == "azdo-cmd": + from penify_hook.commands.config_commands import save_azdo_config + save_azdo_config(args.url, args.project, args.pat_token) + print(f"Azure DevOps configuration set: URL={args.url}, Project={args.project}") + + # Verify connection if requested + if args.verify: + try: + # Verify connection by importing necessary packages + try: + from azure.devops.connection import Connection + from msrest.authentication import BasicAuthentication + + # Create a connection to Azure DevOps + credentials = BasicAuthentication('', args.pat_token) + connection = Connection(base_url=args.url, creds=credentials) + + # Test the connection by getting projects + core_client = connection.clients.get_core_client() + project = core_client.get_project(args.project) + + if project: + print("Azure DevOps connection verified successfully!") + else: + print(f"Project {args.project} not found. Please check your project name.") + except ImportError: + print("Azure DevOps packages not installed. Run 'pip install azure-devops' to enable verification.") + except Exception as e: + print(f"Failed to connect to Azure DevOps: {str(e)}") + + elif args.config_type == "azdo": + from penify_hook.commands.config_commands import config_azdo_web + config_azdo_web() + + elif args.config_type == "asana-cmd": + from penify_hook.commands.config_commands import save_asana_config + save_asana_config(args.token, args.workspace, args.project) + print(f"Asana configuration set: Workspace={args.workspace}, Project={args.project or 'Not specified'}") + + # Verify connection if requested + if args.verify: + try: + import asana + + # Create Asana client + client = asana.Client.access_token(args.token) + + # Verify token by attempting to get user info + me = client.users.me() + print(f"Asana connection verified successfully! Connected as: {me['name']} ({me['email']})") + + # Try to get workspace info if specified + if args.workspace: + try: + # Check if workspace ID is directly provided or need to search by name + if args.workspace.isdigit(): + workspace = client.workspaces.find_by_id(args.workspace) + else: + workspaces = list(client.workspaces.find_all()) + workspace = next((w for w in workspaces if w['name'].lower() == args.workspace.lower()), None) + + if workspace: + print(f"Workspace found: {workspace['name']}") + else: + print(f"Workspace '{args.workspace}' not found. Please check the workspace name or ID.") + except Exception as workspace_e: + print(f"Error finding workspace: {str(workspace_e)}") + + # Try to get project info if both workspace and project are specified + if args.project and args.workspace and workspace: + try: + # Get projects in the workspace + projects = list(client.projects.find_all({'workspace': workspace['gid']})) + project = next((p for p in projects if p['name'].lower() == args.project.lower() or p['gid'] == args.project), None) + + if project: + print(f"Project found: {project['name']}") + else: + print(f"Project '{args.project}' not found in workspace '{workspace['name']}'. Please check the project name or ID.") + except Exception as project_e: + print(f"Error finding project: {str(project_e)}") + + except ImportError: + print("Asana package not installed. Run 'pip install asana' to enable verification.") + except Exception as e: + print(f"Failed to connect to Asana: {str(e)}") + + elif args.config_type == "asana": + from penify_hook.commands.config_commands import config_asana_web + config_asana_web() + + elif args.config_type == "kanban-cmd": + from penify_hook.commands.config_commands import save_kanban_config + save_kanban_config(args.tool, args.board_id, args.columns) + print(f"Kanban configuration set: Tool={args.tool}, Board ID={args.board_id}") + + elif args.config_type == "kanban": + from penify_hook.commands.config_commands import config_kanban_web + config_kanban_web() + + elif args.config_type == "github-cmd": + from penify_hook.commands.config_commands import save_github_config + save_github_config(args.token, args.owner, args.repo) + print(f"GitHub configuration set: Owner={args.owner or 'Not specified'}, Repo={args.repo or 'Not specified'}") + + # Verify connection if requested + if args.verify: + try: + import requests + headers = { + 'Authorization': f'token {args.token}', + 'Accept': 'application/vnd.github.v3+json' + } + + if args.owner and args.repo: + # Verify specific repository access + response = requests.get(f"https://api.github.com/repos/{args.owner}/{args.repo}", headers=headers) + if response.status_code == 200: + print(f"GitHub connection verified successfully! Access to {args.owner}/{args.repo} confirmed.") + else: + print(f"Failed to access {args.owner}/{args.repo}. Status code: {response.status_code}") + else: + # Verify general access + response = requests.get("https://api.github.com/user", headers=headers) + if response.status_code == 200: + user_data = response.json() + print(f"GitHub connection verified successfully! Connected as: {user_data.get('login')}") + else: + print(f"Failed to connect to GitHub. Status code: {response.status_code}") + except Exception as e: + print(f"Error verifying GitHub connection: {str(e)}") + + elif args.config_type == "github": + from penify_hook.commands.config_commands import config_github_web + config_github_web() else: - print("Please specify a config type: llm, llm-web, jira, or jira-web") + print("Please specify a config type: llm, jira, azdo, asana, kanban, github") return 1 return 0 diff --git a/penify_hook/templates/asana_config.html b/penify_hook/templates/asana_config.html new file mode 100644 index 0000000..1d08aba --- /dev/null +++ b/penify_hook/templates/asana_config.html @@ -0,0 +1,249 @@ + + + + Penify Asana Configuration + + + + +
+

Penify Asana Configuration

+

Configure your Asana integration for Penify CLI.

+ +
+

Current Configuration

+
Loading current settings...
+
+ +
+
+ +
+ + +
+

Generate a Personal Access Token from your Asana account: Asana Developer Console

+
+ +
+ + +

Enter the name or ID of your Asana workspace

+
+ +
+ + +

Enter the name or ID of a specific Asana project within the workspace

+
+ +
+
+ + +
+
+ + +
+ +
+
+ + + + \ No newline at end of file diff --git a/penify_hook/templates/azdo_config.html b/penify_hook/templates/azdo_config.html new file mode 100644 index 0000000..f473e38 --- /dev/null +++ b/penify_hook/templates/azdo_config.html @@ -0,0 +1,249 @@ + + + + Penify Azure DevOps Configuration + + + + +
+

Penify Azure DevOps Configuration

+

Configure your Azure DevOps integration for Penify CLI.

+ +
+

Current Configuration

+
Loading current settings...
+
+ +
+
+ + +

The base URL of your Azure DevOps organization

+
+ +
+ + +

The name of your Azure DevOps project

+
+ +
+ +
+ + +
+

Generate a PAT from your Azure DevOps profile settings: Azure DevOps Personal Access Tokens

+
+ +
+
+ + +
+
+ + +
+ +
+
+ + + + \ No newline at end of file diff --git a/penify_hook/templates/github_config.html b/penify_hook/templates/github_config.html new file mode 100644 index 0000000..93f1e16 --- /dev/null +++ b/penify_hook/templates/github_config.html @@ -0,0 +1,249 @@ + + + + Penify GitHub Configuration + + + + +
+

Penify GitHub Configuration

+

Configure your GitHub integration for Penify CLI.

+ +
+

Current Configuration

+
Loading current settings...
+
+ +
+
+ +
+ + +
+

Generate a token from GitHub settings: GitHub Personal Access Tokens

+
+ +
+ + +

The username or organization that owns the repository

+
+ +
+ + +

The name of the GitHub repository

+
+ +
+
+ + +
+
+ + +
+ +
+
+ + + + \ No newline at end of file diff --git a/penify_hook/templates/kanban_config.html b/penify_hook/templates/kanban_config.html new file mode 100644 index 0000000..7d83567 --- /dev/null +++ b/penify_hook/templates/kanban_config.html @@ -0,0 +1,236 @@ + + + + Penify Kanban Configuration + + + + +
+

Penify Kanban Board Configuration

+

Configure your Kanban board integration for Penify CLI.

+ +
+

Current Configuration

+
Loading current settings...
+
+ +
+
+ + +

Select the tool where your Kanban board is hosted

+
+ +
+ + +

The ID or name of your Kanban board

+
+ +
+ + +

Comma-separated list of column names in your Kanban board

+
+ + +
+ +
+
+ + + + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 0679314..216bc40 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ requests>=2.25.0 gitpython>=3.1.0 tqdm>=4.62.0 +python-dotenv>=1.0.0 # Testing requirements pytest>=7.0.0