Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
configs/templates.json
configs/config.ini
configs/config.ini
__pycache__/
*.pyc
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,32 @@ If you run just `gh` (or whatever alias you set) or `gh --help` you will see all
If you get `glab: 401 Unauthorized (HTTP 401)` when using GitHappens, you must repeat `glab auth login`
and then reopen your terminal.

## Project structure 🗂️

The CLI is organised into focused modules so each piece is easy to find, test
and extend:

```
├── main.py # Entry point and argument parsing
├── gitHappens.py # Backwards-compatible shim that delegates to main.py
├── config.py # Configuration loading (config.ini + templates.json)
├── gitlab_api.py # GitLab API interactions (HTTP + glab wrappers)
├── templates.py # Issue-template selection
├── git_utils.py # Local git operations
├── interactive.py # User prompts (inquirer)
├── project.py # Resolve current project id
└── commands/
├── create_issue.py # Issue + branch + MR creation
├── merge_request.py # MR lookups, reviewers, auto-merge
├── open_mr.py # Open the active MR in the browser
├── review.py # Review workflow + AI review hook
├── deploy.py # Last production deployment lookup
├── summary.py # Two-week commit summary (raw + AI)
└── incident.py # Incident report workflow
```

Existing aliases pointing at `gitHappens.py` keep working unchanged.

## Contributing 🫂🫶

Every contributor is welcome.
Expand Down
Empty file added commands/__init__.py
Empty file.
115 changes: 115 additions & 0 deletions commands/create_issue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Issue, branch and MR creation logic."""
import re

import config
import gitlab_api
import interactive


def execute_issue_create(project_id, title, labels, milestone_id, epic, iteration,
weight, estimated_time, issue_type='issue'):
labels = ",".join(labels) if isinstance(labels, list) else labels
assignee_id = gitlab_api.get_authorized_user()['id']

fields = [
('title', title),
('assignee_ids', assignee_id),
('issue_type', issue_type),
]
if labels:
fields.append(('labels', labels))
if weight:
fields.append(('weight', str(weight)))
if milestone_id:
fields.append(('milestone_id', str(milestone_id)))
if epic:
fields.append(('epic_id', str(epic['id'])))

description = ""
if iteration:
description += f"/iteration *iteration:{str(iteration['id'])} "
if estimated_time:
description += f"\n/estimate {estimated_time}m "
fields.append(('description', description))

return gitlab_api.create_issue_via_glab(project_id, fields)


def create_issue(title, project_id, milestone_id, epic, iteration, settings):
if not settings:
print("No settings in template")
exit(2)
issue_type = settings.get('type') or 'issue'
return execute_issue_create(
project_id, title, settings.get('labels'), milestone_id, epic, iteration,
settings.get('weight'), settings.get('estimated_time'), issue_type,
)


def create_branch(project_id, issue):
issue_id = str(issue['iid'])
title = re.sub('\\s+', '-', issue['title']).lower()
title = issue_id + '-' + title.replace(':', '').replace('(', ' ').replace(')', '').replace(' ', '-')
return gitlab_api.create_branch_via_glab(project_id, title, config.MAIN_BRANCH, issue_id)


def create_merge_request(project_id, branch, issue, labels, milestone_id):
issue_id = str(issue['iid'])
branch_name = branch['name']
title = issue['title']
assignee_id = gitlab_api.get_authorized_user()['id']
labels = ",".join(labels) if isinstance(labels, list) else labels

fields = [
('title', title),
('description', f'"Closes #{issue_id}"'),
('source_branch', branch_name),
('target_branch', config.MAIN_BRANCH),
('issue_iid', issue_id),
('assignee_ids', assignee_id),
]
if config.SQUASH_COMMITS:
fields.append(('squash', 'true'))
if config.DELETE_BRANCH:
fields.append(('remove_source_branch', 'true'))
if labels:
fields.append(('labels', labels))
if milestone_id:
fields.append(('milestone_id', str(milestone_id)))

return gitlab_api.create_merge_request_via_glab(project_id, fields)


def start_issue_creation(project_id, title, milestone, epic, iteration, selected_settings, only_issue):
estimated_time = interactive.prompt_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:
selected_settings = selected_settings.copy() if selected_settings else {}
selected_settings['estimated_time'] = int(estimated_time_per_project)

created_issue = create_issue(title, project_id, milestone, epic, iteration, selected_settings)
print(f"Issue #{created_issue['iid']}: {created_issue['title']} created.")

if only_issue:
return created_issue

created_branch = create_branch(project_id, created_issue)
created_mr = create_merge_request(
project_id, created_branch, created_issue,
selected_settings.get('labels'), milestone,
)
print(f"Merge request #{created_mr['iid']}: {created_mr['title']} created.")

print("Run:")
print(" git fetch origin")
print(f" git checkout -b '{created_mr['source_branch']}' 'origin/{created_mr['source_branch']}'")
print("to switch to new branch.")

return created_issue
100 changes: 100 additions & 0 deletions commands/deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Production deployment lookup."""
import datetime

