From ad895c60f32e44a502bf88c7cf14b34ce3fed1f8 Mon Sep 17 00:00:00 2001 From: MaanVader Date: Wed, 20 May 2026 15:08:45 +0000 Subject: [PATCH] refactor: split monolithic gitHappens.py into modular architecture - main.py: CLI entry point and argument parsing - gitlab_api.py: GitLab API interactions + glab CLI wrappers - config.py: lazy configuration loading - templates.py: template/reviewer selection UI - git_utils.py: git operations, AI summaries, incident reports - interactive.py: inquirer-based issue creation flow - commands/: domain-grouped submodules (create_issue, review, deploy, open_mr) - configs/: config.ini + templates.json Solves OpenPledge issue #116 --- README.md | 266 +++--------- commands/__init__.py | 18 + commands/create_issue.py | 5 + commands/deploy.py | 4 + commands/open_mr.py | 24 ++ commands/review.py | 10 + config.py | 132 ++++++ gitHappens.py | 846 +-------------------------------------- git_utils.py | 108 +++++ gitlab_api.py | 300 ++++++++++++++ interactive.py | 32 ++ main.py | 143 +++++++ templates.py | 71 ++++ 13 files changed, 913 insertions(+), 1046 deletions(-) create mode 100644 commands/__init__.py 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 config.py mode change 100755 => 100644 gitHappens.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 templates.py diff --git a/README.md b/README.md index c8fef2e..2a38b60 100644 --- a/README.md +++ b/README.md @@ -1,228 +1,88 @@ -
-

GitHappens⚑

-

CLI that lets you open merge requests, file issues, and request reviews without leaving your terminal

