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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
configs/templates.json
configs/config.ini
configs/config.ini
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@
- 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`
- `pip install -r requirements.txt`

### Setup

Expand All @@ -32,7 +31,7 @@

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'`

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

Expand Down Expand Up @@ -210,6 +209,30 @@ To configure production deployment detection, add project-specific mappings to y

If you run just `gh` (or whatever alias you set) or `gh --help` you will see all available flags and a short explanation.

## Project Structure

The codebase is organized into modular components:

```
.
├── main.py # Entry point and argument parsing
├── gitlab_api.py # GitLab API interactions (unified transport)
├── config.py # Configuration management
├── templates.py # Template processing
├── git_utils.py # Git operations
├── interactive.py # User prompts and CLI interactions
├── ai_code_review.py # AI-powered code review
├── commands/ # Command-specific logic
│ ├── create_issue.py
│ ├── review.py
│ ├── deploy.py
│ ├── open_mr.py
│ ├── report.py
│ ├── summary.py
│ └── ai_review.py
└── tests/ # Unit tests (pytest)
```

## Troubleshooting 🪲🔫

### Recieving 401 Unauthorized error
Expand Down
187 changes: 23 additions & 164 deletions ai_code_review.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
import subprocess
import json
import sys
import os
import configparser

import config
import git_utils
import gitlab_api

