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: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,35 @@ Run `source ~/.zshrc` or restart terminal.

## Usage ⚡

## Project structure

GitHappens is split into focused modules so the CLI entry point stays small and command workflows can be tested independently:

```text
gitHappens.py # CLI entry point and argument dispatch
config.py # Config and template file loading
gitlab_api.py # GitLab API and glab command integrations
git_utils.py # Local git helpers
templates.py # Issue template selection and lookup
interactive.py # Interactive prompt helpers
commands/
create_issue.py # Issue, branch, and merge request creation workflow
open_mr.py # Open the active merge request in the browser
review.py # Reviewers, time tracking, AI review, and auto-merge
summary.py # Commit summary commands
deploy.py # Last production deployment lookup
tests/ # Unit tests for the extracted modules
```

### Testing

Install runtime dependencies and pytest, then run the test suite:

```bash
pip install -r requirements.txt pytest
pytest
```

### Project selection

- Project selection is made automatically if you run script in same path as your project is located.
Expand Down Expand Up @@ -225,4 +254,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 workflows for the GitHappens CLI."""
181 changes: 181 additions & 0 deletions commands/create_issue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import inquirer

from git_utils import getProjectLinkFromCurrentDir
from gitlab_api import (
closeOpenedIssue,
create_branch,
create_merge_request,
executeIssueCreate,
getActiveIteration,
get_all_projects,
list_epics,
list_iterations,
list_milestones,
)
from interactive import (
enterProjectId,
getSelectedEpic,
getSelectedIteration,
getSelectedMilestone,
select_epic,
select_iteration,
select_milestone,
selectLabels,
)


def get_project_id():
project_link = getProjectLinkFromCurrentDir()
if project_link == -1:
return enterProjectId()

all_projects = get_all_projects(project_link)
matching_id = None
for project in all_projects:
if project.get("ssh_url_to_repo") == project_link:
matching_id = project.get("id")
break
return matching_id


def get_milestone(manual):
if manual:
milestones = list_milestones()
return getSelectedMilestone(select_milestone(milestones), milestones)
return list_milestones(True)


def get_iteration(manual):
if manual:
iterations = list_iterations()
return getSelectedIteration(select_iteration(iterations), iterations)
return getActiveIteration()


def get_epic():
epics = list_epics()
return getSelectedEpic(select_epic(epics), epics)


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


def startIssueCreation(project_id, title, milestone, epic, iteration, selectedSettings, onlyIssue, main_branch):
estimated_time = inquirer.prompt(
[
inquirer.Text(
"estimated_time",
message="Estimated time to complete this issue (in minutes, optional)",
validate=lambda _, value: value == "" or value.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.copy() if selectedSettings else {}
selectedSettings["estimated_time"] = int(estimated_time_per_project)

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

if onlyIssue:
return created_issue

created_branch = create_branch(project_id, created_issue, main_branch)
created_merge_request = create_merge_request(
project_id,
created_branch,
created_issue,
selectedSettings.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']}' "
f"'origin/{created_merge_request['source_branch']}'"
)
print("to switch to new branch.")

return created_issue


def process_report(text, minutes):
from config import CONFIG

try:
incident_project_id = CONFIG.get("DEFAULT", "incident_project_id")
except Exception:
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:
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}")

import subprocess

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 error:
print(f"Error adding time tracking: {str(error)}")

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

from config import PRODUCTION_MAPPINGS
from git_utils import getMainBranch
from gitlab_api import get_pipeline_jobs, get_recent_pipelines

from .create_issue import get_project_id


def find_production_deployment(pipelines, jobs_loader, project_id, production_mappings):
for pipeline in pipelines:
jobs = jobs_loader(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
):
return {
"pipeline": pipeline,
"production_job": job,
}
else:
print("Didn't find deployment pipeline")
return None


def get_last_production_deploy():
try:
project_id = get_project_id()
try:
ref = getMainBranch()
except Exception:
ref = "main"

pipelines = get_recent_pipelines(project_id, ref=ref)
if pipelines is None:
return

production_pipeline = find_production_deployment(
pipelines,
get_pipeline_jobs,
project_id,
PRODUCTION_MAPPINGS,
)

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')}")
if job.get("duration"):
print(f" Duration: {job.get('duration', 'N/A')} seconds")
else:
print(" 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 error:
print(f"Error fetching last production deploy: {str(error)}")
31 changes: 31 additions & 0 deletions commands/open_mr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import webbrowser

from config import BASE_URL
from git_utils import getCurrentBranch
from gitlab_api import getMergeRequestForBranch

from .create_issue import get_project_id


def openMergeRequestInBrowser():
try:
merge_request_id = getActiveMergeRequestId()
import subprocess

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 Exception:
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):
merge_request = getMergeRequestForBranch(get_project_id(), branch_name)
return merge_request["iid"]
Loading