DevLab / ai / ai-pull-request-description-writer
AI PYTHON BEGINNER

AI Pull Request Description Writer

This tutorial provides a complete, production-ready Python script to automatically generate Pull Request descriptions using AI.

Python3.10+
OSLinux / macOS / Windows
Lines~230
Packages2 deps
Install Dependencies
$ pip install openai gitpython
Usage
$ python3 pr_writer.py --repo . --base main --head feature/auth
#ai#python#beginner#openai#gitpython

This tutorial provides a complete, production-ready Python script to automatically generate Pull Request descriptions using AI.


Problem Statement

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.

How It Works

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.

Full Script

#!/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()

Configuration

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.
    • Example: export OPENAI_API_KEY='sk-YOUR_SECRET_KEY_HERE'
  • OPENAI_MODEL: (Optional) Specifies which OpenAI model to use for generating the description.
    • Default: gpt-3.5-turbo
    • Example: export 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.
    • Default: 3000
    • Example: export MAX_INPUT_TOKENS='4000'
  • LOG_LEVEL: (Optional) Sets the logging verbosity.
    • Default: INFO
    • Options: DEBUG, INFO, WARNING, ERROR, CRITICAL
    • Example: export LOG_LEVEL='DEBUG'

Usage Examples

First, ensure you have the required dependencies installed:

pip install gitpython openai

Then, set your OPENAI_API_KEY environment variable.

1. Basic Usage (current directory, comparing 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.
----------------------------------------

2. Specifying a different repository path and custom model

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

3. Handling a large diff by setting MAX_INPUT_TOKENS

If 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.

Error Handling

The script includes robust error handling for common scenarios:

  • Missing 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.
  • Missing Dependencies: If gitpython or openai are not installed, the script will catch the ImportError at startup and provide instructions to install them.
  • Invalid Git Repository: If the --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.
  • Invalid Branch Names: If the --base or --head branches do not exist in the specified repository, gitpython will raise a git.BadName error, which is caught and reported.
  • OpenAI API Errors: Any issues during communication with the OpenAI API (e.g., network errors, invalid API key, rate limiting, model not found) will be caught as openai.OpenAIError or its subclasses. The script will log the specific error and inform the user.
  • **Large
// RELATED_SCRIPTS
← Back to DevLab valtersit.com