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
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,37 @@

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 ~/<path-to-githappens-project>/gitHappens.py'`
`alias gh='python3 ~/<path-to-githappens-project>/main.py'`

Existing aliases that point to `gitHappens.py` continue to work because that file now delegates to `main.py`.

Run `source ~/.zshrc` or restart terminal.

## Project structure

The CLI is split into small modules by responsibility:

```text
main.py # Entry point and argument parsing
gitlab_api.py # GitLab API and glab command interactions
config.py # Configuration and template file loading
templates.py # Template lookup helpers
git_utils.py # Git command helpers and project detection
interactive.py # User prompts and CLI interactions
gitHappens.py # Backward-compatible launcher
commands/
create_issue.py # Issue creation and incident report workflow
review.py # Review workflow and time tracking
deploy.py # Production deployment checks
open_mr.py # Merge request browser opening
```

Run the unit tests with:

```bash
python3 -B -m unittest discover -s tests
```

## Usage ⚡

### Project selection
Expand Down Expand Up @@ -225,4 +252,3 @@ I suggest checking Gitlab's official API documentation: https://docs.gitlab.com/
## Donating 💜

Make sure to check this project on [OpenPledge](https://app.openpledge.io/repositories/zigcBenx/gitHappens).

1 change: 1 addition & 0 deletions commands/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Command orchestration modules for GitHappens."""
136 changes: 136 additions & 0 deletions commands/create_issue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import configparser

import gitlab_api
import interactive
from config import CONFIG_PARSER, INCIDENT_PROJECT_ID


def get_selected_milestone(milestone, milestones):
return next((item for item in milestones if item["title"] == milestone), None)


def get_milestone(manual):
if manual:
milestones = gitlab_api.list_milestones()
return get_selected_milestone(interactive.select_milestone(milestones), milestones)
return gitlab_api.list_milestones(True)


def get_selected_iteration(iteration, iterations):
return next((item for item in iterations if f"{item['start_date']} - {item['due_date']}" == iteration), None)


def get_iteration(manual):
if manual:
iterations = gitlab_api.list_iterations()
return get_selected_iteration(interactive.select_iteration(iterations), iterations)
return gitlab_api.get_active_iteration()


def get_selected_epic(epic, epics):
return next((item for item in epics if item["title"] == epic), None)


def get_epic():
epics = gitlab_api.list_epics()
return get_selected_epic(interactive.select_epic(epics), epics)


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


def start_issue_creation(project_id, title, milestone, epic, iteration, selected_settings, only_issue, main_branch):
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 = gitlab_api.create_branch(project_id, created_issue, main_branch)
created_merge_request = gitlab_api.create_merge_request(
project_id,
created_branch,
created_issue,
selected_settings.get("labels"),
milestone,
main_branch,
)
print(f"Merge request #{created_merge_request['iid']}: {created_merge_request['title']} created.")

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

return created_issue


def select_labels(search, multiple=False):
labels = gitlab_api.get_labels_of_group(search)
return interactive.select_labels(labels, multiple)


def process_report(text, minutes):
try:
incident_project_id = INCIDENT_PROJECT_ID or CONFIG_PARSER.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 = select_labels("Department")

incident_settings = {
"labels": ["incident", "report"],
"onlyIssue": True,
"type": "incident",
}

if selected_label:
incident_settings["labels"].append(selected_label)

try:
iteration = gitlab_api.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(issue_iid, incident_project_id)
print(f"Incident issue #{issue_iid} created successfully.")
print(f"Title: {issue_title}")

try:
gitlab_api.add_spent_time(issue_iid, incident_project_id, minutes)
print(f"Added {minutes} minutes to issue time tracking.")
except Exception as e:
print(f"Error adding time tracking: {str(e)}")

except Exception as e:
print(f"Error creating incident issue: {str(e)}")
94 changes: 94 additions & 0 deletions commands/deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import datetime

import gitlab_api
import git_utils
from config import MAIN_BRANCH, PRODUCTION_MAPPINGS


def get_last_production_deploy():
try:
project_id = git_utils.resolve_project_id()
params = {
"per_page": 50,
"order_by": "updated_at",
"sort": "desc",
}

if MAIN_BRANCH:
params["ref"] = MAIN_BRANCH
else:
try:
params["ref"] = git_utils.get_main_branch()
except Exception:
params["ref"] = "main"

pipelines = gitlab_api.list_pipelines(project_id, params)
if pipelines is None:
return

production_pipeline = None
for pipeline in pipelines:
jobs = gitlab_api.list_pipeline_jobs(project_id, pipeline["id"])
if jobs is None:
continue

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

except Exception as e:
print(f"Error fetching last production deploy: {str(e)}")
15 changes: 15 additions & 0 deletions commands/open_mr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import subprocess
import webbrowser

from config import BASE_URL
from commands.review import get_active_merge_request_id


def open_merge_request_in_browser():
try:
merge_request_id = get_active_merge_request_id()
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
75 changes: 75 additions & 0 deletions commands/review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import gitlab_api
import git_utils
import interactive


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


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


def get_merge_request_for_branch(branch_name):
project_id = git_utils.resolve_project_id()
return gitlab_api.get_merge_request_for_branch(project_id, branch_name)


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


def track_issue_time():
try:
project_id = git_utils.resolve_project_id()
issue_id = get_current_issue_id()
except Exception as e:
print(f"Error getting issue details: {str(e)}")
return

spent_time = interactive.prompt_spent_time()

try:
gitlab_api.add_issue_time_note(project_id, issue_id, spent_time)
print(f"Added {spent_time} minutes to issue {issue_id} time tracking.")
except Exception as e:
print(f"Error tracking issue time: {str(e)}")


def choose_reviewers_manually():
return interactive.choose_reviewers_manually(get_user=gitlab_api.get_user)


def add_reviewers_to_merge_request(reviewers=None):
project_id = git_utils.resolve_project_id()
mr_id = get_active_merge_request_id()
gitlab_api.add_reviewers_to_merge_request(project_id, mr_id, reviewers)


def set_merge_request_to_auto_merge():
project_id = git_utils.resolve_project_id()
mr_id = get_active_merge_request_id()
gitlab_api.set_merge_request_to_auto_merge(project_id, mr_id)


def run_review_command(select=False, auto_merge=False):
track_issue_time()
reviewers = choose_reviewers_manually() if select else None
add_reviewers_to_merge_request(reviewers=reviewers)

try:
from ai_code_review import run_review_for_mr

project_id = git_utils.resolve_project_id()
mr_id = get_active_merge_request_id()
from config import API_URL, GITLAB_TOKEN

run_review_for_mr(project_id, mr_id, GITLAB_TOKEN, API_URL)
except Exception as e:
print(f"AI review skipped: {e}")

if auto_merge:
set_merge_request_to_auto_merge()
Loading