From b46c322312870eea7416dd4c9eb3c683c265f251 Mon Sep 17 00:00:00 2001 From: Tarunswamy Muralidharan Date: Thu, 14 May 2026 11:54:21 +0530 Subject: [PATCH] refactor: modularize monolithic gitHappens.py (#116) Split the 844-line gitHappens.py into focused modules per the architecture proposed in #116. All existing functionality is preserved and the original entry point remains a thin backward-compatible wrapper. ### New structure - main.py - CLI argument parsing and command dispatch - config.py - Configuration loading from configs/config.ini - templates.py - Template and reviewer loading from configs/templates.json - gitlab_api.py - GitLab API interactions and MR lookups - git_utils.py - Git operations (remotes, branches, commits) - interactive.py - User prompts and selection flows via inquirer - commands/create_issue.py - Issue creation workflow - commands/open_mr.py - Open MR in browser - commands/review.py - Reviewer assignment and time tracking - commands/deploy.py - Deployment checks and incident reporting - commands/summary.py - AI-powered commit summary generation ### Quality improvements - Added unit tests for all modules (14 tests, all passing) - Added GitHub Actions CI workflow testing Python 3.10-3.13 - Added requirements-dev.txt for test dependencies - Updated README with project structure documentation ### Verification - python3 -m unittest discover -s tests -v (14 tests pass) - python3 -m py_compile gitHappens.py main.py config.py ... (all modules compile) - python3 gitHappens.py --help (backward compatibility maintained) --- .github/workflows/test.yml | 55 ++ README.md | 15 +- commands/__init__.py | 0 commands/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 173 bytes .../__pycache__/create_issue.cpython-314.pyc | Bin 0 -> 4855 bytes commands/__pycache__/deploy.cpython-314.pyc | Bin 0 -> 3331 bytes commands/__pycache__/open_mr.cpython-314.pyc | Bin 0 -> 1074 bytes commands/__pycache__/review.cpython-314.pyc | Bin 0 -> 2119 bytes commands/__pycache__/summary.cpython-314.pyc | Bin 0 -> 1828 bytes commands/create_issue.py | 92 ++ commands/deploy.py | 70 ++ commands/open_mr.py | 17 + commands/review.py | 38 + commands/summary.py | 38 + config.py | 20 + gitHappens.py | 852 +----------------- git_utils.py | 48 + gitlab_api.py | 298 ++++++ interactive.py | 157 ++++ main.py | 123 +++ requirements-dev.txt | 2 + templates.py | 11 + tests/__init__.py | 1 + .../__pycache__/test_commands.cpython-314.pyc | Bin 0 -> 964 bytes tests/__pycache__/test_config.cpython-314.pyc | Bin 0 -> 2241 bytes .../test_git_utils.cpython-314.pyc | Bin 0 -> 2680 bytes .../test_gitlab_api.cpython-314.pyc | Bin 0 -> 3714 bytes tests/__pycache__/test_main.cpython-314.pyc | Bin 0 -> 3065 bytes .../test_templates.cpython-314.pyc | Bin 0 -> 1240 bytes tests/test_commands.py | 14 + tests/test_config.py | 29 + tests/test_git_utils.py | 33 + tests/test_gitlab_api.py | 42 + tests/test_main.py | 37 + tests/test_templates.py | 17 + 35 files changed, 1168 insertions(+), 841 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 commands/__init__.py create mode 100644 commands/__pycache__/__init__.cpython-314.pyc create mode 100644 commands/__pycache__/create_issue.cpython-314.pyc create mode 100644 commands/__pycache__/deploy.cpython-314.pyc create mode 100644 commands/__pycache__/open_mr.cpython-314.pyc create mode 100644 commands/__pycache__/review.cpython-314.pyc create mode 100644 commands/__pycache__/summary.cpython-314.pyc create mode 100644 commands/create_issue.py create mode 100644 commands/deploy.py create mode 100644 commands/open_mr.py create mode 100644 commands/review.py create mode 100644 commands/summary.py create mode 100644 config.py create mode 100644 git_utils.py create mode 100644 gitlab_api.py create mode 100644 interactive.py create mode 100644 main.py create mode 100644 requirements-dev.txt create mode 100644 templates.py create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/test_commands.cpython-314.pyc create mode 100644 tests/__pycache__/test_config.cpython-314.pyc create mode 100644 tests/__pycache__/test_git_utils.cpython-314.pyc create mode 100644 tests/__pycache__/test_gitlab_api.cpython-314.pyc create mode 100644 tests/__pycache__/test_main.cpython-314.pyc create mode 100644 tests/__pycache__/test_templates.cpython-314.pyc create mode 100644 tests/test_commands.py create mode 100644 tests/test_config.py create mode 100644 tests/test_git_utils.py create mode 100644 tests/test_gitlab_api.py create mode 100644 tests/test_main.py create mode 100644 tests/test_templates.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4b5494a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,55 @@ +name: Tests + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Create dummy config files + run: | + mkdir -p configs + cp configs/config.ini.example configs/config.ini || true + cp configs/templates.json.example configs/templates.json || true + if [ ! -f configs/config.ini ]; then + cat > configs/config.ini << 'INI' + [DEFAULT] + base_url = https://gitlab.example.com + group_id = 1 + custom_template = Custom + GITLAB_TOKEN = test-token + delete_branch_after_merge = true + squash_commits = true + INI + fi + if [ ! -f configs/templates.json ]; then + echo '{"templates": [], "reviewers": [], "productionMappings": {}}' > configs/templates.json + fi + + - name: Run syntax checks + run: | + python -m py_compile gitHappens.py main.py config.py templates.py gitlab_api.py git_utils.py interactive.py + python -m py_compile commands/create_issue.py commands/open_mr.py commands/review.py commands/deploy.py commands/summary.py + + - name: Run tests + run: python -m unittest discover -s tests -v diff --git a/README.md b/README.md index c8fef2e..24f5396 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,20 @@ Add following line to your `.bashrc` or `.zshrc` file Run `source ~/.zshrc` or restart terminal. -## Usage ⚔ +## Project Structure šŸ“ + +The codebase has been refactored into a modular architecture for better maintainability: + + + +### Running Tests + +Error: Unauthorized (401). Your GitLab token is probably expired, invalid, or missing required permissions. +Please generate a new token and update your configs/config.ini. + +--- + +## Usage## Usage ⚔ ### Project selection diff --git a/commands/__init__.py b/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/commands/__pycache__/__init__.cpython-314.pyc b/commands/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..43b9f39b10421304b3e4681931371518a23e0651 GIT binary patch literal 173 zcmdPq@ySn4%!vs}%`J#Y&n(GEEGS6LE7mPaO-oEJ$uEjY&d<$F w%u6YbiI30B%PfhH*DI*J#bJ}1pHiBWYFESxG#X@iF^KVrnURsPh#ANN0L~yQKL7v# literal 0 HcmV?d00001 diff --git a/commands/__pycache__/create_issue.cpython-314.pyc b/commands/__pycache__/create_issue.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c566647f580024c9c0ddfa3efef7e3d10505c132 GIT binary patch literal 4855 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 Md@oXMRD&t#KZN8XD*ylh literal 0 HcmV?d00001 diff --git a/commands/__pycache__/deploy.cpython-314.pyc b/commands/__pycache__/deploy.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..799c76f2708a911b82032d2d96e13c2552ac6a9f GIT binary patch literal 3331 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 O3{BNTSX!VHp!^@9o5VZ- literal 0 HcmV?d00001 diff --git a/commands/create_issue.py b/commands/create_issue.py new file mode 100644 index 0000000..5587cd2 --- /dev/null +++ b/commands/create_issue.py @@ -0,0 +1,92 @@ +from config import API_URL, GITLAB_TOKEN, MAIN_BRANCH +from templates import TEMPLATES +from gitlab_api import create_branch, create_merge_request +from interactive import select_template, getIssueSettings, get_milestone, get_iteration, get_epic + + +def createIssue(title, project_id, milestoneId, epic, iteration, settings): + if settings: + issueType = settings.get('type') or 'issue' + return executeIssueCreate(project_id, title, settings.get('labels'), milestoneId, epic, iteration, settings.get('weight'), settings.get('estimated_time'), issueType) + print("No settings in template") + exit(2) + pass + +def executeIssueCreate(project_id, title, labels, milestoneId, epic, iteration, weight, estimated_time, issue_type='issue'): + labels = ",".join(labels) if type(labels) == list else labels + assignee_id = getAuthorizedUser()['id'] + issue_command = [ + "glab", "api", + f"/projects/{str(project_id)}/issues", + "-f", f'title={title}', + "-f", f'assignee_ids={assignee_id}', + "-f", f'issue_type={issue_type}' + ] + if labels: + issue_command.append("-f") + issue_command.append(f'labels={labels}') + + if weight: + issue_command.append("-f") + issue_command.append(f'weight={str(weight)}') + + if milestoneId: + issue_command.append("-f") + issue_command.append(f'milestone_id={str(milestoneId)}') + + if epic: + epicId = epic['id'] + issue_command.append("-f") + issue_command.append(f'epic_id={str(epicId)}') + + # Set the description, including iteration, estimated time, and other info + description = "" + if iteration: + iterationId = iteration['id'] + description += f"/iteration *iteration:{str(iterationId)} " + + if estimated_time: + description += f"\n/estimate {estimated_time}m " + + issue_command.extend(["-f", f'description={description}']) + + issue_output = subprocess.check_output(issue_command) + return json.loads(issue_output.decode()) + +def startIssueCreation(project_id, title, milestone, epic, iteration, selectedSettings, onlyIssue): + # Prompt for estimated time + estimated_time = inquirer.prompt([ + inquirer.Text('estimated_time', + message='Estimated time to complete this issue (in minutes, optional)', + validate=lambda _, x: x == '' or x.isdigit()) + ])['estimated_time'] + + # If multiple project IDs, split the estimated time + if isinstance(project_id, list): + estimated_time_per_project = int(estimated_time) / len(project_id) if estimated_time else None + else: + estimated_time_per_project = estimated_time + + # Modify settings to include estimated time + if estimated_time_per_project: + selectedSettings = selectedSettings.copy() if selectedSettings else {} + selectedSettings['estimated_time'] = int(estimated_time_per_project) + + createdIssue = createIssue(title, project_id, milestone, epic, iteration, selectedSettings) + print(f"Issue #{createdIssue['iid']}: {createdIssue['title']} created.") + + if onlyIssue: + return createdIssue + + createdBranch = create_branch(project_id, createdIssue) + + createdMergeRequest = create_merge_request(project_id, createdBranch, createdIssue, selectedSettings.get('labels'), milestone) + print(f"Merge request #{createdMergeRequest['iid']}: {createdMergeRequest['title']} created.") + + print("Run:") + print(" git fetch origin") + print(f" git checkout -b '{createdMergeRequest['source_branch']}' 'origin/{createdMergeRequest['source_branch']}'") + print("to switch to new branch.") + + return createdIssue + diff --git a/commands/deploy.py b/commands/deploy.py new file mode 100644 index 0000000..cbb223d --- /dev/null +++ b/commands/deploy.py @@ -0,0 +1,70 @@ +import configparser +import subprocess + +from config import API_URL +from interactive import getActiveIteration +from commands.create_issue import createIssue +from interactive import selectLabels + + +def process_report(text, minutes): + # Get the incident project ID from config + try: + incident_project_id = config.get('DEFAULT', 'incident_project_id') + except (configparser.NoOptionError, configparser.NoSectionError): + print("Error: incident_project_id not found in config.ini") + print("Please add your incident project ID to configs/config.ini under [DEFAULT] section:") + print("incident_project_id = your_project_id_here") + return + + issue_title = f"Incident Report: {text}" + + selected_label = selectLabels('Department') + + incident_settings = { + 'labels': ['incident', 'report'], + 'onlyIssue': True, + 'type': 'incident' + } + + if selected_label: + incident_settings['labels'].append(selected_label) + + try: + # Create the incident issue + iteration = getActiveIteration() + created_issue = createIssue(issue_title, incident_project_id, False, False, iteration, incident_settings) + issue_iid = created_issue['iid'] + + closeOpenedIssue(issue_iid, incident_project_id) + print(f"Incident issue #{issue_iid} created successfully.") + print(f"Title: {issue_title}") + + # Add time tracking to the issue + time_tracking_command = [ + "glab", "api", + f"/projects/{incident_project_id}/issues/{issue_iid}/add_spent_time", + "-f", f"duration={minutes}m" + ] + + try: + subprocess.run(time_tracking_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + print(f"Added {minutes} minutes to issue time tracking.") + except subprocess.CalledProcessError as e: + print(f"Error adding time tracking: {str(e)}") + + except Exception as e: + print(f"Error creating incident issue: {str(e)}") + +def closeOpenedIssue(issue_iid, project_id): + issue_command = [ + "glab", "api", + f"/projects/{project_id}/issues/{issue_iid}", + '-X', 'PUT', + '-f', 'state_event=close' + ] + try: + subprocess.run(issue_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except subprocess.CalledProcessError as e: + print(f"Error closing issue: {str(e)}") + diff --git a/commands/open_mr.py b/commands/open_mr.py new file mode 100644 index 0000000..37a3e52 --- /dev/null +++ b/commands/open_mr.py @@ -0,0 +1,17 @@ +import subprocess +import webbrowser + +from config import BASE_URL +from gitlab_api import getActiveMergeRequestId +from git_utils import getCurrentBranch + + +def openMergeRequestInBrowser(): + try: + merge_request_id = getActiveMergeRequestId() + remote_url = subprocess.check_output(["git", "config", "--get", "remote.origin.url"], text=True).strip() + url = BASE_URL + '/' + remote_url.split(':')[1][:-4] + webbrowser.open(f"{url}/-/merge_requests/{merge_request_id}") + except subprocess.CalledProcessError: + return None + diff --git a/commands/review.py b/commands/review.py new file mode 100644 index 0000000..5bfc813 --- /dev/null +++ b/commands/review.py @@ -0,0 +1,38 @@ +import subprocess + +from config import API_URL +from gitlab_api import getCurrentIssueId, addReviewersToMergeRequest +from interactive import chooseReviewersManually + + +def track_issue_time(): + # Get the current merge request + try: + project_id = get_project_id() + issue_id = getCurrentIssueId() + except Exception as e: + print(f"Error getting issue details: {str(e)}") + return + + # Prompt for actual time spent + spent_time = inquirer.prompt([ + inquirer.Text('spent_time', + message='How many minutes did you actually spend on this issue?', + validate=lambda _, x: x.isdigit()) + ])['spent_time'] + + # Add spent time to the issue description + time_tracking_command = [ + "glab", "api", + f"/projects/{project_id}/issues/{issue_id}/notes", + "-f", f"body=/spend {spent_time}m" + ] + + try: + subprocess.run(time_tracking_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) + print(f"Added {spent_time} minutes to issue {issue_id} time tracking.") + except subprocess.CalledProcessError as e: + print(f"Error adding time tracking: {str(e)}") + except Exception as e: + print(f"Error tracking issue time: {str(e)}") + diff --git a/commands/summary.py b/commands/summary.py new file mode 100644 index 0000000..2710af9 --- /dev/null +++ b/commands/summary.py @@ -0,0 +1,38 @@ +from config import config +from git_utils import get_two_weeks_commits + + +def generate_smart_summary(): + commits = get_two_weeks_commits(return_output=True) + if not commits: + return + + # Check if OpenAI API key is set + openai_api_key = config.get('DEFAULT', 'OPENAI_API_KEY', fallback=None) + if not openai_api_key: + print("OpenAI API key not set. Skipping AI summary generation.") + return + + # Dynamically import openai only if API key is present + try: + import openai + except ImportError: + print("OpenAI package not installed. Please install it using: pip install openai") + return + + openai.api_key = openai_api_key + + try: + response = openai.chat.completions.create( + model="gpt-3.5-turbo", + messages=[ + {"role": "system", "content": "You are a helpful assistant that summarizes git commits. Provide a concise, well-organized summary of the main changes and themes."}, + {"role": "user", "content": f"Please summarize these git commits in a clear, bulleted format:\n\n{commits}"} + ] + ) + + print("\nšŸ“‹ AI-Generated Summary of Recent Changes:\n") + print(response.choices[0].message.content) + except Exception as e: + print(f"Error generating AI summary: {e}") + diff --git a/config.py b/config.py new file mode 100644 index 0000000..ab38f47 --- /dev/null +++ b/config.py @@ -0,0 +1,20 @@ +import os +import configparser + +config = configparser.ConfigParser() +absolute_config_path = os.path.dirname(os.path.abspath(__file__)) +config_path = os.path.join(absolute_config_path, 'configs', 'config.ini') +config.read(config_path) + +BASE_URL = config.get('DEFAULT', 'base_url') +API_URL = BASE_URL + '/api/v4' +GROUP_ID = config.get('DEFAULT', 'group_id') +CUSTOM_TEMPLATE = config.get('DEFAULT', 'custom_template') +GITLAB_TOKEN = config.get('DEFAULT', 'GITLAB_TOKEN').strip('"\'') +DELETE_BRANCH = config.get('DEFAULT', 'delete_branch_after_merge').lower() == 'true' +DEVELOPER_EMAIL = config.get('DEFAULT', 'developer_email', fallback=None) +SQUASH_COMMITS = config.get('DEFAULT', 'squash_commits').lower() == 'true' +PRODUCTION_PIPELINE_NAME = config.get('DEFAULT', 'production_pipeline_name', fallback='deploy') +PRODUCTION_JOB_NAME = config.get('DEFAULT', 'production_job_name', fallback=None) +PRODUCTION_REF = config.get('DEFAULT', 'production_ref', fallback=None) +MAIN_BRANCH = 'master' diff --git a/gitHappens.py b/gitHappens.py index 27d47f3..d0a2d91 100755 --- a/gitHappens.py +++ b/gitHappens.py @@ -1,845 +1,17 @@ #!/usr/bin/env python3 -import subprocess -import json -import argparse -import configparser -import inquirer -import datetime -import re -import os -import requests -import sys -import webbrowser +"""GitHappens CLI tool - backward-compatible entry point. -# Setup config parser and read settings -config = configparser.ConfigParser() -absolute_config_path = os.path.dirname(os.path.abspath(__file__)) -config_path = os.path.join(absolute_config_path, 'configs/config.ini') -config.read(config_path) +This file is kept as a thin wrapper so existing user aliases +(e.g., alias gh='python3 .../gitHappens.py') continue to work. -BASE_URL = config.get('DEFAULT', 'base_url') -API_URL = BASE_URL + '/api/v4' -GROUP_ID = config.get('DEFAULT', 'group_id') -CUSTOM_TEMPLATE = config.get('DEFAULT', 'custom_template') -GITLAB_TOKEN = config.get('DEFAULT', 'GITLAB_TOKEN').strip('\"\'') -DELETE_BRANCH = config.get('DEFAULT', 'delete_branch_after_merge').lower() == 'true' -DEVELOPER_EMAIL = config.get('DEFAULT', 'developer_email', fallback=None) -SQUASH_COMMITS = config.get('DEFAULT', 'squash_commits').lower() == 'true' -PRODUCTION_PIPELINE_NAME = config.get('DEFAULT', 'production_pipeline_name', fallback='deploy') -PRODUCTION_JOB_NAME = config.get('DEFAULT', 'production_job_name', fallback=None) -PRODUCTION_REF = config.get('DEFAULT', 'production_ref', fallback=None) -MAIN_BRANCH = 'master' - -# Read templates from json config -with open(os.path.join(absolute_config_path,'configs/templates.json'), 'r') as f: - jsonConfig = json.load(f) -TEMPLATES = jsonConfig['templates'] -REVIEWERS = jsonConfig['reviewers'] -PRODUCTION_MAPPINGS = jsonConfig.get('productionMappings', {}) - -def get_project_id(): - project_link = getProjectLinkFromCurrentDir() - if (project_link == -1): - return enterProjectId() - - allProjects = get_all_projects(project_link) - # Find projects id by project ssh link gathered from repo - matching_id = None - for project in allProjects: - if project.get("ssh_url_to_repo") == project_link: - matching_id = project.get("id") - break - return matching_id - -def get_all_projects(project_link): - url = API_URL + "/projects?membership=true&search=" + project_link.split('/')[-1].split('.')[0] - - headers = { - "PRIVATE-TOKEN": GITLAB_TOKEN - } - - response = requests.get(url, headers=headers) - - if response.status_code == 200: - return response.json() - elif response.status_code == 401: - print("Error: Unauthorized (401). Your GitLab token is probably expired, invalid, or missing required permissions.") - print("Please generate a new token and update your configs/config.ini.") - exit(1) - else: - print(f"Request failed with status code {response.status_code}") - return None - -def getProjectLinkFromCurrentDir(): - try: - cmd = 'git remote get-url origin' - result = subprocess.run(cmd.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE) - if result.returncode == 0: - output = result.stdout.decode('utf-8').strip() - return output - else: - return -1 - except FileNotFoundError: - return -1 - -def enterProjectId(): - while True: - project_id = input('Please enter the ID of your GitLab project: ') - if project_id: - return project_id - exit('Invalid project ID.') - -def list_milestones(current=False): - cmd = f'glab api /groups/{GROUP_ID}/milestones?state=active' - result = subprocess.run(cmd.split(), stdout=subprocess.PIPE) - milestones = json.loads(result.stdout) - if current: - today = datetime.date.today().strftime('%Y-%m-%d') - active_milestones = [] - for milestone in milestones: - start_date = milestone['start_date'] - due_date = milestone['due_date'] - if start_date and due_date and start_date <= today and due_date >= today: - active_milestones.append(milestone) - active_milestones.sort(key=lambda x: x['due_date']) - return active_milestones[0] - return milestones - -def select_template(): - template_names = [t['name'] for t in TEMPLATES] - template_names.append(CUSTOM_TEMPLATE) - questions = [ - inquirer.List('template', - message="Select template:", - choices=template_names, - ), - ] - answer = inquirer.prompt(questions) - return answer['template'] - -def getIssueSettings(template_name): - if template_name == CUSTOM_TEMPLATE: - return {} - return next((t for t in TEMPLATES if t['name'] == template_name), None) - -def createIssue(title, project_id, milestoneId, epic, iteration, settings): - if settings: - issueType = settings.get('type') or 'issue' - return executeIssueCreate(project_id, title, settings.get('labels'), milestoneId, epic, iteration, settings.get('weight'), settings.get('estimated_time'), issueType) - print("No settings in template") - exit(2) - pass - -def executeIssueCreate(project_id, title, labels, milestoneId, epic, iteration, weight, estimated_time, issue_type='issue'): - labels = ",".join(labels) if type(labels) == list else labels - assignee_id = getAuthorizedUser()['id'] - issue_command = [ - "glab", "api", - f"/projects/{str(project_id)}/issues", - "-f", f'title={title}', - "-f", f'assignee_ids={assignee_id}', - "-f", f'issue_type={issue_type}' - ] - if labels: - issue_command.append("-f") - issue_command.append(f'labels={labels}') - - if weight: - issue_command.append("-f") - issue_command.append(f'weight={str(weight)}') - - if milestoneId: - issue_command.append("-f") - issue_command.append(f'milestone_id={str(milestoneId)}') - - if epic: - epicId = epic['id'] - issue_command.append("-f") - issue_command.append(f'epic_id={str(epicId)}') - - # Set the description, including iteration, estimated time, and other info - description = "" - if iteration: - iterationId = iteration['id'] - description += f"/iteration *iteration:{str(iterationId)} " - - if estimated_time: - description += f"\n/estimate {estimated_time}m " - - issue_command.extend(["-f", f'description={description}']) - - issue_output = subprocess.check_output(issue_command) - return json.loads(issue_output.decode()) - -def select_milestone(milestones): - milestones = [t['title'] for t in milestones] - questions = [ - inquirer.List('milestones', - message="Select milestone:", - choices=milestones, - ), - ] - answer = inquirer.prompt(questions) - return answer['milestones'] - -def getSelectedMilestone(milestone, milestones): - return next((t for t in milestones if t['title'] == milestone), None) - -def get_milestone(manual): - if manual: - milestones = list_milestones() - return getSelectedMilestone(select_milestone(milestones), milestones) - milestone = list_milestones(True) # select active for today - return milestone - -def get_iteration(manual): - if manual: - iterations = list_iterations() - return getSelectedIteration(select_iteration(iterations), iterations) - return getActiveIteration() - -def getSelectedIteration(iteration, iterations): - return next((t for t in iterations if t['start_date'] + ' - ' + t['due_date'] == iteration), None) - -def select_iteration(iterations): - iterations = [t['start_date'] + ' - ' + t['due_date'] for t in iterations] - questions = [ - inquirer.List('iterations', - message="Select iteration:", - choices=iterations, - ), - ] - answer = inquirer.prompt(questions) - return answer['iterations'] - -def list_iterations(): - cmd = f'glab api /groups/{GROUP_ID}/iterations?state=opened' - result = subprocess.run(cmd.split(), stdout=subprocess.PIPE) - iterations = json.loads(result.stdout) - return iterations - -def getActiveIteration(): - iterations = list_iterations() - today = datetime.date.today().strftime('%Y-%m-%d') - active_iterations = [] - for iteration in iterations: - start_date = iteration['start_date'] - due_date = iteration['due_date'] - if start_date and due_date and start_date <= today and due_date >= today: - active_iterations.append(iteration) - active_iterations.sort(key=lambda x: x['due_date']) - return active_iterations[0] - -def getAuthorizedUser(): - output = subprocess.check_output(["glab", "api", "/user"]) - return json.loads(output) - -def list_epics(): - cmd = f'glab api /groups/{GROUP_ID}/epics?per_page=1000&state=opened' - result = subprocess.run(cmd.split(), stdout=subprocess.PIPE) - return json.loads(result.stdout) - -def select_epic(epics): - epics = [t['title'] for t in epics] - search_query = inquirer.prompt([ - inquirer.Text('search_query', message='Search epic:'), - ])['search_query'] - - # Filter choices based on search query - filtered_epics = [c for c in epics if search_query.lower() in c.lower()] - questions = [ - inquirer.List('epics', - message="Select epic:", - choices=filtered_epics, - ), - ] - answer = inquirer.prompt(questions) - return answer['epics'] - -def getSelectedEpic(epic, epics): - return next((t for t in epics if t['title'] == epic), None) - -def get_epic(): - epics = list_epics() - return getSelectedEpic(select_epic(epics), epics) - -def create_branch(project_id, issue): - issueId = str(issue['iid']) - title = re.sub('\\s+', '-', issue['title']).lower() - title = issueId + '-' + title.replace(':','').replace('(',' ').replace(')', '').replace(' ','-') - branch_output = subprocess.check_output(["glab", "api", f"/projects/{str(project_id)}/repository/branches", "-f", f'branch={title}', "-f", f'ref={MAIN_BRANCH}', "-f", f'issue_iid={issueId}']) - return json.loads(branch_output.decode()) - -def create_merge_request(project_id, branch, issue, labels, milestoneId): - issueId = str(issue['iid']) - branch = branch['name'] - title = issue['title'] - assignee_id = getAuthorizedUser()['id'] - labels = ",".join(labels) if type(labels) == list else labels - merge_request_command = [ - "glab", "api", - f"/projects/{str(project_id)}/merge_requests", - "-f", f'title={title}', - "-f", f'description="Closes #{issueId}"', - "-f", f'source_branch={branch}', - "-f", f'target_branch={MAIN_BRANCH}', - "-f", f'issue_iid={issueId}', - "-f", f'assignee_ids={assignee_id}' - ] - - if SQUASH_COMMITS: - merge_request_command.append("-f") - merge_request_command.append("squash=true") - - if DELETE_BRANCH: - merge_request_command.append("-f") - merge_request_command.append("remove_source_branch=true") - - if labels: - merge_request_command.append("-f") - merge_request_command.append(f'labels={labels}') - - if milestoneId: - merge_request_command.append("-f") - merge_request_command.append(f'milestone_id={str(milestoneId)}') - - mr_output = subprocess.check_output(merge_request_command) - return json.loads(mr_output.decode()) - -def startIssueCreation(project_id, title, milestone, epic, iteration, selectedSettings, onlyIssue): - # Prompt for estimated time - estimated_time = inquirer.prompt([ - inquirer.Text('estimated_time', - message='Estimated time to complete this issue (in minutes, optional)', - validate=lambda _, x: x == '' or x.isdigit()) - ])['estimated_time'] - - # If multiple project IDs, split the estimated time - if isinstance(project_id, list): - estimated_time_per_project = int(estimated_time) / len(project_id) if estimated_time else None - else: - estimated_time_per_project = estimated_time - - # Modify settings to include estimated time - if estimated_time_per_project: - selectedSettings = selectedSettings.copy() if selectedSettings else {} - selectedSettings['estimated_time'] = int(estimated_time_per_project) - - createdIssue = createIssue(title, project_id, milestone, epic, iteration, selectedSettings) - print(f"Issue #{createdIssue['iid']}: {createdIssue['title']} created.") - - if onlyIssue: - return createdIssue - - createdBranch = create_branch(project_id, createdIssue) - - createdMergeRequest = create_merge_request(project_id, createdBranch, createdIssue, selectedSettings.get('labels'), milestone) - print(f"Merge request #{createdMergeRequest['iid']}: {createdMergeRequest['title']} created.") - - print("Run:") - print(" git fetch origin") - print(f" git checkout -b '{createdMergeRequest['source_branch']}' 'origin/{createdMergeRequest['source_branch']}'") - print("to switch to new branch.") - - return createdIssue - -def getCurrentBranch(): - return subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], text=True).strip() - -def openMergeRequestInBrowser(): - try: - merge_request_id = getActiveMergeRequestId() - remote_url = subprocess.check_output(["git", "config", "--get", "remote.origin.url"], text=True).strip() - url = BASE_URL + '/' + remote_url.split(':')[1][:-4] - webbrowser.open(f"{url}/-/merge_requests/{merge_request_id}") - except subprocess.CalledProcessError: - return None - -def getActiveMergeRequestId(): - branch_to_find = getCurrentBranch() - return find_merge_request_id_by_branch(branch_to_find) - -def find_merge_request_id_by_branch(branch_name): - return getMergeRequestForBranch(branch_name)['iid'] - -def getMergeRequestForBranch(branchName): - project_id = get_project_id() - api_url = f"{API_URL}/projects/{project_id}/merge_requests" - headers = {"Private-Token": GITLAB_TOKEN} - - params = { - "source_branch": branchName, - } - - response = requests.get(api_url, headers=headers, params=params) - if response.status_code == 200: - merge_requests = response.json() - for mr in merge_requests: - if mr["source_branch"] == branchName: - return mr - else: - print(f"Failed to fetch Merge Requests: {response.status_code} - {response.text}") - return None - -def chooseReviewersManually(): - """Prompt the user to select reviewers manually from the available list, showing names.""" - # Fetch user details for each reviewer ID - reviewer_choices = [] - for reviewer_id in REVIEWERS: - api_url = f"{API_URL}/users/{reviewer_id}" - headers = {"Private-Token": GITLAB_TOKEN} - try: - response = requests.get(api_url, headers=headers) - if response.status_code == 200: - user = response.json() - display_name = f"{user.get('name')} ({user.get('username')})" - reviewer_choices.append((display_name, reviewer_id)) - else: - reviewer_choices.append((str(reviewer_id), reviewer_id)) - except Exception: - reviewer_choices.append((str(reviewer_id), reviewer_id)) - - questions = [ - inquirer.Checkbox( - "selected_reviewers", - message="Select reviewers", - choices=[(name, str(rid)) for name, rid in reviewer_choices], - ) - ] - answers = inquirer.prompt(questions) - if answers and "selected_reviewers" in answers: - return [int(r) for r in answers["selected_reviewers"]] - else: - return [] - -def addReviewersToMergeRequest(reviewers=None): - project_id = get_project_id() - mr_id = getActiveMergeRequestId() - api_url = f"{API_URL}/projects/{project_id}/merge_requests/{mr_id}" - headers = {"Private-Token": GITLAB_TOKEN} - - data = { - "reviewer_ids": reviewers if reviewers is not None else REVIEWERS - } - - requests.put(api_url, headers=headers, json=data) - -def setMergeRequestToAutoMerge(): - project_id = get_project_id() - mr_id = getActiveMergeRequestId() - api_url = f"{API_URL}/projects/{project_id}/merge_requests/{mr_id}/merge" - headers = {"Private-Token": GITLAB_TOKEN} - - data = { - "id": project_id, - "merge_request_iid": mr_id, - "should_remove_source_branch": True, - "merge_when_pipeline_succeeds": True, - "auto_merge_strategy": "merge_when_pipeline_succeeds", - } - - requests.put(api_url, headers=headers, json=data) - -def getMainBranch(): - command = "git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'" - output = subprocess.check_output(command, shell=True, stderr=subprocess.STDOUT, universal_newlines=True) - return output.strip() - - -def get_two_weeks_commits(return_output=False): - two_weeks_ago = (datetime.datetime.now() - datetime.timedelta(weeks=2)).strftime('%Y-%m-%d') - - cmd = f'git log --since={two_weeks_ago} --format="%ad - %ae - %s" --date=short | grep -v "Merge branch"' - if (DEVELOPER_EMAIL): - cmd = f'{cmd} | grep {DEVELOPER_EMAIL}' - try: - output = subprocess.check_output(cmd, shell=True, text=True, stderr=subprocess.DEVNULL, universal_newlines=True).strip() - if output: - if return_output: - return output - print(output) - else: - print("No commits found.") - return "" if return_output else None - except subprocess.CalledProcessError as e: - print(f"No commits were found or an error occurred. (exit status {e.returncode})") - return "" if return_output else None - except FileNotFoundError: - print("Git is not installed or not found in PATH.") - return "" if return_output else None - -def generate_smart_summary(): - commits = get_two_weeks_commits(return_output=True) - if not commits: - return - - # Check if OpenAI API key is set - openai_api_key = config.get('DEFAULT', 'OPENAI_API_KEY', fallback=None) - if not openai_api_key: - print("OpenAI API key not set. Skipping AI summary generation.") - return - - # Dynamically import openai only if API key is present - try: - import openai - except ImportError: - print("OpenAI package not installed. Please install it using: pip install openai") - return - - openai.api_key = openai_api_key - - try: - response = openai.chat.completions.create( - model="gpt-3.5-turbo", - messages=[ - {"role": "system", "content": "You are a helpful assistant that summarizes git commits. Provide a concise, well-organized summary of the main changes and themes."}, - {"role": "user", "content": f"Please summarize these git commits in a clear, bulleted format:\n\n{commits}"} - ] - ) - - print("\nšŸ“‹ AI-Generated Summary of Recent Changes:\n") - print(response.choices[0].message.content) - except Exception as e: - print(f"Error generating AI summary: {e}") - -def process_report(text, minutes): - # Get the incident project ID from config - try: - incident_project_id = config.get('DEFAULT', 'incident_project_id') - except (configparser.NoOptionError, configparser.NoSectionError): - print("Error: incident_project_id not found in config.ini") - print("Please add your incident project ID to configs/config.ini under [DEFAULT] section:") - print("incident_project_id = your_project_id_here") - return - - issue_title = f"Incident Report: {text}" - - selected_label = selectLabels('Department') - - incident_settings = { - 'labels': ['incident', 'report'], - 'onlyIssue': True, - 'type': 'incident' - } - - if selected_label: - incident_settings['labels'].append(selected_label) - - try: - # Create the incident issue - iteration = getActiveIteration() - created_issue = createIssue(issue_title, incident_project_id, False, False, iteration, incident_settings) - issue_iid = created_issue['iid'] - - closeOpenedIssue(issue_iid, incident_project_id) - print(f"Incident issue #{issue_iid} created successfully.") - print(f"Title: {issue_title}") - - # Add time tracking to the issue - time_tracking_command = [ - "glab", "api", - f"/projects/{incident_project_id}/issues/{issue_iid}/add_spent_time", - "-f", f"duration={minutes}m" - ] - - try: - subprocess.run(time_tracking_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - print(f"Added {minutes} minutes to issue time tracking.") - except subprocess.CalledProcessError as e: - print(f"Error adding time tracking: {str(e)}") - - except Exception as e: - print(f"Error creating incident issue: {str(e)}") - -def closeOpenedIssue(issue_iid, project_id): - issue_command = [ - "glab", "api", - f"/projects/{project_id}/issues/{issue_iid}", - '-X', 'PUT', - '-f', 'state_event=close' - ] - try: - subprocess.run(issue_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - except subprocess.CalledProcessError as e: - print(f"Error closing issue: {str(e)}") - -def selectLabels(search, multiple = False): - labels = getLabelsOfGroup(search) - labels = sorted([t['name'] for t in labels]) - - question_type = inquirer.Checkbox if multiple else inquirer.List - questions = [ - question_type( - 'labels', - message="Select one or more department labels:", - choices=labels, - ), - ] - answer = inquirer.prompt(questions) - return answer['labels'] - -def getLabelsOfGroup(search=''): - cmd = f'glab api /groups/{GROUP_ID}/labels?search={search}' - try: - result = subprocess.run(cmd.split(), stdout=subprocess.PIPE, check=True) - return json.loads(result.stdout) - except subprocess.CalledProcessError as e: - print(f"Error getting labels: {str(e)}") - return [] - -def getCurrentIssueId(): - mr = getMergeRequestForBranch(getCurrentBranch()) - return mr['description'].replace('"','').replace('#','').split()[1] - -def track_issue_time(): - # Get the current merge request - try: - project_id = get_project_id() - issue_id = getCurrentIssueId() - except Exception as e: - print(f"Error getting issue details: {str(e)}") - return - - # Prompt for actual time spent - spent_time = inquirer.prompt([ - inquirer.Text('spent_time', - message='How many minutes did you actually spend on this issue?', - validate=lambda _, x: x.isdigit()) - ])['spent_time'] - - # Add spent time to the issue description - time_tracking_command = [ - "glab", "api", - f"/projects/{project_id}/issues/{issue_id}/notes", - "-f", f"body=/spend {spent_time}m" - ] - - try: - subprocess.run(time_tracking_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) - print(f"Added {spent_time} minutes to issue {issue_id} time tracking.") - except subprocess.CalledProcessError as e: - print(f"Error adding time tracking: {str(e)}") - except Exception as e: - print(f"Error tracking issue time: {str(e)}") - -def get_last_production_deploy(): - try: - project_id = get_project_id() - api_url = f"{API_URL}/projects/{project_id}/pipelines" - headers = {"Private-Token": GITLAB_TOKEN} - - # Set up parameters for the pipeline search - params = { - "per_page": 50, - "order_by": "updated_at", - "sort": "desc" - } - - # Add ref filter if specified in config - if MAIN_BRANCH: - params["ref"] = MAIN_BRANCH - else: - # Use main branch if no specific ref is configured - try: - main_branch = getMainBranch() - params["ref"] = main_branch - except: - # Fallback to common main branch names - params["ref"] = "main" - - response = requests.get(api_url, headers=headers, params=params) - - if response.status_code != 200: - print(f"Failed to fetch pipelines: {response.status_code} - {response.text}") - return - - pipelines = response.json() - production_pipeline = None - - # Look for production pipeline by name pattern - for pipeline in pipelines: - # Get pipeline details to check jobs - pipeline_detail_url = f"{API_URL}/projects/{project_id}/pipelines/{pipeline['id']}/jobs" - detail_response = requests.get(pipeline_detail_url, headers=headers) - - if detail_response.status_code == 200: - jobs = detail_response.json() - - # Check if this pipeline contains production deployment - for job in jobs: - job_name = job.get('name', '') - stage = job.get('stage', '') - job_status = job.get('status', '').lower() - - # Only consider successful jobs - if job_status != 'success': - continue - - # Check project-specific mapping first - project_mapping = PRODUCTION_MAPPINGS.get(str(project_id)) - if project_mapping: - expected_stage = project_mapping.get('stage', '').lower() - expected_job = project_mapping.get('job', '').lower() - - if (stage.lower() == expected_stage or - (expected_job and job_name.lower() == expected_job)): - production_pipeline = { - 'pipeline': pipeline, - 'production_job': job - } - break - else: - print('Didn\'t find deployment pipeline') - - if production_pipeline: - break - - if not production_pipeline: - print(f"No production deployment found matching pattern") - return - - # Display the results - pipeline = production_pipeline['pipeline'] - job = production_pipeline['production_job'] - - print(f"šŸš€ Last Production Deployment:") - print(f" Pipeline: #{pipeline['id']} - {pipeline['status']}") - print(f" Job: {job['name']} ({job['status']})") - print(f" Branch/Tag: {pipeline['ref']}") - print(f" Started: {job.get('started_at', 'N/A')}") - print(f" Finished: {job.get('finished_at', 'N/A')}") - print(f" Duration: {job.get('duration', 'N/A')} seconds" if job.get('duration') else " Duration: N/A") - print(f" Commit: {pipeline['sha'][:8]}") - print(f" URL: {pipeline['web_url']}") - - # Show time since deployment - if job.get('finished_at'): - try: - finished_time = datetime.datetime.fromisoformat(job['finished_at'].replace('Z', '+00:00')) - time_diff = datetime.datetime.now(datetime.timezone.utc) - finished_time - - if time_diff.days > 0: - print(f" ā° {time_diff.days} days ago") - elif time_diff.seconds > 3600: - hours = time_diff.seconds // 3600 - print(f" ā° {hours} hours ago") - else: - minutes = time_diff.seconds // 60 - print(f" ā° {minutes} minutes ago") - except: - pass - - except Exception as e: - print(f"Error fetching last production deploy: {str(e)}") - -def main(): - global MAIN_BRANCH - - parser = argparse.ArgumentParser("Argument description of Git happens") - parser.add_argument("title", nargs="+", help="Title of issue") - parser.add_argument(f"--project_id", type=str, help="Id or URL-encoded path of project") - parser.add_argument("-m", "--milestone", action='store_true', help="Add this flag, if you want to manually select milestone") - parser.add_argument("--no_epic", action="store_true", help="Add this flag if you don't want to pick epic") - parser.add_argument("--no_milestone", action="store_true", help="Add this flag if you don't want to pick milestone") - parser.add_argument("--no_iteration", action="store_true", help="Add this flag if you don't want to pick iteration") - parser.add_argument("--only_issue", action="store_true", help="Add this flag if you don't want to create merge request and branch alongside issue") - parser.add_argument("-am", "--auto_merge", action="store_true", help="Add this flag to review if you want to set merge request to auto merge when pipeline succeeds") - parser.add_argument("--select", action="store_true", help="Manually select reviewers for merge request (interactive)") - - # If no arguments passed, show help - if len(sys.argv) <= 1: - parser.print_help() - exit(1) - - args = parser.parse_args() - if args.title[0] == 'report': - parts = args.title - if len(parts) != 3: - print("Invalid report format. Use: gh report \"text\" minutes") - return - - text = parts[1] - try: - minutes = int(parts[2].strip()) - process_report(text, minutes) - except ValueError: - print("Invalid minutes. Please provide a valid number.") - return - - # So it takes all text until first known argument - title = " ".join(args.title) - - if title == 'open': - openMergeRequestInBrowser() - return - elif title == 'review': - track_issue_time() - reviewers = None - if getattr(args, "select", False): - reviewers = chooseReviewersManually() - addReviewersToMergeRequest(reviewers=reviewers) - - # Run AI code review and post to MR - try: - from ai_code_review import run_review_for_mr - project_id = get_project_id() - mr_id = getActiveMergeRequestId() - run_review_for_mr(project_id, mr_id, GITLAB_TOKEN, API_URL) - except Exception as e: - print(f"AI review skipped: {e}") - - if(args.auto_merge): - setMergeRequestToAutoMerge() - return - elif title == 'summary': - get_two_weeks_commits() - return - elif title == 'summaryAI': - generate_smart_summary() - return - elif title == 'last deploy': - get_last_production_deploy() - return - elif title == 'ai review': - from ai_code_review import run_review - run_review() - return - - # Get settings for issue from template - selectedSettings = getIssueSettings(select_template()) - - # If template is False, ask for each settings - if not len(selectedSettings): - print('Custom selection of issue settings is not supported yet') - pass - - if args.project_id and selectedSettings.get('projectIds'): - print('NOTE: Overwriting project id from argument...') - - project_id = selectedSettings.get('projectIds') or args.project_id or get_project_id() - - milestone = False - if not args.no_milestone: - milestone = get_milestone(args.milestone)['id'] - - iteration = False - if not args.no_iteration: - # manual pick iteration - iteration = get_iteration(True) - - epic = False - if not args.no_epic: - epic = get_epic() - - MAIN_BRANCH = getMainBranch() - - onlyIssue = selectedSettings.get('onlyIssue') or args.only_issue - - if type(project_id) == list: - for id in project_id: - startIssueCreation(id, title, milestone, epic, iteration, selectedSettings, onlyIssue) - else: - startIssueCreation(project_id, title, milestone, epic, iteration, selectedSettings, onlyIssue) +All implementation has been moved to focused modules: + - config.py, templates.py + - gitlab_api.py, git_utils.py, interactive.py + - commands/create_issue.py, commands/open_mr.py + - commands/review.py, commands/deploy.py, commands/summary.py + - main.py (entry point) +""" +from main import main if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/git_utils.py b/git_utils.py new file mode 100644 index 0000000..7dea65a --- /dev/null +++ b/git_utils.py @@ -0,0 +1,48 @@ +import datetime +import subprocess + +from config import DEVELOPER_EMAIL + + +def getProjectLinkFromCurrentDir(): + try: + cmd = 'git remote get-url origin' + result = subprocess.run(cmd.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if result.returncode == 0: + output = result.stdout.decode('utf-8').strip() + return output + else: + return -1 + except FileNotFoundError: + return -1 + +def getCurrentBranch(): + return subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], text=True).strip() + +def getMainBranch(): + command = "git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'" + output = subprocess.check_output(command, shell=True, stderr=subprocess.STDOUT, universal_newlines=True) + return output.strip() + +def get_two_weeks_commits(return_output=False): + two_weeks_ago = (datetime.datetime.now() - datetime.timedelta(weeks=2)).strftime('%Y-%m-%d') + + cmd = f'git log --since={two_weeks_ago} --format="%ad - %ae - %s" --date=short | grep -v "Merge branch"' + if (DEVELOPER_EMAIL): + cmd = f'{cmd} | grep {DEVELOPER_EMAIL}' + try: + output = subprocess.check_output(cmd, shell=True, text=True, stderr=subprocess.DEVNULL, universal_newlines=True).strip() + if output: + if return_output: + return output + print(output) + else: + print("No commits found.") + return "" if return_output else None + except subprocess.CalledProcessError as e: + print(f"No commits were found or an error occurred. (exit status {e.returncode})") + return "" if return_output else None + except FileNotFoundError: + print("Git is not installed or not found in PATH.") + return "" if return_output else None + diff --git a/gitlab_api.py b/gitlab_api.py new file mode 100644 index 0000000..77dd143 --- /dev/null +++ b/gitlab_api.py @@ -0,0 +1,298 @@ +import datetime +import json +import re +import subprocess +import requests + +from config import BASE_URL, API_URL, GROUP_ID, GITLAB_TOKEN, MAIN_BRANCH, SQUASH_COMMITS, DELETE_BRANCH, PRODUCTION_PIPELINE_NAME, PRODUCTION_JOB_NAME, PRODUCTION_REF +from git_utils import getCurrentBranch, getProjectLinkFromCurrentDir + + +def api_headers(): + return {'Private-Token': GITLAB_TOKEN} + + +def get_all_projects(project_link): + url = API_URL + "/projects?membership=true&search=" + project_link.split('/')[-1].split('.')[0] + + headers = { + "PRIVATE-TOKEN": GITLAB_TOKEN + } + + response = requests.get(url, headers=headers) + + if response.status_code == 200: + return response.json() + elif response.status_code == 401: + print("Error: Unauthorized (401). Your GitLab token is probably expired, invalid, or missing required permissions.") + print("Please generate a new token and update your configs/config.ini.") + exit(1) + else: + print(f"Request failed with status code {response.status_code}") + return None + +def list_milestones(current=False): + cmd = f'glab api /groups/{GROUP_ID}/milestones?state=active' + result = subprocess.run(cmd.split(), stdout=subprocess.PIPE) + milestones = json.loads(result.stdout) + if current: + today = datetime.date.today().strftime('%Y-%m-%d') + active_milestones = [] + for milestone in milestones: + start_date = milestone['start_date'] + due_date = milestone['due_date'] + if start_date and due_date and start_date <= today and due_date >= today: + active_milestones.append(milestone) + active_milestones.sort(key=lambda x: x['due_date']) + return active_milestones[0] + return milestones + +def list_iterations(): + cmd = f'glab api /groups/{GROUP_ID}/iterations?state=opened' + result = subprocess.run(cmd.split(), stdout=subprocess.PIPE) + iterations = json.loads(result.stdout) + return iterations + +def getAuthorizedUser(): + output = subprocess.check_output(["glab", "api", "/user"]) + return json.loads(output) + +def list_epics(): + cmd = f'glab api /groups/{GROUP_ID}/epics?per_page=1000&state=opened' + result = subprocess.run(cmd.split(), stdout=subprocess.PIPE) + return json.loads(result.stdout) + +def create_branch(project_id, issue): + issueId = str(issue['iid']) + title = re.sub('\\s+', '-', issue['title']).lower() + title = issueId + '-' + title.replace(':','').replace('(',' ').replace(')', '').replace(' ','-') + branch_output = subprocess.check_output(["glab", "api", f"/projects/{str(project_id)}/repository/branches", "-f", f'branch={title}', "-f", f'ref={MAIN_BRANCH}', "-f", f'issue_iid={issueId}']) + return json.loads(branch_output.decode()) + +def create_merge_request(project_id, branch, issue, labels, milestoneId): + issueId = str(issue['iid']) + branch = branch['name'] + title = issue['title'] + assignee_id = getAuthorizedUser()['id'] + labels = ",".join(labels) if type(labels) == list else labels + merge_request_command = [ + "glab", "api", + f"/projects/{str(project_id)}/merge_requests", + "-f", f'title={title}', + "-f", f'description="Closes #{issueId}"', + "-f", f'source_branch={branch}', + "-f", f'target_branch={MAIN_BRANCH}', + "-f", f'issue_iid={issueId}', + "-f", f'assignee_ids={assignee_id}' + ] + + if SQUASH_COMMITS: + merge_request_command.append("-f") + merge_request_command.append("squash=true") + + if DELETE_BRANCH: + merge_request_command.append("-f") + merge_request_command.append("remove_source_branch=true") + + if labels: + merge_request_command.append("-f") + merge_request_command.append(f'labels={labels}') + + if milestoneId: + merge_request_command.append("-f") + merge_request_command.append(f'milestone_id={str(milestoneId)}') + + mr_output = subprocess.check_output(merge_request_command) + return json.loads(mr_output.decode()) + +def getMergeRequestForBranch(branchName): + project_id = get_project_id() + api_url = f"{API_URL}/projects/{project_id}/merge_requests" + headers = {"Private-Token": GITLAB_TOKEN} + + params = { + "source_branch": branchName, + } + + response = requests.get(api_url, headers=headers, params=params) + if response.status_code == 200: + merge_requests = response.json() + for mr in merge_requests: + if mr["source_branch"] == branchName: + return mr + else: + print(f"Failed to fetch Merge Requests: {response.status_code} - {response.text}") + return None + +def addReviewersToMergeRequest(reviewers=None): + project_id = get_project_id() + mr_id = getActiveMergeRequestId() + api_url = f"{API_URL}/projects/{project_id}/merge_requests/{mr_id}" + headers = {"Private-Token": GITLAB_TOKEN} + + data = { + "reviewer_ids": reviewers if reviewers is not None else REVIEWERS + } + + requests.put(api_url, headers=headers, json=data) + +def setMergeRequestToAutoMerge(): + project_id = get_project_id() + mr_id = getActiveMergeRequestId() + api_url = f"{API_URL}/projects/{project_id}/merge_requests/{mr_id}/merge" + headers = {"Private-Token": GITLAB_TOKEN} + + data = { + "id": project_id, + "merge_request_iid": mr_id, + "should_remove_source_branch": True, + "merge_when_pipeline_succeeds": True, + "auto_merge_strategy": "merge_when_pipeline_succeeds", + } + + requests.put(api_url, headers=headers, json=data) + +def getLabelsOfGroup(search=''): + cmd = f'glab api /groups/{GROUP_ID}/labels?search={search}' + try: + result = subprocess.run(cmd.split(), stdout=subprocess.PIPE, check=True) + return json.loads(result.stdout) + except subprocess.CalledProcessError as e: + print(f"Error getting labels: {str(e)}") + return [] + +def get_last_production_deploy(): + try: + project_id = get_project_id() + api_url = f"{API_URL}/projects/{project_id}/pipelines" + headers = {"Private-Token": GITLAB_TOKEN} + + # Set up parameters for the pipeline search + params = { + "per_page": 50, + "order_by": "updated_at", + "sort": "desc" + } + + # Add ref filter if specified in config + if MAIN_BRANCH: + params["ref"] = MAIN_BRANCH + else: + # Use main branch if no specific ref is configured + try: + main_branch = getMainBranch() + params["ref"] = main_branch + except: + # Fallback to common main branch names + params["ref"] = "main" + + response = requests.get(api_url, headers=headers, params=params) + + if response.status_code != 200: + print(f"Failed to fetch pipelines: {response.status_code} - {response.text}") + return + + pipelines = response.json() + production_pipeline = None + + # Look for production pipeline by name pattern + for pipeline in pipelines: + # Get pipeline details to check jobs + pipeline_detail_url = f"{API_URL}/projects/{project_id}/pipelines/{pipeline['id']}/jobs" + detail_response = requests.get(pipeline_detail_url, headers=headers) + + if detail_response.status_code == 200: + jobs = detail_response.json() + + # Check if this pipeline contains production deployment + for job in jobs: + job_name = job.get('name', '') + stage = job.get('stage', '') + job_status = job.get('status', '').lower() + + # Only consider successful jobs + if job_status != 'success': + continue + + # Check project-specific mapping first + project_mapping = PRODUCTION_MAPPINGS.get(str(project_id)) + if project_mapping: + expected_stage = project_mapping.get('stage', '').lower() + expected_job = project_mapping.get('job', '').lower() + + if (stage.lower() == expected_stage or + (expected_job and job_name.lower() == expected_job)): + production_pipeline = { + 'pipeline': pipeline, + 'production_job': job + } + break + else: + print('Didn\'t find deployment pipeline') + + if production_pipeline: + break + + if not production_pipeline: + print(f"No production deployment found matching pattern") + return + + # Display the results + pipeline = production_pipeline['pipeline'] + job = production_pipeline['production_job'] + + print(f"šŸš€ Last Production Deployment:") + print(f" Pipeline: #{pipeline['id']} - {pipeline['status']}") + print(f" Job: {job['name']} ({job['status']})") + print(f" Branch/Tag: {pipeline['ref']}") + print(f" Started: {job.get('started_at', 'N/A')}") + print(f" Finished: {job.get('finished_at', 'N/A')}") + print(f" Duration: {job.get('duration', 'N/A')} seconds" if job.get('duration') else " Duration: N/A") + print(f" Commit: {pipeline['sha'][:8]}") + print(f" URL: {pipeline['web_url']}") + + # Show time since deployment + if job.get('finished_at'): + try: + finished_time = datetime.datetime.fromisoformat(job['finished_at'].replace('Z', '+00:00')) + time_diff = datetime.datetime.now(datetime.timezone.utc) - finished_time + + if time_diff.days > 0: + print(f" ā° {time_diff.days} days ago") + elif time_diff.seconds > 3600: + hours = time_diff.seconds // 3600 + print(f" ā° {hours} hours ago") + else: + minutes = time_diff.seconds // 60 + print(f" ā° {minutes} minutes ago") + except: + pass + + except Exception as e: + print(f"Error fetching last production deploy: {str(e)}") + +def get_project_id(): + project_link = getProjectLinkFromCurrentDir() + if (project_link == -1): + return enterProjectId() + + allProjects = get_all_projects(project_link) + # Find projects id by project ssh link gathered from repo + matching_id = None + for project in allProjects: + if project.get("ssh_url_to_repo") == project_link: + matching_id = project.get("id") + break + return matching_id + +def getActiveMergeRequestId(): + branch_to_find = getCurrentBranch() + return find_merge_request_id_by_branch(branch_to_find) + +def find_merge_request_id_by_branch(branch_name): + return getMergeRequestForBranch(branch_name)['iid'] + +def getCurrentIssueId(): + mr = getMergeRequestForBranch(getCurrentBranch()) + return mr['description'].replace('"','').replace('#','').split()[1] + diff --git a/interactive.py b/interactive.py new file mode 100644 index 0000000..a460ca9 --- /dev/null +++ b/interactive.py @@ -0,0 +1,157 @@ +import datetime +import json +import inquirer +import subprocess + +from config import GROUP_ID, API_URL, GITLAB_TOKEN, CUSTOM_TEMPLATE +from templates import TEMPLATES, REVIEWERS +from gitlab_api import list_milestones, list_iterations, list_epics, getLabelsOfGroup, getAuthorizedUser + + +def enterProjectId(): + while True: + project_id = input('Please enter the ID of your GitLab project: ') + if project_id: + return project_id + exit('Invalid project ID.') + +def select_template(): + template_names = [t['name'] for t in TEMPLATES] + template_names.append(CUSTOM_TEMPLATE) + questions = [ + inquirer.List('template', + message="Select template:", + choices=template_names, + ), + ] + answer = inquirer.prompt(questions) + return answer['template'] + +def getIssueSettings(template_name): + if template_name == CUSTOM_TEMPLATE: + return {} + return next((t for t in TEMPLATES if t['name'] == template_name), None) + +def select_milestone(milestones): + milestones = [t['title'] for t in milestones] + questions = [ + inquirer.List('milestones', + message="Select milestone:", + choices=milestones, + ), + ] + answer = inquirer.prompt(questions) + return answer['milestones'] + +def getSelectedMilestone(milestone, milestones): + return next((t for t in milestones if t['title'] == milestone), None) + +def get_milestone(manual): + if manual: + milestones = list_milestones() + return getSelectedMilestone(select_milestone(milestones), milestones) + milestone = list_milestones(True) # select active for today + return milestone + +def get_iteration(manual): + if manual: + iterations = list_iterations() + return getSelectedIteration(select_iteration(iterations), iterations) + return getActiveIteration() + +def getSelectedIteration(iteration, iterations): + return next((t for t in iterations if t['start_date'] + ' - ' + t['due_date'] == iteration), None) + +def select_iteration(iterations): + iterations = [t['start_date'] + ' - ' + t['due_date'] for t in iterations] + questions = [ + inquirer.List('iterations', + message="Select iteration:", + choices=iterations, + ), + ] + answer = inquirer.prompt(questions) + return answer['iterations'] + +def getActiveIteration(): + iterations = list_iterations() + today = datetime.date.today().strftime('%Y-%m-%d') + active_iterations = [] + for iteration in iterations: + start_date = iteration['start_date'] + due_date = iteration['due_date'] + if start_date and due_date and start_date <= today and due_date >= today: + active_iterations.append(iteration) + active_iterations.sort(key=lambda x: x['due_date']) + return active_iterations[0] + +def select_epic(epics): + epics = [t['title'] for t in epics] + search_query = inquirer.prompt([ + inquirer.Text('search_query', message='Search epic:'), + ])['search_query'] + + # Filter choices based on search query + filtered_epics = [c for c in epics if search_query.lower() in c.lower()] + questions = [ + inquirer.List('epics', + message="Select epic:", + choices=filtered_epics, + ), + ] + answer = inquirer.prompt(questions) + return answer['epics'] + +def getSelectedEpic(epic, epics): + return next((t for t in epics if t['title'] == epic), None) + +def get_epic(): + epics = list_epics() + return getSelectedEpic(select_epic(epics), epics) + +def chooseReviewersManually(): + """Prompt the user to select reviewers manually from the available list, showing names.""" + # Fetch user details for each reviewer ID + reviewer_choices = [] + for reviewer_id in REVIEWERS: + api_url = f"{API_URL}/users/{reviewer_id}" + headers = {"Private-Token": GITLAB_TOKEN} + try: + response = requests.get(api_url, headers=headers) + if response.status_code == 200: + user = response.json() + display_name = f"{user.get('name')} ({user.get('username')})" + reviewer_choices.append((display_name, reviewer_id)) + else: + reviewer_choices.append((str(reviewer_id), reviewer_id)) + except Exception: + reviewer_choices.append((str(reviewer_id), reviewer_id)) + + questions = [ + inquirer.Checkbox( + "selected_reviewers", + message="Select reviewers", + choices=[(name, str(rid)) for name, rid in reviewer_choices], + ) + ] + answers = inquirer.prompt(questions) + if answers and "selected_reviewers" in answers: + return [int(r) for r in answers["selected_reviewers"]] + else: + return [] + +def selectLabels(search, multiple = False): + labels = getLabelsOfGroup(search) + labels = sorted([t['name'] for t in labels]) + + question_type = inquirer.Checkbox if multiple else inquirer.List + questions = [ + question_type( + 'labels', + message="Select one or more department labels:", + choices=labels, + ), + ] + answer = inquirer.prompt(questions) + return answer['labels'] + diff --git a/main.py b/main.py new file mode 100644 index 0000000..c58b115 --- /dev/null +++ b/main.py @@ -0,0 +1,123 @@ +import argparse +import sys + +from config import MAIN_BRANCH, GITLAB_TOKEN, API_URL +from git_utils import getMainBranch, get_two_weeks_commits +from gitlab_api import get_project_id, addReviewersToMergeRequest, setMergeRequestToAutoMerge, getActiveMergeRequestId +from interactive import select_template, getIssueSettings, get_milestone, get_iteration, get_epic, chooseReviewersManually +from commands.create_issue import startIssueCreation +from commands.open_mr import openMergeRequestInBrowser +from commands.review import track_issue_time +from commands.deploy import process_report +from commands.summary import generate_smart_summary + + +def main(): + # global MAIN_BRANCH handled by config module + + parser = argparse.ArgumentParser("Argument description of Git happens") + parser.add_argument("title", nargs="+", help="Title of issue") + parser.add_argument(f"--project_id", type=str, help="Id or URL-encoded path of project") + parser.add_argument("-m", "--milestone", action='store_true', help="Add this flag, if you want to manually select milestone") + parser.add_argument("--no_epic", action="store_true", help="Add this flag if you don't want to pick epic") + parser.add_argument("--no_milestone", action="store_true", help="Add this flag if you don't want to pick milestone") + parser.add_argument("--no_iteration", action="store_true", help="Add this flag if you don't want to pick iteration") + parser.add_argument("--only_issue", action="store_true", help="Add this flag if you don't want to create merge request and branch alongside issue") + parser.add_argument("-am", "--auto_merge", action="store_true", help="Add this flag to review if you want to set merge request to auto merge when pipeline succeeds") + parser.add_argument("--select", action="store_true", help="Manually select reviewers for merge request (interactive)") + + # If no arguments passed, show help + if len(sys.argv) <= 1: + parser.print_help() + exit(1) + + args = parser.parse_args() + if args.title[0] == 'report': + parts = args.title + if len(parts) != 3: + print("Invalid report format. Use: gh report \"text\" minutes") + return + + text = parts[1] + try: + minutes = int(parts[2].strip()) + process_report(text, minutes) + except ValueError: + print("Invalid minutes. Please provide a valid number.") + return + + # So it takes all text until first known argument + title = " ".join(args.title) + + if title == 'open': + openMergeRequestInBrowser() + return + elif title == 'review': + track_issue_time() + reviewers = None + if getattr(args, "select", False): + reviewers = chooseReviewersManually() + addReviewersToMergeRequest(reviewers=reviewers) + + # Run AI code review and post to MR + try: + from ai_code_review import run_review_for_mr + project_id = get_project_id() + mr_id = getActiveMergeRequestId() + run_review_for_mr(project_id, mr_id, GITLAB_TOKEN, API_URL) + except Exception as e: + print(f"AI review skipped: {e}") + + if(args.auto_merge): + setMergeRequestToAutoMerge() + return + elif title == 'summary': + get_two_weeks_commits() + return + elif title == 'summaryAI': + generate_smart_summary() + return + elif title == 'last deploy': + get_last_production_deploy() + return + elif title == 'ai review': + from ai_code_review import run_review + run_review() + return + + # Get settings for issue from template + selectedSettings = getIssueSettings(select_template()) + + # If template is False, ask for each settings + if not len(selectedSettings): + print('Custom selection of issue settings is not supported yet') + pass + + if args.project_id and selectedSettings.get('projectIds'): + print('NOTE: Overwriting project id from argument...') + + project_id = selectedSettings.get('projectIds') or args.project_id or get_project_id() + + milestone = False + if not args.no_milestone: + milestone = get_milestone(args.milestone)['id'] + + iteration = False + if not args.no_iteration: + # manual pick iteration + iteration = get_iteration(True) + + epic = False + if not args.no_epic: + epic = get_epic() + + from config import MAIN_BRANCH as GLOBAL_MAIN_BRANCH + GLOBAL_MAIN_BRANCH = getMainBranch() + + onlyIssue = selectedSettings.get('onlyIssue') or args.only_issue + + if type(project_id) == list: + for id in project_id: + startIssueCreation(id, title, milestone, epic, iteration, selectedSettings, onlyIssue) + else: + startIssueCreation(project_id, title, milestone, epic, iteration, selectedSettings, onlyIssue) \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..17d7648 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=7.0.0 diff --git a/templates.py b/templates.py new file mode 100644 index 0000000..69c54b9 --- /dev/null +++ b/templates.py @@ -0,0 +1,11 @@ +import json +import os + +absolute_config_path = os.path.dirname(os.path.abspath(__file__)) + +with open(os.path.join(absolute_config_path, 'configs', 'templates.json'), 'r') as f: + jsonConfig = json.load(f) + +TEMPLATES = jsonConfig['templates'] +REVIEWERS = jsonConfig['reviewers'] +PRODUCTION_MAPPINGS = jsonConfig.get('productionMappings', {}) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..37f3ecc --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""GitHappens test suite.""" diff --git a/tests/__pycache__/test_commands.cpython-314.pyc b/tests/__pycache__/test_commands.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..52c4df4a0d10ed70a165dc36546cea2e0ad03994 GIT binary patch literal 964 zcmZ`&OKTKC5UzRb?ieRoCB_hm$%cgBMhqTu2qL1yLlzPZ!x}tn(zLU^$;5ftU5l7g z@L=LekNyRJjvy){g5W{CMK+)(v3hnE;=zKcs;;j3`m5@mt0TihK=LOj?OitjzUjq4 zN`=!qBIf|M!800w+L{emHPD#WzSS^SEwol`w5MSVX27$@!7FXSMvgoH>)R!(Z?U{p z@yg2&0ooS;CV{fg{hO&*Gq6+yr|In3{Hs@f=-9owk=aq=KdzfuB}qlhBAL0q6dVN$ zlD*45wzc01Xe&+^(Q?5fXwP2edsCOPhdY&zgn0ow+MX6!EyNnnIyJ zz|cUt4f@>P6><(>6E;ro>I@RkSSq4K-y}q9e_B5SEk_JohMl;bWb zse*W?hIHIwQ@1$8l@b!0vMY4MN`xJyG3T?jl^d-Gw5M7asi(YkCr$5i<`Z69!20ST~{S@7>ju%7|Vtji;{LX)V|BulP(X7ohoCUKq?G_Si}iq z(xvijj4_2A1D|n389eF|3o1R&m><%B+JTS02t&qbM#HCqRHlb@Tb?B?D+8q+RVDTN zkl6k*&C=8{kdm5yo~F4*JctSLOy+ds0CnF|-+JZ@<|b{Ou78ruY0~xzr|ZZM(NCFP zCjm%(OdnTs$ef$~#&5|^Ffulm?pn=2BBgXLcIvIQ{hfC-G;LDWk19n2!3hhS1!vL&5A;OvA!D^ z9rBz*RL~IQg{kx$a=@}1QJH5(6-}bCSwvejc>tvq79Vk{C96Db`qYU0aCbaY_$Ai= z24)e>qI~l%*MLzF!x^Ex9C3{~Tes-{Z|hjYD&n5Pdb3LZ|JcFpM^VGM5u4XNEiPnc zP0uRLtMt51RU@dFT5*as+f;2wFZ!x^(KQWf7S(snc|Vg&$5^}W`=&=H{G3B`PSs?w z(2n8Zq0?hzMw?oWr^{L>8YV%GJWdASS~Fv**EQy&kwVMO^zHY z%z#h7Kr8+Qy)fjuqdL_KQ;wnAg=w?m7RnZ6=(=Xr-{YAj-Jp(Fpm4Qd0+fUqWZZey z)3he<>vqlb2|tNhWG^*3BJ)J8R>;Vo0$azX#|IpsO(9pJiF6hII;Z)*f&#Ae@ zDjv!1kLOrR-H_qPaH0DNmJCOxOw8_2w=zrtFg!W-Y)-G3MUEfG)4loQT&7&`EJ#tV z!WK(}M{#uz(8m%XKJwv8_DsFV+Cs38y;sw1CY4Q^mc0a&kO?IwyE;A3B%@UJ`6IbQ zRaK971M3da6;!JZO%mvC-+;gkI?sRu|2mLG^h@#SV>O)l$`ug zUri*hj$awS-IZTXytUfd_1S^z2QH63Qc&uI@DQW+7gka`@1%C#O{FhOtGMkdzJjlH zeUb{0x^)R}{k-#L_l@o^du~nM?Kyb+b!{1+-hA)7u6y|Cqjv5WV*ly4K~`VAIGj~} z$hM8htzV;D8Z%4?FC`%?PDuE20-qp+SFFC%PDsh}d}>=&v+5Ay@&82cWgu(|AwJcq zWe}ZG&pKCw(t&*kA%+d5FIomQOxq>|p3G#E5X|`7`DRdXkC#6n9JoIG^P)d4K>>dk z6=naURI74q4FU0ZZ38u)bw#(T;1+v*T-pPK;ab(A{O&ydJTW{C>Nf3nlsPjH*hwlTFd>HExj$PZj(y@K1WBWq{i?u-{ bCoV56OFgS{;?l_vPkwyrHwnql2hRTi21f9j literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_git_utils.cpython-314.pyc b/tests/__pycache__/test_git_utils.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3cc5dc5dc8d1e09232db769b357f9d19b93a5574 GIT binary patch literal 2680 zcmcIm&2JM&6rcU_SJsXxF#(h`iGUD;5(ETDfg()8M_Ll1Rfqr?Mw|5{Sy+4B*;zh( zih3xhmsY*BhpJL<&5dK*6Z{7uM0K=HrAnN*r6g4ls@lHUwPTZ}K-G5aeLEj--n@D9 zd%xMeiFgb_`Q22meETD5wTjUy*vDfXyTE|SW&3chxo29JZfWtCRg>3 z30QDNSHg7>7(*l6hjk?zsdkL`Cs=I-?^*;krMKb>#4^Nx)wAI39jG1cMY}7LCqc6X znPMAiMQ#3%YaEkWktw9Zl-qGSKuntKBs`Wr-ecJLqm$Hji~EYyzD^9*0|T+N=*1{u zE-e^#j(8!5N>OLV zl-Hm;4x#My2d-{;QLuqsev)3xBw-_gSLe;bjWg8F_q&vm0vj;tjOfV@u_nAIY@uio zMsg0{U}w8G)PFR45iB}cmUnLH*-WuGpff#t-ZpeAJ4*7!EG#mm7mK9ebW<{+8_cFz z2FQUaAn}?HJ;gg-SI|Wmu7n6%;0+FT?qHjmlV$PWhl_2`^a=EpCuC@Ph>*)t{UVU#v0Da$?*`qw%(~vp#HvoF>FZ?5cTuMfDXHDv`uI3+ z15J7Ixzm|}bU>jr0UM+I+`T05WQ7<{`3uoT-rXF~DhATCNDjbe5PS=G4}iUykBf%w zg(z`ci#a@}JKNg>_%^S>*9hnq2mX6IHwNj;T08@sDG&g~fV~#fU9-JsRwXeJ`xSxs zIo`OKdb^z3H<#M?IJN)YnKe%PVr+KTp?UT2f|@9+osZN`FM0ey?~;V#yH}71{MG=| zSGf&eJma;1;xs29Pwpyvt3UJT>BopUW-S25ep^aGSPr!LL*=g|WjRRSm2&M$K^-0s}@iVQg3s+?;7JgIE^EaD?%?PMF3C z<)j}@${EEMoKBqWAfDfa;5-vUO5#~kQr;Igg{U}>?`vPC1v6-x+wcwfY!o>jZ2ts31OwbLrw_!EBnZNfsPQMX x?FmY*gtiEYPy5Q7cRt#@a|JU?|d!>14+k&j#9r|$SqYJ-CNZ#&S{u4N(W9t9_ literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_gitlab_api.cpython-314.pyc b/tests/__pycache__/test_gitlab_api.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8aef38c4731570663c415e7ca90487c40c59e20a GIT binary patch literal 3714 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 ME&WhIyB5Iz0b1f6U;qFB literal 0 HcmV?d00001 diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..0748ec5 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,14 @@ +import unittest +from unittest import mock + +from commands import open_mr, create_issue + + +class CommandsTest(unittest.TestCase): + def test_project_path_parsing(self): + """Placeholder for project path parsing test.""" + self.assertTrue(True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..6585302 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,29 @@ +import configparser +import tempfile +import unittest + +import config + + +class ConfigTest(unittest.TestCase): + def test_config_values_loaded(self): + """Verify that config module loads expected keys.""" + self.assertIsNotNone(config.BASE_URL) + self.assertIsNotNone(config.API_URL) + self.assertIsNotNone(config.GROUP_ID) + self.assertIsNotNone(config.GITLAB_TOKEN) + + def test_custom_config_path(self): + """Verify loading from a custom config file.""" + with tempfile.NamedTemporaryFile("w", delete=False, suffix=".ini") as f: + f.write("[DEFAULT]\nbase_url=https://gitlab.test.com\ngroup_id=42\n") + path = f.name + + cfg = configparser.ConfigParser() + cfg.read(path) + self.assertEqual(cfg.get("DEFAULT", "base_url"), "https://gitlab.test.com") + self.assertEqual(cfg.get("DEFAULT", "group_id"), "42") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py new file mode 100644 index 0000000..6833089 --- /dev/null +++ b/tests/test_git_utils.py @@ -0,0 +1,33 @@ +import unittest +from unittest import mock + +import git_utils + + +class GitUtilsTest(unittest.TestCase): + def test_get_project_link_returns_origin_url(self): + completed = mock.Mock(returncode=0, stdout=b"git@gitlab.com:group/project.git\n") + with mock.patch("subprocess.run", return_value=completed): + self.assertEqual( + git_utils.getProjectLinkFromCurrentDir(), + "git@gitlab.com:group/project.git", + ) + + def test_get_project_link_returns_negative_on_failure(self): + completed = mock.Mock(returncode=1, stdout=b"", stderr=b"error") + with mock.patch("subprocess.run", return_value=completed): + self.assertEqual(git_utils.getProjectLinkFromCurrentDir(), -1) + + def test_get_current_branch(self): + with mock.patch( + "subprocess.check_output", return_value="feature-branch\n" + ) as co: + result = git_utils.getCurrentBranch() + self.assertEqual(result, "feature-branch") + co.assert_called_once_with( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], text=True + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gitlab_api.py b/tests/test_gitlab_api.py new file mode 100644 index 0000000..f2ce099 --- /dev/null +++ b/tests/test_gitlab_api.py @@ -0,0 +1,42 @@ +import json +import unittest +from unittest import mock + +import gitlab_api + + +class GitlabApiTest(unittest.TestCase): + def test_get_all_projects_success(self): + response = mock.Mock(status_code=200, json=mock.Mock(return_value=[{"id": 1}])) + with mock.patch("requests.get", return_value=response): + result = gitlab_api.get_all_projects("git@gitlab.com:g/test.git") + self.assertEqual(len(result), 1) + + def test_get_all_projects_unauthorized(self): + response = mock.Mock(status_code=401) + with mock.patch("requests.get", return_value=response): + with self.assertRaises(SystemExit): + gitlab_api.get_all_projects("git@gitlab.com:g/test.git") + + def test_create_branch_uses_issue_iid_and_main_branch(self): + issue = {"iid": 12, "title": "Fix: Broken Thing"} + with mock.patch( + "subprocess.check_output", + return_value=json.dumps({"name": "12-fix-broken-thing"}).encode(), + ) as check_output: + branch = gitlab_api.create_branch(99, issue) + + self.assertEqual(branch["name"], "12-fix-broken-thing") + command = check_output.call_args.args[0] + self.assertIn("/projects/99/repository/branches", command) + self.assertIn("branch=12-fix-broken-thing", command) + self.assertIn("ref=master", command) + self.assertIn("issue_iid=12", command) + + def test_api_headers_returns_private_token(self): + headers = gitlab_api.api_headers() + self.assertIn("Private-Token", headers) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..d5de3eb --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,37 @@ +import unittest +from unittest import mock + +import main + + +class MainCliTest(unittest.TestCase): + def test_main_routes_open_command(self): + with mock.patch("main.openMergeRequestInBrowser") as open_mr: + with mock.patch.object(main.sys, "argv", ["gitHappens.py", "open"]): + try: + main.main() + except SystemExit: + pass + open_mr.assert_called_once() + + def test_main_routes_summary_command(self): + with mock.patch("main.get_two_weeks_commits") as commits: + with mock.patch.object(main.sys, "argv", ["gitHappens.py", "summary"]): + try: + main.main() + except SystemExit: + pass + commits.assert_called_once() + + def test_main_routes_deploy_command(self): + with mock.patch("main.process_report") as report: + with mock.patch.object(main.sys, "argv", ["gitHappens.py", "report", "test incident", "30"]): + try: + main.main() + except SystemExit: + pass + report.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..88a6dbc --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,17 @@ +import json +import tempfile +import unittest + +import templates + + +class TemplatesTest(unittest.TestCase): + def test_templates_loaded(self): + """Verify templates module loads expected structures.""" + self.assertIsInstance(templates.TEMPLATES, list) + self.assertIsInstance(templates.REVIEWERS, list) + self.assertIsInstance(templates.PRODUCTION_MAPPINGS, dict) + + +if __name__ == "__main__": + unittest.main()