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

## Usage ⚡

## Project structure 🧱

GitHappens is split into small modules so CLI workflows can be tested and extended without editing a single large script.

```
gitHappens.py # Backwards-compatible executable wrapper
main.py # Entry point and argument parsing
config.py # Configuration and template file loading
templates.py # Template selection and lookup
gitlab_api.py # GitLab API and glab command interactions
git_utils.py # Local git helpers
interactive.py # User prompts and interactive selections
commands/
create_issue.py # Issue, branch, and merge request creation workflow
review.py # Review workflow
deploy.py # Deployment lookup workflow
open_mr.py # Open merge request workflow
report.py # Incident report workflow
summary.py # Commit summary workflows
```

### 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 +246,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 GitHappens."""
44 changes: 44 additions & 0 deletions commands/create_issue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from gitlab_api import create_branch, create_issue, create_merge_request
from interactive import prompt_estimated_time


def start_issue_creation(project_id, title, milestone, epic, iteration, selected_settings, only_issue):
estimated_time = 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_merge_request = create_merge_request(
project_id,
created_branch,
created_issue,
selected_settings.get("labels"),
milestone,
)
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


# Backwards-compatible name.
startIssueCreation = start_issue_creation
113 changes: 113 additions & 0 deletions commands/deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import datetime

import requests

import gitlab_api
from config import CONFIG, PRODUCTION_MAPPINGS
from git_utils import get_main_branch
from gitlab_api import get_project_id


def get_last_production_deploy():
try:
project_id = get_project_id()
api_url = f"{CONFIG.api_url}/projects/{project_id}/pipelines"
headers = {"Private-Token": CONFIG.gitlab_token}
params = {
"per_page": 50,
"order_by": "updated_at",
"sort": "desc",
}

if gitlab_api.MAIN_BRANCH:
params["ref"] = gitlab_api.MAIN_BRANCH
else:
try:
params["ref"] = get_main_branch()
except Exception:
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

for pipeline in pipelines:
pipeline_detail_url = f"{CONFIG.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()

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 error:
print(f"Error fetching last production deploy: {str(error)}")


# Backwards-compatible name.
get_last_production_deploy = get_last_production_deploy
22 changes: 22 additions & 0 deletions commands/open_mr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import subprocess
import webbrowser

from config import CONFIG
from gitlab_api 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 = CONFIG.base_url + "/" + remote_url.split(":")[1][:-4]
webbrowser.open(f"{url}/-/merge_requests/{merge_request_id}")
except subprocess.CalledProcessError:
return None


# Backwards-compatible name.
openMergeRequestInBrowser = open_merge_request_in_browser
55 changes: 55 additions & 0 deletions commands/report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import subprocess

from config import CONFIG
from gitlab_api import close_opened_issue, create_issue, get_active_iteration
from interactive import select_labels


def process_report(text, minutes):
incident_project_id = CONFIG.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 = select_labels("Department")
incident_settings = {
"labels": ["incident", "report"],
"onlyIssue": True,
"type": "incident",
}

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

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

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

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)}")


# Backwards-compatible name.
process_report = process_report
66 changes: 66 additions & 0 deletions commands/review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import subprocess

from config import CONFIG
from git_utils import get_current_branch
from gitlab_api import (
add_reviewers_to_merge_request,
get_active_merge_request_id,
get_merge_request_for_branch,
get_project_id,
set_merge_request_to_auto_merge,
)
from interactive import choose_reviewers_manually, prompt_spent_time


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


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

spent_time = prompt_spent_time()
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 error:
print(f"Error adding time tracking: {str(error)}")
except Exception as error:
print(f"Error tracking issue time: {str(error)}")


def run_review_workflow(select_reviewers=False, auto_merge=False):
track_issue_time()
reviewers = choose_reviewers_manually() if select_reviewers else None
add_reviewers_to_merge_request(reviewers=reviewers)

try:
from ai_code_review import run_review_for_mr

project_id = get_project_id()
mr_id = get_active_merge_request_id()
run_review_for_mr(project_id, mr_id, CONFIG.gitlab_token, CONFIG.api_url)
except Exception as error:
print(f"AI review skipped: {error}")

if auto_merge:
set_merge_request_to_auto_merge()


# Backwards-compatible names.
getCurrentIssueId = get_current_issue_id
track_issue_time = track_issue_time
40 changes: 40 additions & 0 deletions commands/summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from config import CONFIG
from git_utils import get_two_weeks_commits


def generate_smart_summary():
commits = get_two_weeks_commits(return_output=True)
if not commits:
return

if not CONFIG.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 = CONFIG.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 error:
print(f"Error generating AI summary: {error}")
Loading