import config
import git_utils
import gitlab_api
from project import get_project_id


def _resolve_ref():
if config.MAIN_BRANCH:
return config.MAIN_BRANCH
try:
return git_utils.get_main_branch()
except Exception:
return "main"


def get_last_production_deploy():
try:
project_id = get_project_id()
params = {
"per_page": 50,
"order_by": "updated_at",
"sort": "desc",
"ref": _resolve_ref(),
}
response = gitlab_api.list_pipelines(project_id, 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_response = gitlab_api.list_pipeline_jobs(project_id, pipeline['id'])
if detail_response.status_code != 200:
continue

jobs = detail_response.json()
for job in jobs:
job_name = job.get('name', '')
stage = job.get('stage', '')
job_status = job.get('status', '').lower()

if job_status != 'success':
continue

project_mapping = config.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("No production deployment found matching pattern")
return

pipeline = production_pipeline['pipeline']
job = production_pipeline['production_job']

print("🚀 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']}")

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:
print(f" ⏰ {time_diff.seconds // 3600} hours ago")
else:
print(f" ⏰ {time_diff.seconds // 60} minutes ago")
except Exception:
pass

except Exception as e:
print(f"Error fetching last production deploy: {str(e)}")
42 changes: 42 additions & 0 deletions commands/incident.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Incident report workflow."""
import config
import gitlab_api
import interactive
from commands.create_issue import create_issue


def process_report(text, minutes):
incident_project_id = config.get_incident_project_id()
if not incident_project_id:
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 = interactive.select_labels('Department')

incident_settings = {
'labels': ['incident', 'report'],
'onlyIssue': True,
'type': 'incident',
}
if selected_label:
incident_settings['labels'].append(selected_label)

try:
iteration = interactive.get_active_iteration()
created_issue = create_issue(
issue_title, incident_project_id, False, False, iteration, incident_settings,
)
issue_iid = created_issue['iid']

gitlab_api.close_issue(incident_project_id, issue_iid)
print(f"Incident issue #{issue_iid} created successfully.")
print(f"Title: {issue_title}")

if gitlab_api.add_spent_time(incident_project_id, issue_iid, minutes):
print(f"Added {minutes} minutes to issue time tracking.")

except Exception as e:
print(f"Error creating incident issue: {str(e)}")
49 changes: 49 additions & 0 deletions commands/merge_request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Merge request operations: lookups, reviewers, auto-merge."""
import git_utils
import gitlab_api
from project import get_project_id


def get_merge_request_for_branch(branch_name):
project_id = get_project_id()
merge_requests = gitlab_api.get_merge_requests(project_id, {"source_branch": branch_name})
if not merge_requests:
return None
for mr in merge_requests:
if mr["source_branch"] == branch_name:
return mr
return None


def find_merge_request_id_by_branch(branch_name):
return get_merge_request_for_branch(branch_name)['iid']


def get_active_merge_request_id():
return find_merge_request_id_by_branch(git_utils.get_current_branch())


def get_current_issue_id():
mr = get_merge_request_for_branch(git_utils.get_current_branch())
return mr['description'].replace('"', '').replace('#', '').split()[1]


def add_reviewers_to_merge_request(reviewers=None):
import config
project_id = get_project_id()
mr_id = get_active_merge_request_id()
data = {"reviewer_ids": reviewers if reviewers is not None else config.REVIEWERS}
gitlab_api.update_merge_request(project_id, mr_id, data)


def set_merge_request_to_auto_merge():
project_id = get_project_id()
mr_id = get_active_merge_request_id()
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",
}
gitlab_api.merge_merge_request(project_id, mr_id, data)
17 changes: 17 additions & 0 deletions commands/open_mr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Open the active merge request in the browser."""
import subprocess
import webbrowser

import config
import git_utils
from commands.merge_request import get_active_merge_request_id


def open_merge_request_in_browser():
try:
merge_request_id = get_active_merge_request_id()
remote_url = git_utils.get_remote_origin_url()
url = config.BASE_URL + '/' + remote_url.split(':')[1][:-4]
webbrowser.open(f"{url}/-/merge_requests/{merge_request_id}")
except subprocess.CalledProcessError:
return None
Loading