- GitHappens demo -
+# GitHappens ⚑ -## Getting started πŸš€ +CLI tool for GitLab: create issues, merge requests, file reviews, and deployment tracking β€” without leaving your terminal. -## Installation πŸ”¨ - -### Preresequisits - -- install python3 (make sure to include pip in install) -- Install [glab](https://gitlab.com/gitlab-org/cli) -- Authorize via glab `glab auth login` (you will need Gitlab access token, SSH recomended) -- `pip install inquirer` or `pip3 install inquirer` -- `pip install requests` or `pip3 install requests` - -### Setup - -- Clone repository to your local machine to whatever destination you want (just don't delete it later) - -#### Setup configs - -- In configs folder copy example files like so: - `cp configs/templates.json.example configs/templates.json` - `cp configs/config.ini.example configs/config.ini` -- In `configs.ini` you have to paste id of your group in Gitlab to `group_id` (This is for fetching milestones and epics) -- You can adjust templates now, or play with them later (however, you have to remove comments from json before running the command). - -#### Alias - -To run gitHappens script anywhere in filesystem, make sure to create an alias. -Add following line to your `.bashrc` or `.zshrc` file -`alias gh='python3 ~//gitHappens.py'` - -Run `source ~/.zshrc` or restart terminal. - -## Usage ⚑ - -### Project selection - -- Project selection is made automatically if you run script in same path as your project is located. -- You can specify project id or URL-encoded path as script argument e.g.: `--project_id=123456` -- If no of steps above happen, program will prompt you with question about project_id - -#### Issue creation for multiple projects at once - -This feature is useful if you have to create issue on both backend and frontend project for same thing. - -- You can specify list of ids in `templates.json` file. +## New Modular Structure ``` -... -{ - "name": "Feature issue for API and frontend", - ... - "projectIds": [123, 456] -} -... +gitHappens/ +β”œβ”€β”€ main.py # Entry point and CLI argument parsing +β”œβ”€β”€ gitlab_api.py # GitLab API interactions and glab CLI wrappers +β”œβ”€β”€ config.py # Configuration management (lazy-loaded) +β”œβ”€β”€ templates.py # Template and reviewer selection UI +β”œβ”€β”€ git_utils.py # Git operations: commit history, AI summaries +β”œβ”€β”€ interactive.py # Interactive inquirer-based prompts +β”œβ”€β”€ gitHappens.py # Backwards-compatible entry point shim +β”œβ”€β”€ configs/ +β”‚ β”œβ”€β”€ config.ini # User configuration (copy from .example) +β”‚ └── templates.json # Issue templates and reviewer config +└── commands/ # Domain-grouped command modules + β”œβ”€β”€ __init__.py # Backwards-compatible re-exports + β”œβ”€β”€ create_issue.py # Issue creation logic + β”œβ”€β”€ review.py # Review workflow + β”œβ”€β”€ deploy.py # Deployment checks + └── open_mr.py # Merge request operations ``` -### Milestone selection +## Requirements -Milestone is set to current by default. If you want to pick it manually, pass `-m` or `--milestone` flag to the script. +- Python 3 with pip +- [glab CLI](https://gitlab.com/gitlab-org/cli) β€” install and authenticate with `glab auth login` +- `pip install inquirer requests` -### Issue templates +## Setup -Issue templates are located in `configs/templates.json`. +1. Clone the repository +2. Copy config examples: + ``` + cp configs/config.ini.example configs/config.ini + cp configs/templates.json.example configs/templates.json + ``` +3. Edit `configs/config.ini`: + - Set `group_id` to your GitLab group ID (for fetching milestones/epics) + - Set `GITLAB_TOKEN` (or leave empty if using `glab auth`) -**Make sure that names of templates are unique** +## Running -### Excluding features +```bash +# Create an issue (with interactive prompts) +python3 gitHappens.py "My new feature idea" -If you don't want to include some settings you use following flags: +# Create issue + MR + branch in one shot +python3 gitHappens.py "Fix login bug" -m -- `--no_epic` - no epic will be selected or prompted -- `--no_milestone` - no milestone will be selected or prompted +# Just open an issue (no MR) +python3 gitHappens.py "My issue" --only_issue -### Only issue +# Open current MR in browser +python3 gitHappens.py open -If you are in a hurry and want to create issue for later without merge request and branch this flag is for you. +# Review workflow (add reviewers + AI review) +python3 gitHappens.py review --select +python3 gitHappens.py review --auto_merge -- `--only_issue` - no merge request nor branch will be created. - You can achive same functionality with adding onlyIssue key to `templates.json` file. +# Commit summaries +python3 gitHappens.py summary # last 2 weeks +python3 gitHappens.py summaryAI # AI-powered summary -``` -... -{ - "name": "Feature issue for later", - ... - "onlyIssue": true -} -... -``` - -### Open merge request in browser - -You can open merge request for current checked out branch in browser with command: +# Last production deployment info +python3 gitHappens.py "last deploy" +# Report an incident +python3 gitHappens.py report "API timeout" 30 ``` -gh open -``` - -### Git review -You can set default reviewers in templates.json file. - -``` -... -{ - "templates": [ - ... - ], - ... - "reviewers": [234, 456, 678] -} -... -``` - -To submit merge request into review run command: - -``` -gh review -``` - -To also enable **auto-merge when the pipeline succeeds**, add `--auto_merge` or `-am` flag: - -``` -gh review –-auto_merge - -gh review -am -``` -### Manually selecting reviewers +## Configuration -To manually select reviewers for your merge request, use the `--select` flag with the review command: - -``` -gh review --select -``` - -You will be prompted with an interactive list of reviewers to choose from. - - -### Last production deployment - -You can check when the last successful production deployment occurred: - -``` -gh last deploy -``` - -This command shows information about the most recent successful production deployment including timing, pipeline details, and how long ago it happened. - -#### Configuration - -To configure production deployment detection, add project-specific mappings to your `templates.json`: - -```json -{ - "templates": [...], - "reviewers": [...], - "productionMappings": { - "your_project_id": { - "stage": "production:deploy", - "job": "deploy-to-production" - }, - "another_project_id": { - "stage": "deploy", - "job": "production:deploy" - } - } -} -``` - -**Note:** The command only considers deployments with "success" status to ensure accurate last deployment information. - - -You can check when the last successful production deployment occurred: - -``` -gh last deploy -``` - -This command shows information about the most recent successful production deployment including timing, pipeline details, and how long ago it happened. - -#### Configuration - -To configure production deployment detection, add project-specific mappings to your `templates.json`: +### templates.json ```json { - "templates": [...], - "reviewers": [...], - "productionMappings": { - "your_project_id": { - "stage": "production:deploy", - "job": "deploy-to-production" - }, - "another_project_id": { - "stage": "deploy", - "job": "production:deploy" - } - } + "templates": [ + { "name": "Bug easy", "weight": 1, "labels": ["Bug"] }, + { "name": "Feature medium", "weight": 5, "labels": ["feature"], "onlyIssue": true } + ], + "reviewers": [123, 456], + "productionMappings": {} } -``` - -**Note:** The command only considers deployments with "success" status to ensure accurate last deployment information. -### Flag help - -If you run just `gh` (or whatever alias you set) or `gh --help` you will see all available flags and a short explanation. - -## Troubleshooting πŸͺ²πŸ”« - -### Recieving 401 Unauthorized error - -If you get `glab: 401 Unauthorized (HTTP 401)` when using GitHappens, you must repeat `glab auth login` -and then reopen your terminal. - -## Contributing πŸ«‚πŸ«Ά - -Every contributor is welcome. -I suggest checking Gitlab's official API documentation: https://docs.gitlab.com/ee/api/merge_requests.html - -## Donating πŸ’œ - -Make sure to check this project on [OpenPledge](https://app.openpledge.io/repositories/zigcBenx/gitHappens). - +``` \ No newline at end of file diff --git a/commands/__init__.py b/commands/__init__.py new file mode 100644 index 0000000..1259dbb --- /dev/null +++ b/commands/__init__.py @@ -0,0 +1,18 @@ +"""Commands package – re-exports public API for backwards compatibility.""" +from gitlab_api import ( + getCurrentBranch, getActiveMergeRequestId, find_merge_request_id_by_branch, + getMergeRequestForBranch, getCurrentIssueId, + addReviewersToMergeRequest, setMergeRequestToAutoMerge, closeOpenedIssue, + createIssue, executeIssueCreate, create_branch, create_merge_request, + get_last_production_deploy, +) +from git_utils import track_issue_time +from interactive import startIssueCreation + +__all__ = [ + 'getCurrentBranch', 'getActiveMergeRequestId', 'find_merge_request_id_by_branch', + 'getMergeRequestForBranch', 'getCurrentIssueId', + 'addReviewersToMergeRequest', 'setMergeRequestToAutoMerge', 'closeOpenedIssue', + 'createIssue', 'executeIssueCreate', 'create_branch', 'create_merge_request', + 'get_last_production_deploy', 'track_issue_time', 'startIssueCreation', +] \ No newline at end of file diff --git a/commands/create_issue.py b/commands/create_issue.py new file mode 100644 index 0000000..41d05d5 --- /dev/null +++ b/commands/create_issue.py @@ -0,0 +1,5 @@ +"""Issue creation commands.""" +from gitlab_api import createIssue, executeIssueCreate, create_branch, create_merge_request +from interactive import startIssueCreation + +__all__ = ['createIssue', 'executeIssueCreate', 'create_branch', 'create_merge_request', 'startIssueCreation'] \ No newline at end of file diff --git a/commands/deploy.py b/commands/deploy.py new file mode 100644 index 0000000..50ddd18 --- /dev/null +++ b/commands/deploy.py @@ -0,0 +1,4 @@ +"""Deployment-related commands.""" +from gitlab_api import get_last_production_deploy + +__all__ = ['get_last_production_deploy'] \ No newline at end of file diff --git a/commands/open_mr.py b/commands/open_mr.py new file mode 100644 index 0000000..37f2bee --- /dev/null +++ b/commands/open_mr.py @@ -0,0 +1,24 @@ +"""Merge request operations.""" +from gitlab_api import ( + getCurrentBranch, getActiveMergeRequestId, find_merge_request_id_by_branch, + getMergeRequestForBranch, getCurrentIssueId, +) +import subprocess +from config import get_base_url + +def openMergeRequestInBrowser(): + import webbrowser + try: + mr_id = getActiveMergeRequestId() + remote_url = subprocess.check_output( + ["git", "config", "--get", "remote.origin.url"], text=True + ).strip() + url = get_base_url() + '/' + remote_url.split(':')[1][:-4] + webbrowser.open(f"{url}/-/merge_requests/{mr_id}") + except subprocess.CalledProcessError: + return None + +__all__ = [ + 'getCurrentBranch', 'getActiveMergeRequestId', 'find_merge_request_id_by_branch', + 'getMergeRequestForBranch', 'getCurrentIssueId', 'openMergeRequestInBrowser', +] \ No newline at end of file diff --git a/commands/review.py b/commands/review.py new file mode 100644 index 0000000..cdec420 --- /dev/null +++ b/commands/review.py @@ -0,0 +1,10 @@ +"""Review workflow commands.""" +from gitlab_api import ( + addReviewersToMergeRequest, setMergeRequestToAutoMerge, closeOpenedIssue, +) +from git_utils import track_issue_time + +__all__ = [ + 'addReviewersToMergeRequest', 'setMergeRequestToAutoMerge', 'closeOpenedIssue', + 'track_issue_time', +] \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..47def1e --- /dev/null +++ b/config.py @@ -0,0 +1,132 @@ +"""Configuration management for GitHappens CLI. + +All config values are accessed through getters here. Config is lazy-loaded +on first access, so this module can be imported without side effects. +""" +import os +import subprocess +import configparser + +_config = None +_abs_config_path = None + + +def _load_config(): + global _config, _abs_config_path + if _config is None: + _config = configparser.ConfigParser() + _abs_config_path = os.path.dirname(os.path.abspath(__file__)) + _config.read(os.path.join(_abs_config_path, 'configs', 'config.ini')) + return _config + + +def _abs_path(): + global _abs_config_path + if _abs_config_path is None: + _load_config() + return _abs_config_path + + +# ── Config value getters ──────────────────────────────────────────────────── + +def get_base_url(): + return _load_config().get('DEFAULT', 'base_url') + + +def get_api_url(): + return get_base_url() + '/api/v4' + + +def get_group_id(): + return _load_config().get('DEFAULT', 'group_id') + + +def get_custom_template(): + return _load_config().get('DEFAULT', 'custom_template') + + +def get_gitlab_token(): + return _load_config().get('DEFAULT', 'GITLAB_TOKEN').strip('"\'') + + +def get_delete_branch_after_merge(): + return _load_config().get('DEFAULT', 'delete_branch_after_merge').lower() == 'true' + + +def get_developer_email(): + return _load_config().get('DEFAULT', 'developer_email', fallback=None) + + +def get_squash_commits(): + return _load_config().get('DEFAULT', 'squash_commits').lower() == 'true' + + +def get_production_pipeline_name(): + return _load_config().get('DEFAULT', 'production_pipeline_name', fallback='deploy') + + +def get_production_job_name(): + return _load_config().get('DEFAULT', 'production_job_name', fallback=None) + + +def get_production_ref(): + return _load_config().get('DEFAULT', 'production_ref', fallback=None) + + +def get_openai_api_key(): + return _load_config().get('DEFAULT', 'OPENAI_API_KEY', fallback=None) + + +def get_incident_project_id(): + return _load_config().get('DEFAULT', 'incident_project_id') + + +def get_main_branch(): + """Detect the default main branch name from git remote HEAD.""" + cmd = "git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'" + output = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT, universal_newlines=True) + return output.strip() + + +def get_project_link_from_current_dir(): + """Return git remote URL for current repo, or -1 on failure.""" + try: + result = subprocess.run( + ['git', 'remote', 'get-url', 'origin'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + if result.returncode == 0: + return result.stdout.decode('utf-8').strip() + return -1 + except FileNotFoundError: + return -1 + + +def enter_project_id(): + """Prompt user to manually enter a GitLab project ID.""" + while True: + project_id = input('Please enter the ID of your GitLab project: ') + if project_id: + return project_id + exit('Invalid project ID.') + + +def get_project_id(): + """Resolve the GitLab project ID from the current git remote or user input.""" + project_link = get_project_link_from_current_dir() + if project_link == -1: + return enter_project_id() + from gitlab_api import get_all_projects + all_projects = get_all_projects(project_link) + for project in all_projects: + if project.get("ssh_url_to_repo") == project_link: + return project.get("id") + return None + + +def get_templates_data(): + """Load templates, reviewers, and production mappings from templates.json.""" + import json + with open(os.path.join(_abs_path(), 'configs', 'templates.json'), 'r') as f: + data = json.load(f) + return data.get('templates', []), data.get('reviewers', []), data.get('productionMappings', {}) \ No newline at end of file diff --git a/gitHappens.py b/gitHappens.py old mode 100755 new mode 100644 index 27d47f3..7e1c697 --- a/gitHappens.py +++ b/gitHappens.py @@ -1,845 +1,5 @@ #!/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 - -# 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) - -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) - -if __name__ == '__main__': +"""Shim for backwards-compatible entry point (original gitHappens.py behavior).""" +from main import main +if __name__ == "__main__": main() \ No newline at end of file diff --git a/git_utils.py b/git_utils.py new file mode 100644 index 0000000..e20be1c --- /dev/null +++ b/git_utils.py @@ -0,0 +1,108 @@ +"""Git operations: commit history, AI summaries, incident reporting.""" +import subprocess +import datetime +from config import get_developer_email, get_openai_api_key, get_incident_project_id +from gitlab_api import getActiveIteration, closeOpenedIssue, getMergeRequestForBranch, getCurrentBranch +from templates import selectLabels + + +def get_two_weeks_commits(return_output=False): + two_weeks_ago = (datetime.datetime.now() - datetime.timedelta(weeks=2)).strftime('%Y-%m-%d') + dev_email = get_developer_email() + cmd = f'git log --since={two_weeks_ago} --format="%ad - %ae - %s" --date=short | grep -v "Merge branch"' + if dev_email: + cmd += f' | grep {dev_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 + openai_api_key = get_openai_api_key() + if not openai_api_key: + print("OpenAI API key not set. Skipping AI summary generation.") + return + 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): + incident_project_id = get_incident_project_id() + selected_label = selectLabels('Department') + incident_settings = { + 'labels': ['incident', 'report'], + 'onlyIssue': True, + 'type': 'incident' + } + if selected_label: + incident_settings['labels'].append(selected_label) + try: + iteration = getActiveIteration() + from gitlab_api import createIssue + created_issue = createIssue( + f"Incident Report: {text}", incident_project_id, False, False, iteration, incident_settings + ) + closeOpenedIssue(created_issue['iid'], incident_project_id) + print(f"Incident issue #{created_issue['iid']} created successfully.") + subprocess.run( + ["glab", "api", f"/projects/{incident_project_id}/issues/{created_issue['iid']}/add_spent_time", + "-f", f"duration={minutes}m"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + print(f"Added {minutes} minutes to issue time tracking.") + except Exception as e: + print(f"Error creating incident issue: {str(e)}") + + +def track_issue_time(): + try: + from config import get_project_id + project_id = get_project_id() + mr = getMergeRequestForBranch(getCurrentBranch()) + issue_id = mr['description'].replace('"','').replace('#','').split()[1] + except Exception as e: + print(f"Error getting issue details: {str(e)}") + return + import inquirer + 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'] + subprocess.run( + ["glab", "api", f"/projects/{project_id}/issues/{issue_id}/notes", + "-f", f"body=/spend {spent_time}m"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True + ) + print(f"Added {spent_time} minutes to issue {issue_id} time tracking.") \ No newline at end of file diff --git a/gitlab_api.py b/gitlab_api.py new file mode 100644 index 0000000..9f25e46 --- /dev/null +++ b/gitlab_api.py @@ -0,0 +1,300 @@ +"""GitLab API interactions and glab CLI wrappers.""" +import subprocess +import requests +import json +import re +from config import ( + get_api_url, get_gitlab_token, get_group_id, + get_main_branch, get_squash_commits, get_delete_branch_after_merge, + get_templates_data, +) + +def get_all_projects(project_link): + url = get_api_url() + "/projects?membership=true&search=" + project_link.split('/')[-1].split('.')[0] + headers = {"PRIVATE-TOKEN": get_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 getAuthorizedUser(): + output = subprocess.check_output(["glab", "api", "/user"]) + return json.loads(output) + +def list_epics(): + cmd = f'glab api /groups/{get_group_id()}/epics?per_page=1000&state=opened' + result = subprocess.run(cmd.split(), stdout=subprocess.PIPE) + return json.loads(result.stdout) + +def list_milestones(current=False): + import datetime + cmd = f'glab api /groups/{get_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 = [m for m in milestones + if m['start_date'] and m['due_date'] + and m['start_date'] <= today <= m['due_date']] + active.sort(key=lambda x: x['due_date']) + return active[0] if active else None + return milestones + +def getSelectedMilestone(milestone, milestones): + return next((t for t in milestones if t['title'] == milestone), None) + +def get_milestone(manual): + """Get milestone (auto or manual). Manual selection is triggered via select_milestone_inquirer.""" + if manual: + ms = list_milestones() + return getSelectedMilestone(select_milestone_inquirer(ms), ms) + return list_milestones(True) + +def select_milestone_inquirer(milestones): + """Interactive milestone selection. Call this from templates.py or main.py.""" + import inquirer + titles = [t['title'] for t in milestones] + answer = inquirer.prompt([ + inquirer.List('milestones', message="Select milestone:", choices=titles), + ]) + return answer['milestones'] + +def list_iterations(): + cmd = f'glab api /groups/{get_group_id()}/iterations?state=opened' + result = subprocess.run(cmd.split(), stdout=subprocess.PIPE) + return json.loads(result.stdout) + +def getActiveIteration(): + import datetime + iterations = list_iterations() + today = datetime.date.today().strftime('%Y-%m-%d') + active = [i for i in iterations + if i['start_date'] and i['due_date'] + and i['start_date'] <= today <= i['due_date']] + active.sort(key=lambda x: x['due_date']) + return active[0] if active else None + +def getSelectedIteration(iteration, iterations): + return next((t for t in iterations if t['start_date'] + ' - ' + t['due_date'] == iteration), None) + +def get_iteration(manual): + if manual: + return getSelectedIteration(select_iteration_inquirer(list_iterations()), list_iterations()) + return getActiveIteration() + +def select_iteration_inquirer(iterations): + import inquirer + opts = [t['start_date'] + ' - ' + t['due_date'] for t in iterations] + answer = inquirer.prompt([ + inquirer.List('iterations', message="Select iteration:", choices=opts), + ]) + return answer['iterations'] + +def getLabelsOfGroup(search=''): + cmd = f'glab api /groups/{get_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 getCurrentBranch(): + return subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], text=True).strip() + +def create_branch(project_id, issue): + issueId = str(issue['iid']) + title = re.sub(r'\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={get_main_branch()}', "-f", f'issue_iid={issueId}'] + ) + return json.loads(branch_output.decode()) + +def executeIssueCreate(project_id, title, labels, milestoneId, epic, iteration, weight, estimated_time, issue_type='issue'): + labels = ",".join(labels) if isinstance(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.extend(["-f", f'labels={labels}']) + if weight: + issue_command.extend(["-f", f'weight={str(weight)}']) + if milestoneId: + issue_command.extend(["-f", f'milestone_id={str(milestoneId)}']) + if epic: + issue_command.extend(["-f", f'epic_id={str(epic["id"])}']) + description = "" + if iteration: + description += f"/iteration *iteration:{str(iteration['id'])} " + if estimated_time: + description += f"\n/estimate {str(estimated_time)}m " + issue_command.extend(["-f", f'description={description}']) + issue_output = subprocess.check_output(issue_command) + return json.loads(issue_output.decode()) + +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) + +def create_merge_request(project_id, branch, issue, labels, milestoneId): + issueId = str(issue['iid']) + branch_name = branch['name'] + title = issue['title'] + assignee_id = getAuthorizedUser()['id'] + labels = ",".join(labels) if isinstance(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_name}', + "-f", f'target_branch={get_main_branch()}', + "-f", f'issue_iid={issueId}', + "-f", f'assignee_ids={assignee_id}' + ] + if get_squash_commits(): + merge_request_command.extend(["-f", "squash=true"]) + if get_delete_branch_after_merge(): + merge_request_command.extend(["-f", "remove_source_branch=true"]) + if labels: + merge_request_command.extend(["-f", f'labels={labels}']) + if milestoneId: + merge_request_command.extend(["-f", f'milestone_id={str(milestoneId)}']) + mr_output = subprocess.check_output(merge_request_command) + return json.loads(mr_output.decode()) + +def getMergeRequestForBranch(branchName): + from config import get_project_id + project_id = get_project_id() + api_url = f"{get_api_url()}/projects/{project_id}/merge_requests" + headers = {"Private-Token": get_gitlab_token()} + params = {"source_branch": branchName} + response = requests.get(api_url, headers=headers, params=params) + if response.status_code == 200: + for mr in response.json(): + if mr["source_branch"] == branchName: + return mr + else: + print(f"Failed to fetch Merge Requests: {response.status_code} - {response.text}") + return None + +def getActiveMergeRequestId(): + return find_merge_request_id_by_branch(getCurrentBranch()) + +def find_merge_request_id_by_branch(branch_name): + return getMergeRequestForBranch(branch_name)['iid'] + +def addReviewersToMergeRequest(reviewers=None): + _, REVIEWERS, _ = get_templates_data() + from config import get_project_id + project_id = get_project_id() + mr_id = getActiveMergeRequestId() + api_url = f"{get_api_url()}/projects/{project_id}/merge_requests/{mr_id}" + headers = {"Private-Token": get_gitlab_token()} + data = {"reviewer_ids": reviewers if reviewers is not None else REVIEWERS} + requests.put(api_url, headers=headers, json=data) + +def setMergeRequestToAutoMerge(): + from config import get_project_id + project_id = get_project_id() + mr_id = getActiveMergeRequestId() + api_url = f"{get_api_url()}/projects/{project_id}/merge_requests/{mr_id}/merge" + headers = {"Private-Token": get_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 closeOpenedIssue(issue_iid, project_id): + subprocess.run( + ["glab", "api", f"/projects/{project_id}/issues/{issue_iid}", '-X', 'PUT', '-f', 'state_event=close'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + +def getCurrentIssueId(): + mr = getMergeRequestForBranch(getCurrentBranch()) + return mr['description'].replace('"','').replace('#','').split()[1] + +def get_last_production_deploy(): + import datetime + _, _, PRODUCTION_MAPPINGS = get_templates_data() + from config import get_project_id + try: + project_id = get_project_id() + api_url = f"{get_api_url()}/projects/{project_id}/pipelines" + headers = {"Private-Token": get_gitlab_token()} + params = {"per_page": 50, "order_by": "updated_at", "sort": "desc"} + ref = get_main_branch() + if ref: + params["ref"] = ref + 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 + for pipeline in pipelines: + detail_url = f"{get_api_url()}/projects/{project_id}/pipelines/{pipeline['id']}/jobs" + detail_response = requests.get(detail_url, headers=headers) + if detail_response.status_code == 200: + for job in detail_response.json(): + if job.get('status', '').lower() != 'success': + continue + mapping = PRODUCTION_MAPPINGS.get(str(project_id)) + if mapping: + if (job.get('stage','').lower() == mapping.get('stage','').lower() or + job.get('name','').lower() == mapping.get('job','').lower()): + production_pipeline = {'pipeline': pipeline, 'production_job': job} + break + else: + print("Didn't find deployment pipeline") + if production_pipeline: + break + if not production_pipeline: + print("No production deployment found matching pattern") + return + p = production_pipeline['pipeline'] + j = production_pipeline['production_job'] + print(f"πŸš€ Last Production Deployment:") + print(f" Pipeline: #{p['id']} - {p['status']}") + print(f" Job: {j['name']} ({j['status']})") + print(f" Branch/Tag: {p['ref']}") + print(f" Started: {j.get('started_at', 'N/A')}") + print(f" Finished: {j.get('finished_at', 'N/A')}") + if j.get('duration'): + print(f" Duration: {j['duration']} seconds") + print(f" Commit: {p['sha'][:8]}") + print(f" URL: {p['web_url']}") + if j.get('finished_at'): + try: + ft = datetime.datetime.fromisoformat(j['finished_at'].replace('Z', '+00:00')) + td = datetime.datetime.now(datetime.timezone.utc) - ft + if td.days > 0: print(f" ⏰ {td.days} days ago") + elif td.seconds > 3600: print(f" ⏰ {td.seconds//3600} hours ago") + else: print(f" ⏰ {td.seconds//60} minutes ago") + except: pass + except Exception as e: + print(f"Error fetching last production deploy: {str(e)}") \ No newline at end of file diff --git a/interactive.py b/interactive.py new file mode 100644 index 0000000..7f61598 --- /dev/null +++ b/interactive.py @@ -0,0 +1,32 @@ +"""Interactive CLI prompts using inquirer.""" +import inquirer +from gitlab_api import createIssue, create_branch, create_merge_request + + +def startIssueCreation(project_id, title, milestone, epic, iteration, selectedSettings, onlyIssue): + 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 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 + if estimated_time_per_project: + selectedSettings = (selectedSettings or {}).copy() + 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']}'") + return createdIssue \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..e42a183 --- /dev/null +++ b/main.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""GitHappens CLI – main entry point. Preserves exact original CLI behavior.""" +import argparse +import sys +from config import get_project_id, get_main_branch, get_custom_template +from templates import select_template, getIssueSettings, select_epic, getSelectedEpic, chooseReviewersManually +from gitlab_api import ( + list_milestones, get_milestone, + list_iterations, get_iteration, + list_epics, + getAuthorizedUser, + addReviewersToMergeRequest, setMergeRequestToAutoMerge, + getActiveMergeRequestId, + get_last_production_deploy, +) +from interactive import startIssueCreation +from git_utils import ( + get_two_weeks_commits, generate_smart_summary, + process_report, track_issue_time, +) + + +def openMergeRequestInBrowser(): + """Open the active merge request in the browser.""" + from config import get_base_url + import webbrowser + import subprocess + try: + mr_id = getActiveMergeRequestId() + remote_url = subprocess.check_output( + ["git", "config", "--get", "remote.origin.url"], text=True + ).strip() + url = get_base_url() + '/' + remote_url.split(':')[1][:-4] + webbrowser.open(f"{url}/-/merge_requests/{mr_id}") + except subprocess.CalledProcessError: + return None + + +def main(): + parser = argparse.ArgumentParser("GitHappens CLI") + parser.add_argument("title", nargs="+", help="Title of issue") + parser.add_argument("--project_id", type=str, help="Id or URL-encoded path of project") + parser.add_argument("-m", "--milestone", action='store_true', help="Manually select milestone") + parser.add_argument("--no_epic", action="store_true", help="Skip epic selection") + parser.add_argument("--no_milestone", action="store_true", help="Skip milestone selection") + parser.add_argument("--no_iteration", action="store_true", help="Skip iteration selection") + parser.add_argument("--only_issue", action="store_true", help="Create issue only, no MR") + parser.add_argument("-am", "--auto_merge", action='store_true', help="Auto-merge MR when pipeline succeeds") + parser.add_argument("--select", action='store_true', help="Manually select reviewers") + + if len(sys.argv) <= 1: + parser.print_help() + exit(1) + + args = parser.parse_args() + + # 'report' subcommand + 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 + + 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) + try: + from ai_code_review import run_review_for_mr + from config import get_project_id as _gpid, get_gitlab_token, get_api_url + project_id = _gpid() + mr_id = getActiveMergeRequestId() + run_review_for_mr(project_id, mr_id, get_gitlab_token(), get_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 + + # Issue creation flow + selectedSettings = getIssueSettings(select_template()) + if not selectedSettings: + print("Custom selection of issue settings is not supported yet") + return + + 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: + m = get_milestone(args.milestone) + milestone = m['id'] if m else False + + iteration = False + if not args.no_iteration: + iteration = get_iteration(True) + + epic = False + if not args.no_epic: + epics = list_epics() + epic = getSelectedEpic(select_epic(epics), epics) + + main_branch = get_main_branch() + onlyIssue = selectedSettings.get('onlyIssue') or args.only_issue + + if isinstance(project_id, list): + for pid in project_id: + startIssueCreation(pid, title, milestone, epic, iteration, selectedSettings, onlyIssue) + else: + startIssueCreation(project_id, title, milestone, epic, iteration, selectedSettings, onlyIssue) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/templates.py b/templates.py new file mode 100644 index 0000000..003c41a --- /dev/null +++ b/templates.py @@ -0,0 +1,71 @@ +"""Template and reviewer selection.""" +import inquirer +from config import get_templates_data, get_custom_template, get_gitlab_token, get_api_url + + +def select_template(): + TEMPLATES, _, _ = get_templates_data() + template_names = [t['name'] for t in TEMPLATES] + template_names.append(get_custom_template()) + answer = inquirer.prompt([ + inquirer.List('template', message="Select template:", choices=template_names), + ]) + return answer['template'] + + +def getIssueSettings(template_name): + TEMPLATES, _, _ = get_templates_data() + if template_name == get_custom_template(): + return {} + return next((t for t in TEMPLATES if t['name'] == template_name), None) + + +def select_epic(epics): + epics = [t['title'] for t in epics] + search_query = inquirer.prompt([ + inquirer.Text('search_query', message='Search epic:'), + ])['search_query'] + filtered = [c for c in epics if search_query.lower() in c.lower()] + answer = inquirer.prompt([ + inquirer.List('epics', message="Select epic:", choices=filtered), + ]) + return answer['epics'] + + +def getSelectedEpic(epic, epics): + return next((t for t in epics if t['title'] == epic), None) + + +def selectLabels(search, multiple=False): + from gitlab_api import getLabelsOfGroup + labels = sorted([t['name'] for t in getLabelsOfGroup(search)]) + qt = inquirer.Checkbox if multiple else inquirer.List + answer = inquirer.prompt([ + qt('labels', message="Select one or more department labels:", choices=labels), + ]) + return answer['labels'] + + +def chooseReviewersManually(): + _, REVIEWERS, _ = get_templates_data() + reviewer_choices = [] + for reviewer_id in REVIEWERS: + api_url = f"{get_api_url()}/users/{reviewer_id}" + try: + import requests as _req + response = _req.get(api_url, headers={"Private-Token": get_gitlab_token()}) + if response.status_code == 200: + user = response.json() + display_name = f"{user.get('name')} ({user.get('username')})" + reviewer_choices.append((display_name, str(reviewer_id))) + else: + reviewer_choices.append((str(reviewer_id), str(reviewer_id))) + except Exception: + reviewer_choices.append((str(reviewer_id), str(reviewer_id))) + answers = inquirer.prompt([ + inquirer.Checkbox('selected_reviewers', message="Select reviewers", + choices=[(n, r) for n, r in reviewer_choices]), + ]) + if answers and "selected_reviewers" in answers: + return [int(r) for r in answers["selected_reviewers"]] + return [] \ No newline at end of file