diff --git a/.github/workflows/snorkell-auto-documentation.yml b/.github/workflows/snorkell-auto-documentation.yml deleted file mode 100644 index 926c7e5..0000000 --- a/.github/workflows/snorkell-auto-documentation.yml +++ /dev/null @@ -1,19 +0,0 @@ -# This workflow will improvise current file with AI genereated documentation and Create new PR - -name: Penify - Revolutionizing Documentation on GitHub - -on: - push: - branches: ["main"] - workflow_dispatch: - -jobs: - Documentation: - runs-on: ubuntu-latest - steps: - - name: Penify DocGen Client - uses: SingularityX-ai/snorkell-documentation-client@v1.0.0 - with: - client_id: ${{ secrets.SNORKELL_CLIENT_ID }} - api_key: ${{ secrets.SNORKELL_API_KEY }} - branch_name: "main" \ No newline at end of file diff --git a/README.md b/README.md index ec60b4d..58d1d04 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Penify CLI -Penify CLI is a command-line tool for managing Git hooks, generating documentation, and streamlining the development workflow. It provides functionality to install and uninstall Git post-commit hooks, generate documentation for files or folders, perform Git commits with automated message generation, and manage authentication. +Penify CLI is a command-line tool for managing Git commits, generating documentation, and streamlining the development workflow. It provides AI-powered commit message generation, JIRA integration, and documentation tools. ## Installation @@ -49,7 +49,7 @@ penifycli uninstall-hook -l /path/to/git/repo To generate documentation for files or folders: ```bash -penifycli doc-gen [options] +penifycli docgen [options] ``` Options: diff --git a/penify_hook/api_client.py b/penify_hook/api_client.py index abeecf9..505ff7e 100644 --- a/penify_hook/api_client.py +++ b/penify_hook/api_client.py @@ -41,9 +41,11 @@ def send_file_for_docstring_generation(self, file_name, content, line_numbers, r response = response.json() return response.get('modified_content') else: - print(f"Response: {response.status_code}") - print(f"Error: {response.text}") - return content + error_message = response.json().get('detail') + if not error_message: + 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. @@ -82,9 +84,9 @@ def generate_commit_summary(self, git_diff, instruction: str = "", repo_details response = response.json() return response else: - print(f"Response: {response.status_code}") - print(f"Error: {response.text}") - return None + # 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 diff --git a/penify_hook/commands/config_commands.py b/penify_hook/commands/config_commands.py index 7376ada..19b1c35 100644 --- a/penify_hook/commands/config_commands.py +++ b/penify_hook/commands/config_commands.py @@ -6,6 +6,7 @@ import pkg_resources from pathlib import Path from threading import Thread +import logging def save_llm_config(model, api_base, api_key): """ @@ -267,13 +268,10 @@ def log_message(self, format, *args): print("Configuration completed.") -def get_token(passed_token): +def get_token(): """ Get the token based on priority. """ - if passed_token: - return passed_token - import os env_token = os.getenv('PENIFY_API_TOKEN') if env_token: diff --git a/penify_hook/commands/doc_commands.py b/penify_hook/commands/doc_commands.py index 500d81c..a3fafcd 100644 --- a/penify_hook/commands/doc_commands.py +++ b/penify_hook/commands/doc_commands.py @@ -1,33 +1,49 @@ +import os import sys from ..folder_analyzer import FolderAnalyzerGenHook from ..file_analyzer import FileAnalyzerGenHook from ..git_analyzer import GitDocGenHook from ..api_client import APIClient -def generate_doc(api_url, token, file_path=None, complete_folder_path=None, git_folder_path=None): - """ - Generates documentation based on the given parameters. +def generate_doc(api_url, token, location=None): + """Generates documentation based on the given parameters. + + This function initializes an API client using the provided API URL and + token. It then generates documentation by analyzing the specified + location, which can be a folder, a file, or the current working + directory if no location is provided. The function handles different + types of analysis based on the input location and reports any errors + encountered during the process. + + Args: + api_url (str): The URL of the API to connect to for documentation generation. + token (str): The authentication token for accessing the API. + location (str?): The path to a specific file or folder to analyze. + If not provided, the current working directory is used. """ api_client = APIClient(api_url, token) - - if file_path: + if location is None: + current_folder_path = os.getcwd() try: - analyzer = FileAnalyzerGenHook(file_path, api_client) + analyzer = GitDocGenHook(current_folder_path, api_client) analyzer.run() except Exception as e: print(f"Error: {e}") sys.exit(1) - elif complete_folder_path: + + # if location is a file + elif len(location.split('.')) > 1: try: - analyzer = FolderAnalyzerGenHook(complete_folder_path, api_client) + analyzer = FileAnalyzerGenHook(location, api_client) analyzer.run() except Exception as e: print(f"Error: {e}") sys.exit(1) + else: try: - analyzer = GitDocGenHook(git_folder_path, api_client) + analyzer = FolderAnalyzerGenHook(location, api_client) analyzer.run() except Exception as e: print(f"Error: {e}") - sys.exit(1) + sys.exit(1) \ No newline at end of file diff --git a/penify_hook/commands/hook_commands.py b/penify_hook/commands/hook_commands.py index dcb515f..9a56f40 100644 --- a/penify_hook/commands/hook_commands.py +++ b/penify_hook/commands/hook_commands.py @@ -4,13 +4,15 @@ HOOK_FILENAME = "post-commit" HOOK_TEMPLATE = """#!/bin/sh # This is a post-commit hook generated by penifycli. +# Automatically generates documentation for changed files after each commit. -penifycli -t {token} -gf {git_folder_path} +penifycli docgen -gf {git_folder_path} -t {token} """ def install_git_hook(location, token): """ - Install a post-commit hook in the specified location. + Install a post-commit hook in the specified location that generates documentation + for changed files after each commit. """ hooks_dir = Path(location) / ".git/hooks" hook_path = hooks_dir / HOOK_FILENAME @@ -24,6 +26,7 @@ def install_git_hook(location, token): hook_path.chmod(0o755) # Make the hook script executable print(f"Post-commit hook installed in {hook_path}") + print(f"Documentation will now be automatically generated after each commit.") def uninstall_git_hook(location): """ diff --git a/penify_hook/file_analyzer.py b/penify_hook/file_analyzer.py index d383c73..d058450 100644 --- a/penify_hook/file_analyzer.py +++ b/penify_hook/file_analyzer.py @@ -1,14 +1,32 @@ import os +import sys from git import Repo +from tqdm import tqdm +import time + +from penify_hook.utils import get_repo_details, recursive_search_git_folder from .api_client import APIClient +import logging +from .ui_utils import ( + format_highlight, print_info, print_success, print_warning, print_error, + print_status, create_stage_progress_bar, + update_stage, format_file_path +) + +# Set up logger +logger = logging.getLogger(__name__) class FileAnalyzerGenHook: def __init__(self, file_path: str, api_client: APIClient): self.file_path = file_path + self.repo_path = recursive_search_git_folder(file_path) + self.repo = Repo(self.repo_path) + self.repo_details = get_repo_details(self.repo) + self.relative_file_path = os.path.relpath(file_path) self.api_client = api_client self.supported_file_types = set(self.api_client.get_supported_file_types()) - def process_file(self, file_path): + def process_file(self, file_path, pbar): """Process a file by reading its content and sending it to an API for processing. @@ -19,36 +37,77 @@ def process_file(self, file_path): Args: file_path (str): The relative path to the file that needs to be processed. + pbar (tqdm): Progress bar to update during processing. Returns: bool: True if the file was processed successfully, False otherwise. """ file_abs_path = os.path.join(os.getcwd(), file_path) file_extension = os.path.splitext(file_path)[1].lower() - + + # --- STAGE 1: Validating --- + update_stage(pbar, "Validating") if not file_extension: - print(f"File {file_path} has no extension. Skipping.") + logger.info(f"File {file_path} has no extension. Skipping.") return False file_extension = file_extension[1:] # Remove the leading dot if file_extension not in self.supported_file_types: - print(f"File type {file_extension} is not supported. Skipping {file_path}.") + logger.info(f"File type {file_extension} is not supported. Skipping {file_path}.") return False - with open(file_abs_path, 'r') as file: - content = file.read() + # Update progress bar to indicate we're moving to next stage + pbar.update(1) + + # --- STAGE 2: Reading content --- + update_stage(pbar, "Reading content") + try: + with open(file_abs_path, 'r') as file: + content = file.read() + except Exception as e: + logger.error(f"Error reading file {file_path}: {str(e)}") + return False - modified_lines = [i for i in range(len(content.splitlines()))] + modified_lines = [i for i in range(len(content.splitlines()))] - # Send data to API - response = self.api_client.send_file_for_docstring_generation(file_path, content, modified_lines) + # Update progress bar to indicate we're moving to next stage + pbar.update(1) - # If the response is successful, replace the file content - with open(file_abs_path, 'w') as file: - file.write(response) - print(f"File [{self.file_path}] processed successfully.") - + # --- STAGE 3: Documenting --- + update_stage(pbar, "Documenting") + + response = self.api_client.send_file_for_docstring_generation(file_path, content, modified_lines, self.repo_details) + + if response is None: + return False + + if response == content: + logger.info(f"No changes needed for {file_path}") + return False + + # Update progress bar to indicate we're moving to next stage + pbar.update(1) + + # --- STAGE 4: Writing changes --- + update_stage(pbar, "Writing changes") + + try: + with open(file_abs_path, 'w') as file: + file.write(response) + logger.info(f"Updated file {file_path} with generated documentation") + + # Mark final stage as complete + pbar.update(1) + return True + except Exception as e: + logger.error(f"Error writing file {file_path}: {str(e)}") + return False + + def print_processing(self, file_path): + """Print a processing message for a file.""" + formatted_path = format_file_path(file_path) + print(f"\n{format_highlight(f'Processing file: {formatted_path}')}") def run(self): """Run the post-commit hook. @@ -56,12 +115,48 @@ def run(self): This method executes the post-commit hook by processing a specified file. It attempts to process the file located at `self.file_path`. If an error occurs during the processing, it catches the exception and prints - an error message indicating that the file was not processed. + an error message indicating that the file was not processed. The method + displays a progress bar and colored output to provide visual feedback on + the processing status. """ + + # Create a progress bar with appropriate stages + stages = ["Validating", "Reading content", "Documenting", "Writing changes", "Completed"] + pbar, _ = create_stage_progress_bar(stages, f"Starting documenting") + try: - self.process_file(self.file_path) - # Stage the modified file + # Print a clear indication of which file is being processed + # self.print_processing(self.file_path) + # Process the file + result = self.process_file(self.file_path, pbar) + + # Ensure all stages are completed + remaining_steps = len(stages) - pbar.n + pbar.update(remaining_steps) + + + # Display appropriate message based on result + remaining = len(stages) - pbar.n + if remaining > 0: + pbar.update(remaining) + update_stage(pbar, "Complete") + pbar.clear() + pbar.close() + except Exception as e: - print(f"File [{self.file_path}] was not processed.") - \ No newline at end of file + remaining = len(stages) - pbar.n + if remaining > 0: + pbar.update(remaining) + update_stage(pbar, "Complete") + pbar.clear() + pbar.close() + print_status('error', e) + sys.exit(1) + + # Ensure progress bar completes even on error + if result: + print_success(f"\n✓ Documentation updated for {self.relative_file_path}") + else: + print_success(f"\n✓ No changes needed for {self.relative_file_path}") + diff --git a/penify_hook/git_analyzer.py b/penify_hook/git_analyzer.py index d3d1333..9be6e60 100644 --- a/penify_hook/git_analyzer.py +++ b/penify_hook/git_analyzer.py @@ -2,76 +2,26 @@ import re from git import Repo from tqdm import tqdm + +from penify_hook.utils import get_repo_details, recursive_search_git_folder from .api_client import APIClient +import logging +from .ui_utils import ( + print_info, print_success, print_warning, print_error, + print_processing, print_status, create_progress_bar, + format_file_path +) + +# Set up logger +logger = logging.getLogger(__name__) class GitDocGenHook: def __init__(self, repo_path: str, api_client: APIClient): - self.repo_path = repo_path + self.repo_path = recursive_search_git_folder(repo_path) self.api_client = api_client - self.repo = Repo(repo_path) + self.repo = Repo(self.repo_path) self.supported_file_types = set(self.api_client.get_supported_file_types()) - self.repo_details = self.get_repo_details() - - def get_repo_details(self): - """Get the details of the repository, including the hosting service, - organization name, and repository name. - - This method checks the remote URL of the repository to determine whether - it is hosted on GitHub, Azure DevOps, Bitbucket, GitLab, or another - service. It extracts the organization (or user) name and the repository - name from the URL. If the hosting service cannot be determined, it will - return "Unknown Hosting Service". - - Returns: - dict: A dictionary containing the organization name, repository name, and - hosting service. - """ - remote_url = None - hosting_service = "Unknown" - org_name = None - repo_name = None - - try: - # Get the remote URL - remote = self.repo.remotes.origin.url - remote_url = remote - - # Determine the hosting service based on the URL - if "github.com" in remote: - hosting_service = "GITHUB" - match = re.match(r".*github\.com[:/](.*?)/(.*?)(\.git)?$", remote) - elif "dev.azure.com" in remote: - hosting_service = "AZUREDEVOPS" - match = re.match(r".*dev\.azure\.com/(.*?)/(.*?)/_git/(.*?)(\.git)?$", remote) - elif "visualstudio.com" in remote: - hosting_service = "AZUREDEVOPS" - match = re.match(r".*@(.*?)\.visualstudio\.com/(.*?)/_git/(.*?)(\.git)?$", remote) - elif "bitbucket.org" in remote: - hosting_service = "BITBUCKET" - match = re.match(r".*bitbucket\.org[:/](.*?)/(.*?)(\.git)?$", remote) - elif "gitlab.com" in remote: - hosting_service = "GITLAB" - match = re.match(r".*gitlab\.com[:/](.*?)/(.*?)(\.git)?$", remote) - else: - hosting_service = "Unknown Hosting Service" - match = None - - if match: - org_name = match.group(1) - repo_name = match.group(2) - - # For Azure DevOps, adjust the group indices - if hosting_service == "AZUREDEVOPS": - repo_name = match.group(3) - - except Exception as e: - print(f"Error determining repo details: {e}") - - return { - "organization_name": org_name, - "repo_name": repo_name, - "vendor": hosting_service - } + self.repo_details = get_repo_details(self.repo) def get_modified_files_in_last_commit(self): """Get the list of files modified in the last commit. @@ -164,13 +114,13 @@ def process_file(self, file_path): file_extension = os.path.splitext(file_path)[1].lower() if not file_extension: - print(f"File {file_path} has no extension. Skipping.") + logger.info(f"File {file_path} has no extension. Skipping.") return False file_extension = file_extension[1:] # Remove the leading dot if file_extension not in self.supported_file_types: - print(f"File type {file_extension} is not supported. Skipping {file_path}.") + logger.info(f"File type {file_extension} is not supported. Skipping {file_path}.") return False with open(file_abs_path, 'r') as file: @@ -184,7 +134,7 @@ def process_file(self, file_path): diff_text = self.repo.git.diff(prev_commit.hexsha, last_commit.hexsha, '--', file_path) if not diff_text: - print(f"No changes detected for {file_path}") + logger.info(f"No changes detected for {file_path}") return False modified_lines = self.get_modified_lines(diff_text) @@ -194,11 +144,12 @@ def process_file(self, file_path): return False if response == content: - print(f"No changes detected for {file_path}") + logger.info(f"No changes detected for {file_path}") return False # If the response is successful, replace the file content with open(file_abs_path, 'w') as file: file.write(response) + logger.info(f"Updated file {file_path} with generated documentation") return True def run(self): @@ -208,29 +159,41 @@ def run(self): and processes each file. It stages any files that have been modified during processing and creates an auto-commit if changes were made. A progress bar is displayed to indicate the processing status of each - file. The method handles any exceptions that occur during file + file. The method handles any exceptions that occur during file processing, printing an error message for each file that fails to process. If any modifications are made to the files, an auto-commit is created to save those changes. """ + logger.info("Starting doc_gen_hook processing") + print_info("Starting doc_gen_hook processing") + modified_files = self.get_modified_files_in_last_commit() changes_made = False total_files = len(modified_files) - with tqdm(total=total_files, desc="Processing files", unit="file", ncols=80, ascii=True) as pbar: + with create_progress_bar(total_files, "Processing files", "file") as pbar: for file in modified_files: + print_processing(file) + logging.info(f"Processing file: {file}") try: if self.process_file(file): # Stage the modified file self.repo.git.add(file) changes_made = True + print_status('success', "Documentation updated") + else: + print_status('warning', "No changes needed") except Exception as file_error: - print(f"Error processing file [{file}]: {file_error}") + error_msg = f"Error processing file [{file}]: {file_error}" + logger.error(error_msg) + print_status('error', error_msg) pbar.update(1) # Update the progress bar # If any file was modified, create a new commit if changes_made: # self.repo.git.commit('-m', 'Auto-commit: Updated files after doc_gen_hook processing.') - print("Auto-commit created with changes.") + logger.info("Auto-commit created with changes.") + print_success("\n✓ Auto-commit created with changes") else: - print("doc_gen_hook complete. No changes made.") \ No newline at end of file + logger.info("doc_gen_hook complete. No changes made.") + print_info("\n✓ doc_gen_hook complete. No changes made.") \ No newline at end of file diff --git a/penify_hook/main.py b/penify_hook/main.py index 1ce977d..8dcd62e 100644 --- a/penify_hook/main.py +++ b/penify_hook/main.py @@ -26,35 +26,51 @@ # API_URL = 'http://localhost:8000/api' def main(): - """Main entry point for the Penify CLI tool.""" + """Main entry point for the Penify CLI tool. + + This function serves as the main interface for the Penify command-line + tool, which provides various functionalities including AI commit message + generation, JIRA integration for enhancing commit messages, code + documentation generation, and Git hook installation for automatic + documentation generation. It sets up the command-line argument parser + with subcommands for basic and advanced operations. Basic commands do + not require user login, while advanced commands do. The function also + handles the parsing of arguments and the execution of the corresponding + commands based on user input. For more information about the tool and + its capabilities, please visit https://docs.penify.dev/. + """ # 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.") - - parser.add_argument("-t", "--token", help="API token for authentication.") + # Multi-line description using triple quotes + description = """Penify CLI tool for: +1. AI commit message generation +2. Using JIRA descriptions to enhance commit messages +3. Generating code documentation for code files +4. Installing Git hooks for automatic documentation generation +5. For more information, visit https://docs.penify.dev/ +""" + parser = argparse.ArgumentParser(description=description, formatter_class=argparse.RawDescriptionHelpFormatter) + + # Create subparsers for the main commands subparsers = parser.add_subparsers(title="subcommands", dest="subcommand") - - # Subcommand: install-hook - install_parser = subparsers.add_parser("install-hook", help="Install the Git post-commit hook.") - install_parser.add_argument("-l", "--location", required=True, help="Location in which to install the Git hook.") - - # Subcommand: uninstall-hook - uninstall_parser = subparsers.add_parser("uninstall-hook", help="Uninstall the Git post-commit hook.") - uninstall_parser.add_argument("-l", "--location", required=True, help="Location from which to uninstall the Git hook.") - - # Subcommand: doc-gen - doc_gen_parser = subparsers.add_parser("doc-gen", help="Generate documentation for specified files or folders.") - doc_gen_parser.add_argument("-fl", "--file_path", help="Path of the file to generate documentation.") - 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.", default=os.getcwd()) - + + # Group commands logically + basic_title = "Basic Commands (No login required)" + advanced_title = "Advanced Commands (Login required)" + + # Create grouped subparsers (visually separated in help output) + parser.add_argument_group(basic_title) + parser.add_argument_group(advanced_title) + + # ===== BASIC COMMANDS (No login required) ===== + # Subcommand: commit - commit_parser = subparsers.add_parser("commit", help="Commit with a message.") + commit_parser = subparsers.add_parser("commit", help="Generate smart commit messages (no login required).") commit_parser.add_argument("-gf", "--git_folder_path", help="Path to the folder with git.", default=os.getcwd()) - commit_parser.add_argument("-m", "--message", required=False, help="Commit message.", default="N/A") + commit_parser.add_argument("-m", "--message", required=False, help="Commit with contextual 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") @@ -65,54 +81,81 @@ def main(): 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") + # Subcommand: config + config_parser = subparsers.add_parser("config", help="Configure local settings (no login required).") + config_subparsers = config_parser.add_subparsers(title="config_type", dest="config_type") + + # Config subcommand: llm + llm_config_parser = config_subparsers.add_parser("llm", 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") - - # Add a new subcommand: config-llm-web - 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") + + # Config subcommand: llm-web + config_subparsers.add_parser("llm-web", help="Configure LLM settings through a web interface") + + # Config subcommand: jira + jira_config_parser = config_subparsers.add_parser("jira", 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 + config_subparsers.add_parser("jira-web", help="Configure JIRA settings through a web interface") + + # ===== ADVANCED COMMANDS (Login required) ===== + + # Subcommand: login (bridge between basic and advanced) + login_parser = subparsers.add_parser("login", help="Log in to Penify to use advanced features like documentation generation.") - # Add a new subcommand: config-jira-web - subparsers.add_parser("config-jira-web", help="Configure JIRA settings through a web interface") + docgen_description="""By default, 'docgen' generates documentation for the latest Git commit diff. Use the -l flag to document a specific file or folder. - # Subcommand: login - subparsers.add_parser("login", help="Log in to Penify and obtain an API token.") +The 'install-hook' command sets up a Git post-commit hook to auto-generate documentation after each commit. +""" + + # Advanced Subcommand: docgen + docgen_parser = subparsers.add_parser("docgen", help="[REQUIRES LOGIN] Generate code documentation for the file or folder", description=docgen_description, formatter_class=argparse.RawDescriptionHelpFormatter) + docgen_subparsers = docgen_parser.add_subparsers(title="docgen_subcommand", dest="docgen_subcommand") + + # Docgen main options (for direct documentation generation) + docgen_parser.add_argument("-l", "--location", help="[Optional] Path to the folder or file to Generate Documentation. By default it will pick the root directory.", default=None) + + # Subcommand: install-hook (as part of docgen) + install_hook_parser = docgen_subparsers.add_parser("install-hook", help="Install the Git post-commit hook.") + install_hook_parser.add_argument("-l", "--location", required=False, + help="Location in which to install the Git hook. Defaults to current directory.", + default=os.getcwd()) + + # Subcommand: uninstall-hook (as part of docgen) + uninstall_hook_parser = docgen_subparsers.add_parser("uninstall-hook", help="Uninstall the Git post-commit hook.") + uninstall_hook_parser.add_argument("-l", "--location", required=False, + help="Location from which to uninstall the Git hook. Defaults to current directory.", + default=os.getcwd()) args = parser.parse_args() # Get the token based on priority - token = get_token(args.token) + token = get_token() # Process commands - if args.subcommand == "install-hook": + if args.subcommand == "docgen": + # Check for login for all advanced commands if not token: - print("Error: API token is required.") + logging.error("Error: Unable to authenticate. Please run 'penifycli login'.") sys.exit(1) - install_git_hook(args.location, token) - - elif args.subcommand == "uninstall-hook": - uninstall_git_hook(args.location) - - elif args.subcommand == "doc-gen": - if not token: - print("Error: API token is required.") - sys.exit(1) - generate_doc(API_URL, token, args.file_path, args.complete_folder_path, args.git_folder_path) + + if args.docgen_subcommand == "install-hook": + install_git_hook(args.location, token) + + elif args.docgen_subcommand == "uninstall-hook": + uninstall_git_hook(args.location) + + else: # Direct documentation generation + generate_doc(API_URL, token, args.location) elif args.subcommand == "commit": - if not token: - print("Error: API token is required.") - sys.exit(1) - + # For commit, token is now optional - some functionality may be limited without it open_terminal = args.terminal.lower() == "true" # Get LLM configuration @@ -143,34 +186,39 @@ def main(): llm_model, llm_api_base, llm_api_key, jira_url, jira_user, jira_api_token) - elif args.subcommand == "config-llm": - 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": - config_llm_web() - - elif args.subcommand == "config-jira": - save_jira_config(args.url, args.username, args.api_token) - print(f"JIRA configuration set: URL={args.url}, Username={args.username}") + elif args.subcommand == "config": + # Config doesn't require token + if args.config_type == "llm": + 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'}") - # 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!") + elif args.config_type == "llm-web": + config_llm_web() + + elif args.config_type == "jira": + 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("Failed to connect to JIRA. Please check your credentials.") - else: - print("JIRA package not installed. Cannot verify connection.") - - elif args.subcommand == "config-jira-web": - config_jira_web() + print("JIRA package not installed. Cannot verify connection.") + + elif args.config_type == "jira-web": + config_jira_web() + + else: + config_parser.print_help() elif args.subcommand == "login": login(API_URL, DASHBOARD_URL) diff --git a/penify_hook/ui_utils.py b/penify_hook/ui_utils.py new file mode 100644 index 0000000..ebb2142 --- /dev/null +++ b/penify_hook/ui_utils.py @@ -0,0 +1,137 @@ +""" +UI utilities for Penify CLI. + +This module provides utility functions for consistent UI formatting, +colored output, and progress indicators across the Penify CLI application. +""" +import os +from colorama import Fore, Style, init +from tqdm import tqdm + +# Initialize colorama for cross-platform colored terminal output +init(autoreset=True) + +# Color constants for different message types +INFO_COLOR = Fore.CYAN +SUCCESS_COLOR = Fore.GREEN +WARNING_COLOR = Fore.YELLOW +ERROR_COLOR = Fore.RED +HIGHLIGHT_COLOR = Fore.BLUE +NEUTRAL_COLOR = Fore.WHITE + +# Status symbols +SUCCESS_SYMBOL = "✓" +WARNING_SYMBOL = "○" +ERROR_SYMBOL = "✗" +PROCESSING_SYMBOL = "⟳" + +def format_info(message): + """Format an informational message with appropriate color.""" + return f"{INFO_COLOR}{message}{Style.RESET_ALL}" + +def format_success(message): + """Format a success message with appropriate color.""" + return f"{SUCCESS_COLOR}{message}{Style.RESET_ALL}" + +def format_warning(message): + """Format a warning message with appropriate color.""" + return f"{WARNING_COLOR}{message}{Style.RESET_ALL}" + +def format_error(message): + """Format an error message with appropriate color.""" + return f"{ERROR_COLOR}{message}{Style.RESET_ALL}" + +def format_highlight(message): + """Format a highlighted message with appropriate color.""" + return f"{HIGHLIGHT_COLOR}{message}{Style.RESET_ALL}" + +def format_file_path(file_path): + """Format a file path with appropriate color.""" + return f"{WARNING_COLOR}{file_path}{Style.RESET_ALL}" + +def print_info(message): + """Print an informational message with appropriate formatting.""" + print(format_info(message)) + +def print_success(message): + """Print a success message with appropriate formatting.""" + print(format_success(message)) + +def print_warning(message): + """Print a warning message with appropriate formatting.""" + print(format_warning(message)) + +def print_error(message): + """Print an error message with appropriate formatting.""" + print(format_error(message)) + +def print_processing(file_path): + """Print a processing message for a file.""" + formatted_path = format_file_path(file_path) + print(f"\n{format_highlight(f'Processing file: {formatted_path}')}") + +def print_status(status, message): + """Print a status message with an appropriate symbol. + + Args: + status (str): One of 'success', 'warning', or 'error' + message (str): The message to print + """ + if status == 'success': + print(f" {SUCCESS_COLOR}{SUCCESS_SYMBOL} {message}{Style.RESET_ALL}") + elif status == 'warning': + print(f" {NEUTRAL_COLOR}{WARNING_SYMBOL} {message}{Style.RESET_ALL}") + elif status == 'error': + print(f" {ERROR_COLOR}{ERROR_SYMBOL} {message}{Style.RESET_ALL}") + else: + print(f" {PROCESSING_SYMBOL} {message}") + +def create_progress_bar(total, desc="Processing", unit="item"): + """Create a tqdm progress bar with consistent styling. + + Args: + total (int): Total number of items to process + desc (str): Description for the progress bar + unit (str): Unit label for the progress items + + Returns: + tqdm: A configured tqdm progress bar instance + """ + return tqdm( + total=total, + desc=format_info(desc), + unit=unit, + ncols=80, + ascii=True + ) + +def create_stage_progress_bar(stages, desc="Processing"): + """Create a tqdm progress bar for processing stages with consistent styling. + + Args: + stages (list): List of stage names + desc (str): Description for the progress bar + + Returns: + tuple: (tqdm progress bar, list of stages) + """ + pbar = tqdm( + total=len(stages), + desc=format_info(desc), + unit="step", + ncols=80, + ascii=True + ) + return pbar, stages + +def update_stage(pbar, stage_name): + """Update the progress bar with a new stage name. + + Args: + pbar (tqdm): The progress bar to update + stage_name (str): The name of the current stage + """ + # Force refresh with a custom description and ensure it's visible + pbar.set_postfix_str("") # Clear any existing postfix + pbar.set_description_str(f"{format_info(stage_name)}") + pbar.refresh() # Force refresh the display diff --git a/penify_hook/utils.py b/penify_hook/utils.py index f91a920..a8dbb0b 100644 --- a/penify_hook/utils.py +++ b/penify_hook/utils.py @@ -1,8 +1,98 @@ +import logging import os +import re + +from git import Repo +logger = logging.getLogger(__name__) + class GitRepoNotFoundError(Exception): pass + +def get_repo_details(repo: Repo): + """Get the details of the repository, including the hosting service, + organization name, and repository name. + + This method checks the remote URL of the repository to determine whether + it is hosted on GitHub, Azure DevOps, Bitbucket, GitLab, or another + service. It extracts the organization (or user) name and the repository + name from the URL. If the hosting service cannot be determined, it will + return "Unknown Hosting Service". + + Returns: + dict: A dictionary containing the organization name, repository name, and + hosting service. + """ + remote_url = None + hosting_service = "Unknown" + org_name = None + repo_name = None + + try: + # Get the remote URL + remote = repo.remotes.origin.url + remote_url = remote + + # Determine the hosting service based on the URL + if "github.com" in remote: + hosting_service = "GITHUB" + match = re.match(r".*github\.com[:/](.*?)/(.*?)(\.git)?$", remote) + elif "dev.azure.com" in remote: + hosting_service = "AZUREDEVOPS" + match = re.match(r".*dev\.azure\.com/(.*?)/(.*?)/_git/(.*?)(\.git)?$", remote) + elif "visualstudio.com" in remote: + hosting_service = "AZUREDEVOPS" + match = re.match(r".*@(.*?)\.visualstudio\.com/(.*?)/_git/(.*?)(\.git)?$", remote) + elif "bitbucket.org" in remote: + hosting_service = "BITBUCKET" + match = re.match(r".*bitbucket\.org[:/](.*?)/(.*?)(\.git)?$", remote) + elif "gitlab.com" in remote: + hosting_service = "GITLAB" + match = re.match(r".*gitlab\.com[:/](.*?)/(.*?)(\.git)?$", remote) + else: + hosting_service = "Unknown Hosting Service" + match = None + + if match: + org_name = match.group(1) + repo_name = match.group(2) + + # For Azure DevOps, adjust the group indices + if hosting_service == "AZUREDEVOPS": + repo_name = match.group(3) + + except Exception as e: + logger.error(f"Error determining GIT provider: {e}") + + return { + "organization_name": org_name, + "repo_name": repo_name, + "vendor": hosting_service + } + +def recursive_search_git_folder(folder_path): + """Recursively search for the .git folder in the specified directory and + return its parent directory. + + This function takes a folder path as input and checks if the specified + directory contains a '.git' folder. If it does, the function returns the + path of that directory. If not, it recursively searches the parent + directory until it finds a '.git' folder or reaches the root of the + filesystem. + + Args: + folder_path (str): The path of the directory to search for the .git folder. + + Returns: + str: The path of the directory containing the .git folder. + """ + if os.path.isdir(folder_path): + if '.git' in os.listdir(folder_path): + return folder_path + else: + return recursive_search_git_folder(os.path.dirname(folder_path)) + def find_git_parent(path): """Find the parent directory of a Git repository. diff --git a/setup.py b/setup.py index 0d9fcd9..219cd41 100644 --- a/setup.py +++ b/setup.py @@ -8,6 +8,7 @@ "requests", "tqdm", "GitPython", + "colorama", "litellm", "jira" # Add JIRA as a dependency ],