This tutorial provides a complete, production-ready Python script to automatically generate Pull Request descriptions using AI.
This tutorial provides a complete, production-ready Python script to automatically generate Pull Request descriptions using AI.
Writing comprehensive and accurate Pull Request (PR) descriptions can be a tedious and time-consuming task for developers. Often, PRs are submitted with minimal descriptions, making it difficult for reviewers to understand the context, scope, and impact of the changes. This leads to slower review cycles, increased friction, and potential misunderstandings, especially in large teams or complex projects. Manually sifting through diffs and commit messages to synthesize a clear summary is inefficient and prone to human error or oversight.
This script addresses this problem by leveraging AI to automate the generation of PR descriptions. By analyzing the changes and commit messages between two Git branches, the script can produce a concise and informative summary, saving developers time and improving the quality of PR documentation. This allows developers to focus on coding while ensuring that their contributions are clearly communicated to their team.
The AI Pull Request Description Writer operates by integrating with your local Git repository and the OpenAI API. Upon execution, the script first uses the gitpython library to interact with the specified Git repository. It identifies the differences between the designated base and head branches, extracting both the full diff content (showing file changes) and the commit messages that occurred within that range.
This raw Git information (diffs and commit messages) is then compiled into a structured prompt. This prompt, along with system instructions, is sent to a chat completion model via the OpenAI API. The AI processes this input, understanding the context of the code changes and the intent behind the commits. It then generates a human-readable summary that explains the "What," "Why," and "How" of the PR. Key design decisions include using gitpython for robust Git interaction, argparse for flexible command-line input, and os.environ.get for secure and flexible configuration via environment variables. The script also incorporates basic error handling for common issues like missing API keys, invalid repositories, or API communication failures.
#!/usr/bin/env python3
"""
AI Pull Request Description Writer
This script analyzes the differences and commit messages between two Git branches
in a specified repository and uses the OpenAI API to generate a concise and
informative Pull Request description.
"""
import os
import argparse
import logging
from typing import List, Dict, Any, Optional
try:
import git
from openai import OpenAI, OpenAIError
except ImportError as e:
print(f"Error: Missing required library. Please install with: pip install gitpython openai")
print(f"Details: {e}")
exit(1)
# --- Configuration ---
# Configure logging
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO").upper(),
format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# OpenAI API Key: Fetched from environment variable for security
OPENAI_API_KEY: Optional[str] = os.environ.get("OPENAI_API_KEY")
# OpenAI Model: Default to a capable chat model
OPENAI_MODEL: str = os.environ.get("OPENAI_MODEL", "gpt-3.5-turbo")
# Maximum tokens for the combined diff and commit messages sent to OpenAI
# This helps prevent exceeding the model's context window and manages costs.
# gpt-3.5-turbo typically has 4096 or 16384 tokens. Keeping it conservative.
MAX_INPUT_TOKENS: int = int(os.environ.get("MAX_INPUT_TOKENS", "3000"))
# --- Helper Functions ---
def get_git_diff(repo_path: str, base_branch: str, head_branch: str) -> str:
"""
Retrieves the diff between two branches in a Git repository.
Args:
repo_path: Path to the Git repository.
base_branch: The base branch name (e.g., 'main').
head_branch: The head branch name (e.g., 'feature/new-feature').
Returns:
A string containing the git diff.
Raises:
git.InvalidGitRepositoryError: If repo_path is not a valid git repository.
git.BadName: If base_branch or head_branch do not exist.
Exception: For other git-related errors.
"""
try:
repo = git.Repo(repo_path)
# Fetch latest changes to ensure branches are up-to-date (optional but good practice)
# repo.remotes.origin.fetch() # This might be too aggressive for a simple diff tool
# Check if branches exist
if base_branch not in repo.branches and base_branch not in repo.remotes.origin.refs:
raise git.BadName(f"Base branch '{base_branch}' not found in repository.")
if head_branch not in repo.branches and head_branch not in repo.remotes.origin.refs:
raise git.BadName(f"Head branch '{head_branch}' not found in repository.")
# Ensure we are comparing local branches or remote tracking branches correctly
# If branches are local, use them directly. If they are remote, specify full path.
base_ref = base_branch
head_ref = head_branch
if base_branch not in repo.branches: # Assume it's a remote branch if not local
base_ref = f'origin/{base_branch}'
if head_branch not in repo.branches: # Assume it's a remote branch if not local
head_ref = f'origin/{head_branch}'
# Get diff for all changes between the two branches
diff_output = repo.git.diff(base_ref, head_ref)
logger.debug(f"Generated diff length: {len(diff_output)} characters")
return diff_output
except git.InvalidGitRepositoryError:
logger.error(f"'{repo_path}' is not a valid Git repository.")
raise
except git.BadName as e:
logger.error(f"Git branch error: {e}")
raise
except Exception as e:
logger.error(f"An unexpected Git error occurred: {e}")
raise
def get_commit_messages(repo_path: str, base_branch: str, head_branch: str) -> List[str]:
"""
Retrieves commit messages between two branches in a Git repository.
Args:
repo_path: Path to the Git repository.
base_branch: The base branch name.
head_branch: The head branch name.
Returns:
A list of commit message strings.
Raises:
git.InvalidGitRepositoryError: If repo_path is not a valid git repository.
git.BadName: If base_branch or head_branch do not exist.
Exception: For other git-related errors.
"""
try:
repo = git.Repo(repo_path)
commit_messages: List[str] = []
# Iterate commits from head_branch that are not in base_branch
for commit in repo.iter_commits(f'{base_branch}..{head_branch}'):
commit_messages.append(commit.message.strip())
logger.debug(f"Found {len(commit_messages)} commit messages.")
return commit_messages
except git.InvalidGitRepositoryError:
logger.error(f"'{repo_path}' is not a valid Git repository.")
raise
except git.BadName as e:
logger.error(f"Git branch error: {e}")
raise
except Exception as e:
logger.error(f"An unexpected Git error occurred while getting commit messages: {e}")
raise
def generate_pr_description(diff_content: str, commit_messages: List[str]) -> str:
"""
Generates a Pull Request description using the OpenAI API.
Args:
diff_content: The full git diff as a string.
commit_messages: A list of commit messages.
Returns:
The generated PR description string.
Raises:
ValueError: If OPENAI_API_KEY is not set.
OpenAIError: For any issues with the OpenAI API call.
"""
if not OPENAI_API_KEY:
raise ValueError("OPENAI_API_KEY environment variable is not set.")
client = OpenAI(api_key=OPENAI_API_KEY)
# Combine commit messages into a single string
combined_commit_messages = "\n".join([f"- {msg}" for msg in commit_messages])
if not combined_commit_messages:
combined_commit_messages = "No specific commit messages found beyond the diff."
# Construct the prompt for the AI
# We'll use a simple character count as a proxy for tokens,
# as exact token counting requires a separate library and can be complex.
# This ensures we don't send excessively long inputs.
prompt_template = (
"You are an AI assistant that specializes in summarizing code changes and "
"writing concise, informative pull request descriptions.\n\n"
"Generate a pull request description based on the following git changes and commit messages. "
"Focus on the 'Why', 'What', and 'How' of the changes. "
"Keep it concise but comprehensive, using clear and professional language. "
"Structure it with clear headings like 'Summary', 'Changes', 'Why this change', and 'How to test (if applicable)'.\n\n"
"--- Commit Messages ---\n{commit_messages}\n\n"
"--- Diff ---\n{diff_content}"
)
# Truncate diff content if it's too long
# We estimate 1 character ~ 0.25 tokens, so MAX_INPUT_TOKENS * 4 for char limit
max_diff_chars = MAX_INPUT_TOKENS * 3 # A bit more conservative
if len(diff_content) > max_diff_chars:
logger.warning(f"Diff content ({len(diff_content)} chars) is too long. Truncating to {max_diff_chars} characters.")
diff_content = diff_content[:max_diff_chars] + "\n... [DIFF TRUNCATED] ..."
prompt_content = prompt_template.format(
commit_messages=combined_commit_messages,
diff_content=diff_content
)
messages: List[Dict[str, str]] = [
{"role": "system", "content": "You are a helpful assistant for writing concise and informative pull request descriptions."},
{"role": "user", "content": prompt_content}
]
logger.debug(f"Sending prompt to OpenAI (approx. {len(prompt_content) // 3} tokens).")
try:
response = client.chat.completions.create(
model=OPENAI_MODEL,
messages=messages,
temperature=0.7, # Controls randomness: lower for more focused, higher for more creative
max_tokens=500 # Max tokens for the AI's response
)
pr_description = response.choices[0].message.content.strip()
return pr_description
except OpenAIError as e:
logger.error(f"OpenAI API error: {e}")
raise
except Exception as e:
logger.error(f"An unexpected error occurred during OpenAI API call: {e}")
raise
# --- Main Logic ---
def main() -> None:
"""
Main function to parse arguments, retrieve git info, and generate PR description.
"""
parser = argparse.ArgumentParser(
description="AI Pull Request Description Writer. Generates PR descriptions "
"by analyzing git diffs and commit messages using OpenAI."
)
parser.add_argument(
"--repo",
type=str,
default=".",
help="Path to the Git repository (default: current directory)."
)
parser.add_argument(
"--base",
type=str,
required=True,
help="The base branch name (e.g., 'main', 'develop')."
)
parser.add_argument(
"--head",
type=str,
required=True,
help="The head branch name (e.g., 'feature/my-new-feature', 'bugfix/issue-123')."
)
args = parser.parse_args()
if not OPENAI_API_KEY:
logger.error("OPENAI_API_KEY environment variable is not set. Please set it to your OpenAI API key.")
print("\nError: OPENAI_API_KEY is not set. Please configure your environment variable.")
print("Example: export OPENAI_API_KEY='sk-YOUR_KEY_HERE'")
exit(1)
logger.info(f"Analyzing repository: {args.repo}")
logger.info(f"Comparing base branch '{args.base}' with head branch '{args.head}' using model '{OPENAI_MODEL}'.")
try:
# Step 1: Get Git diff
diff_content = get_git_diff(args.repo, args.base, args.head)
if not diff_content:
logger.warning("No diff found between the specified branches. Generating a general description.")
# Step 2: Get commit messages
commit_messages = get_commit_messages(args.repo, args.base, args.head)
if not commit_messages:
logger.warning("No new commit messages found between the specified branches.")
# Step 3: Generate PR description using AI
logger.info("Generating PR description with AI...")
pr_description = generate_pr_description(diff_content, commit_messages)
print("\n--- Generated Pull Request Description ---")
print(pr_description)
print("----------------------------------------\n")
except (git.InvalidGitRepositoryError, git.BadName, ValueError, OpenAIError) as e:
logger.error(f"Script failed: {e}")
print(f"\nScript execution failed: {e}")
exit(1)
except Exception as e:
logger.critical(f"An unhandled error occurred: {e}", exc_info=True)
print(f"\nAn unexpected error occurred. Check logs for details: {e}")
exit(1)
if __name__ == "__main__":
main()
The script relies on environment variables for sensitive information and configurable parameters. It's recommended to set these variables in your shell profile or CI/CD environment.
OPENAI_API_KEY: (Required) Your secret API key for accessing the OpenAI service. You can obtain this from the OpenAI platform website.export OPENAI_API_KEY='sk-YOUR_SECRET_KEY_HERE'OPENAI_MODEL: (Optional) Specifies which OpenAI model to use for generating the description.gpt-3.5-turboexport OPENAI_MODEL='gpt-4'MAX_INPUT_TOKENS: (Optional) An integer representing the maximum number of tokens for the combined diff and commit messages sent to the OpenAI API. This helps manage API costs and avoid exceeding context window limits. The script uses a character-to-token estimation for truncation.3000export MAX_INPUT_TOKENS='4000'LOG_LEVEL: (Optional) Sets the logging verbosity.INFODEBUG, INFO, WARNING, ERROR, CRITICALexport LOG_LEVEL='DEBUG'First, ensure you have the required dependencies installed:
pip install gitpython openai
Then, set your OPENAI_API_KEY environment variable.
main to feature/auth)Assume you are in your Git repository's root directory and have a branch named feature/auth that you want to merge into main.
# Set your API key (replace with your actual key)
export OPENAI_API_KEY='sk-YOUR_ACTUAL_OPENAI_API_KEY'
python3 pr_writer.py --repo . --base main --head feature/auth
Expected Output (example):
INFO - Analyzing repository: .
INFO - Comparing base branch 'main' with head branch 'feature/auth' using model 'gpt-3.5-turbo'.
INFO - Generating PR description with AI...
--- Generated Pull Request Description ---
## Summary
This pull request introduces a comprehensive authentication system using JWT tokens. It includes user registration, login, token generation, and protected API routes, ensuring secure access to application resources.
## Changes
- **Added `auth.py`:** Contains JWT encoding/decoding logic and authentication middleware.
- **Modified `users.py`:** Implemented user registration and login endpoints with password hashing.
- **Updated `app.py`:** Integrated authentication middleware to protect `/profile` and other sensitive routes.
- **Added `requirements.txt` entries:** Included `PyJWT`, `passlib`, and `bcrypt`.
## Why this change
The existing application lacked a robust user authentication mechanism, making it unsuitable for production environments requiring user-specific data and secure interactions. This change provides a foundational authentication layer, enhancing security and enabling future features that rely on user identity.
## How to test (if applicable)
1. **Setup:** Ensure `PyJWT`, `passlib`, and `bcrypt` are installed.
2. **Register:** Send a POST request to `/register` with `username` and `password`.
3. **Login:** Send a POST request to `/login` with the registered credentials to receive a JWT token.
4. **Access Protected Route:** Use the received JWT token in the `Authorization: Bearer <token>` header to access `/profile`. Verify that access is denied without a valid token.
----------------------------------------
If your repository is not in the current directory, or you want to use a more powerful model like gpt-4 (if you have access).
# Set your API key
export OPENAI_API_KEY='sk-YOUR_ACTUAL_OPENAI_API_KEY'
# Optionally set a different model
export OPENAI_MODEL='gpt-4'
python3 pr_writer.py --repo /path/to/your/other/project --base develop --head bugfix/fix-login-flow
MAX_INPUT_TOKENSIf you have very large diffs and want to control the input size more precisely to avoid exceeding token limits or manage costs.
export OPENAI_API_KEY='sk-YOUR_ACTUAL_OPENAI_API_KEY'
export MAX_INPUT_TOKENS='2000' # Limit input to ~2000 tokens
python3 pr_writer.py --repo . --base main --head feature/large-refactor
If the diff is too large, you might see a warning message like:
WARNING - Diff content (15000 chars) is too long. Truncating to 6000 characters.
The generated description will then be based on the truncated diff.
The script includes robust error handling for common scenarios:
OPENAI_API_KEY: If the OPENAI_API_KEY environment variable is not set, the script will print an error message and exit immediately, prompting the user to set it.gitpython or openai are not installed, the script will catch the ImportError at startup and provide instructions to install them.--repo path does not point to a valid Git repository, gitpython will raise a git.InvalidGitRepositoryError, which the script catches and logs, informing the user.--base or --head branches do not exist in the specified repository, gitpython will raise a git.BadName error, which is caught and reported.openai.OpenAIError or its subclasses. The script will log the specific error and inform the user.