# ANSI color codes
class Colors:
Expand Down Expand Up @@ -45,51 +46,10 @@ class Colors:
- MEDIUM: Code smells, potential bugs, missing error handling
- LOW: Minor improvements, suggestions, style inconsistencies"""

def get_branch_diff():
"""Get the diff of changed files in current branch vs main branch."""
try:
# Get main branch name
main_branch = subprocess.check_output(
"git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'",
shell=True, text=True, stderr=subprocess.STDOUT
).strip()
except subprocess.CalledProcessError:
main_branch = 'master'

try:
current_branch = subprocess.check_output(
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
text=True
).strip()

if current_branch == main_branch:
print(f"{Colors.HIGH}⚠ You are on the main branch ({main_branch}). No changes to review.{Colors.RESET}")
return None

# Get diff of changed files only
diff_output = subprocess.check_output(
['git', 'diff', f'{main_branch}...HEAD'],
text=True,
stderr=subprocess.DEVNULL
)

if not diff_output.strip():
print(f"{Colors.INFO}ℹ No changes detected between {current_branch} and {main_branch}{Colors.RESET}")
return None

return diff_output

except subprocess.CalledProcessError as e:
print(f"{Colors.CRITICAL}✗ Error getting git diff: {e}{Colors.RESET}")
return None

def get_openai_client():
"""Initialize OpenAI client with API key from config."""
config = configparser.ConfigParser()
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'configs/config.ini')
config.read(config_path)

api_key = config.get('DEFAULT', 'OPENAI_API_KEY', fallback=None)
api_key = config.config.get('DEFAULT', 'OPENAI_API_KEY', fallback=None)
if not api_key:
print(f"{Colors.HIGH}⚠ OpenAI API key not set in configs/config.ini{Colors.RESET}")
print(f"{Colors.DIM} Add: OPENAI_API_KEY = your_key_here{Colors.RESET}")
Expand Down Expand Up @@ -213,123 +173,11 @@ def format_issues(issues, severity, emoji):

return comment

def get_merge_request_changes(project_id, mr_id, gitlab_token, api_url):
"""Get the changes (diffs) from the merge request to find commit SHAs."""
import requests

url = f"{api_url}/projects/{project_id}/merge_requests/{mr_id}/changes"
headers = {"Private-Token": gitlab_token}

try:
response = requests.get(url, headers=headers)
if response.status_code == 200:
return response.json()
else:
print(f"{Colors.CRITICAL}✗ Failed to get MR changes: {response.status_code}{Colors.RESET}")
return None
except Exception as e:
print(f"{Colors.CRITICAL}✗ Error getting MR changes: {e}{Colors.RESET}")
return None

def get_diff_refs(project_id, mr_id, gitlab_token, api_url):
"""Get the diff refs (base_sha, head_sha, start_sha) from the merge request."""
import requests

url = f"{api_url}/projects/{project_id}/merge_requests/{mr_id}"
headers = {"Private-Token": gitlab_token}

try:
response = requests.get(url, headers=headers)
if response.status_code == 200:
mr_data = response.json()
diff_refs = mr_data.get('diff_refs', {})
return {
'base_sha': diff_refs.get('base_sha'),
'head_sha': diff_refs.get('head_sha'),
'start_sha': diff_refs.get('start_sha')
}
return None
except Exception:
return None

def post_inline_comment(issue, severity, project_id, mr_id, gitlab_token, api_url, diff_refs):
"""Post an inline comment on a specific line in the merge request."""
import requests

url = f"{api_url}/projects/{project_id}/merge_requests/{mr_id}/discussions"
headers = {"Private-Token": gitlab_token}

severity_emoji = {
'critical': '🔴',
'high': '🟡',
'medium': '🔵',
'low': '🟢'
}

emoji = severity_emoji.get(severity, '⚪')
body = f"{emoji} **{severity.upper()}**: {issue['issue']}"

file_path = issue['file']
line = issue['line']

# Convert line to integer if it's a string
try:
line = int(line) if isinstance(line, str) else line
except (ValueError, TypeError):
print(f"{Colors.HIGH}⚠ Skipping comment (invalid line number): {file_path}:{line}{Colors.RESET}")
return False

data = {
"body": body,
"position": {
"base_sha": diff_refs['base_sha'],
"head_sha": diff_refs['head_sha'],
"start_sha": diff_refs['start_sha'],
"position_type": "text",
"new_path": file_path,
"new_line": line
}
}

try:
response = requests.post(url, headers=headers, json=data)
if response.status_code == 201:
return True
else:
# Print error details for debugging
error_msg = response.json() if response.headers.get('content-type') == 'application/json' else response.text
print(f"{Colors.DIM} Failed {file_path}:{line} - {response.status_code}: {error_msg}{Colors.RESET}")
return False
except Exception as e:
print(f"{Colors.DIM} Error posting inline comment: {e}{Colors.RESET}")
return False

def post_to_merge_request(comment_body, project_id, mr_id, gitlab_token, api_url):
"""Post AI review as a general comment on the GitLab merge request."""
import requests

url = f"{api_url}/projects/{project_id}/merge_requests/{mr_id}/notes"
headers = {"Private-Token": gitlab_token}
data = {"body": comment_body}

try:
response = requests.post(url, headers=headers, json=data)
if response.status_code == 201:
print(f"{Colors.INFO}✓ AI review summary posted to merge request{Colors.RESET}")
return True
else:
print(f"{Colors.CRITICAL}✗ Failed to post comment: {response.status_code}{Colors.RESET}")
print(f"{Colors.DIM}{response.text}{Colors.RESET}")
return False
except Exception as e:
print(f"{Colors.CRITICAL}✗ Error posting to GitLab: {e}{Colors.RESET}")
return False

def run_review():
"""Main entry point for AI code review (terminal output)."""
print(f"{Colors.INFO}🔍 Analyzing code changes...{Colors.RESET}")

diff_content = get_branch_diff()
diff_content = git_utils.get_branch_diff()
if not diff_content:
sys.exit(0)

Expand All @@ -340,11 +188,12 @@ def run_review():
sys.exit(0)
display_review_results(results)

def run_review_for_mr(project_id, mr_id, gitlab_token, api_url):

def run_review_for_mr(project_id, mr_id):
"""Run AI code review and post inline comments to GitLab merge request."""
print(f"{Colors.INFO}🤖 Running AI code review...{Colors.RESET}")

diff_content = get_branch_diff()
diff_content = git_utils.get_branch_diff()
if not diff_content:
return

Expand All @@ -354,11 +203,11 @@ def run_review_for_mr(project_id, mr_id, gitlab_token, api_url):
return

# Get diff refs for inline comments
diff_refs = get_diff_refs(project_id, mr_id, gitlab_token, api_url)
diff_refs = gitlab_api.get_diff_refs(project_id, mr_id)
if not diff_refs or not all(diff_refs.values()):
print(f"{Colors.HIGH}⚠ Could not get diff refs, posting summary only{Colors.RESET}")
comment = format_gitlab_comment(results)
post_to_merge_request(comment, project_id, mr_id, gitlab_token, api_url)
gitlab_api.post_to_merge_request(comment, project_id, mr_id)
return

# Post inline comments for each issue
Expand All @@ -368,7 +217,17 @@ def run_review_for_mr(project_id, mr_id, gitlab_token, api_url):
for severity in ['critical', 'high', 'medium', 'low']:
issues = results.get(severity, [])
for issue in issues:
success = post_inline_comment(issue, severity, project_id, mr_id, gitlab_token, api_url, diff_refs)
file_path = issue['file']
line = issue['line']
try:
line = int(line) if isinstance(line, str) else line
except (ValueError, TypeError):
print(f"{Colors.HIGH}⚠ Skipping comment (invalid line number): {file_path}:{line}{Colors.RESET}")
continue

emoji = {'critical': '🔴', 'high': '🟡', 'medium': '🔵', 'low': '🟢'}.get(severity, '⚪')
body = f"{emoji} **{severity.upper()}**: {issue['issue']}"
success = gitlab_api.post_inline_comment(project_id, mr_id, body, diff_refs, file_path, line)
if success:
total_posted += 1
print(f"{Colors.INFO} ✓ Posted {severity} issue on {issue['file']}:{issue['line']}{Colors.RESET}")
Expand All @@ -382,7 +241,7 @@ def run_review_for_mr(project_id, mr_id, gitlab_token, api_url):
for severity, issue in failed_comments:
emoji = {'critical': '🔴', 'high': '🟡', 'medium': '🔵', 'low': '🟢'}.get(severity, '⚪')
summary_comment += f"- {emoji} **`{issue['file']}:{issue['line']}`** - {issue['issue']}\n"
post_to_merge_request(summary_comment, project_id, mr_id, gitlab_token, api_url)
gitlab_api.post_to_merge_request(summary_comment, project_id, mr_id)
else:
print(f"{Colors.INFO}✓ All {total_posted} issues posted as inline comments{Colors.RESET}")

Expand Down
1 change: 1 addition & 0 deletions commands/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# commands package
6 changes: 6 additions & 0 deletions commands/ai_review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env python3
from ai_code_review import run_review


def run():
run_review()
49 changes: 49 additions & 0 deletions commands/create_issue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
import config
import gitlab_api
import interactive


def run(title, project_id, 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)

issue_type = selected_settings.get('type') or 'issue'
created_issue = gitlab_api.execute_issue_create(
project_id,
title,
selected_settings.get('labels'),
milestone,
epic,
iteration,
selected_settings.get('weight'),
selected_settings.get('estimated_time'),
issue_type,
)
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)
created_mr = gitlab_api.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
Loading