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 β‘
-## 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