diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..4b739b82 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run tests + run: pytest -q + + - name: Build package + run: python -m build + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: python-package + path: dist/ + diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml new file mode 100644 index 00000000..1bbebca3 --- /dev/null +++ b/.github/workflows/pr-review.yml @@ -0,0 +1,56 @@ +name: AI Code Review + +on: + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: write + +jobs: + review: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Get PR diff + run: | + git diff origin/main...HEAD > pr_diff.txt + + - name: Run AI review + id: ai-review + env: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: | + REVIEW=$(python scripts/ai_review.py pr_diff.txt) + echo "review<> $GITHUB_OUTPUT + echo "$REVIEW" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Post review comment + uses: actions/github-script@v7 + env: + REVIEW: ${{ steps.ai-review.outputs.review }} + with: + script: | + const review = process.env.REVIEW; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `## 🤖 AI Code Review\n\n${review}\n\n---\n*Powered by Gemini AI*` + }); diff --git a/ai-cicd-github b/ai-cicd-github new file mode 160000 index 00000000..f54e82db --- /dev/null +++ b/ai-cicd-github @@ -0,0 +1 @@ +Subproject commit f54e82dbe9de4e9f9e584c1bfe1d0832a821a991 diff --git a/app.py b/app.py index 8f2f7ae1..c7966909 100644 --- a/app.py +++ b/app.py @@ -14,3 +14,7 @@ def is_even(n: int) -> bool: def reverse_string(s: str) -> str: """Reverse a string.""" return s[::-1] + +def multiply(a: int, b: int): + """Mutltiply two numbers""" + return a * b diff --git a/requirements.txt b/requirements.txt index 5f456517..389d026e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ # Production dependencies (none for this simple app) +google-genai>=1.0.0 # Development/testing dependencies pytest>=7.0.0 diff --git a/scripts/ai_review.py b/scripts/ai_review.py new file mode 100644 index 00000000..467ba8d1 --- /dev/null +++ b/scripts/ai_review.py @@ -0,0 +1,38 @@ +import sys +import os +from google import genai + +# Configure your Gemini API key +client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY")) + +def review_code(diff_text: str) -> str: + """Send a code diff to Gemini and return its review.""" + prompt = f"""You are a code reviewer. Review the following code diff and check for: +- Bugs or errors +- Security vulnerabilities (e.g. SQL injection) +- Style and readability issues + +If the code looks good, say so. + +Code diff to review: + +{diff_text} + +Provide your review in a clear, structured format.""" + + response = client.models.generate_content(model="gemini-2.5-flash", contents=prompt) + return response.text + + +if __name__ == "__main__": + # Check if a filename was passed as a command-line argument + if len(sys.argv) > 1: + diff_file = sys.argv[1] + with open(diff_file, "r") as f: + diff_content = f.read() + else: + # If no file was provided, read from stdin (e.g. piped input) + diff_content = sys.stdin.read() + + review = review_code(diff_content) + print(review) \ No newline at end of file diff --git a/scripts/generate_tests.py b/scripts/generate_tests.py new file mode 100644 index 00000000..24a0ea6f --- /dev/null +++ b/scripts/generate_tests.py @@ -0,0 +1,90 @@ +import ast +from google import genai +import sys +import os + + +def extract_functions(file_path): + #Read the source code + with open(file_path, "r") as f: + source = f.read() + + #parse it into a tree + tree = ast.parse(source) + + #Walk through the tree to find node + function = [] + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef): + func_info = { + "name": node.name, + "arguments": [ag.arg for arg in node args.args], + "docstring": ast.get_docstring(node), + "source": ast.get_source_segment(source, node), + } + functions.append(func_info) + return function + + +def generate_tests_for_function(func_info): + client = genai.Client(api_key=os.environ.get('GEMINI_API_KEY')) + prompt = f""" + You are an expert Python developer. Generate 3 - 5 meaningful pytest test cases for the following function. + + Function name: {func_info['name']} + Arguments: {func_info['arguments']} + Docstring: {func_info['docstring']} + Source: {func_info['source']} + + Rules: + - Do not write placeholder tests like assert True or assert False + - Each tests must actually call the function with real arguments. + - Test edge cases like empty input, negative numbers, or none where relevant + - Each testfunction must have a descriptive name explaining what it tests + - Only return the code, no explanations + """ + + response = client.models.generate_content( + model="gemini-2.0-flash", + contents=prompt + ) + + return response.txt + + + +def main(): + file_paths = sys.argv[1:] + + all_tests = [] + + for file_path in file_paths: + if not file_path.endswith(".py"): + print(f"Skipping {file_path} — not a Python file") + continue + + if "test" in file_path: + print(f"Skipping {file_path} — looks like a test file") + continue + + functions = extract_functions(file_path) + + for func in functions: + if func["name"].startswith("_"): + print(f"Skipping private function: {func['name']}") + continue + + print(f"Generating tests for {func['name']}...") + tests = generate_tests_for_function(func) + all_tests.append(tests) + + os.mkdir("tests", exist_ok=True) + + with open("tests/test_generated.py", "w") as f: + f.write("\n\n".join(all_tests)) + + print("Done! Tests written to tests/test_generated.py") + + +if __name__ == '__main__': + main() diff --git a/scripts/scripts/sample_diff.txt b/scripts/scripts/sample_diff.txt new file mode 100644 index 00000000..ba99ee11 --- /dev/null +++ b/scripts/scripts/sample_diff.txt @@ -0,0 +1,16 @@ +diff --git a/app.py b/app.py +index 1234567..abcdefg 100644 +--- a/app.py ++++ b/app.py +@@ -1,5 +1,12 @@ + """Simple utility functions""" ++import sqlite3 ++ ++def get_user(username): ++ conn = sqlite3.connect("users.db") ++ query = f"SELECT * FROM users WHERE name = '{username}'" ++ return conn.execute(query).fetchone() ++ + def add(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b \ No newline at end of file diff --git a/tests/test_app.py b/tests/test_app.py index 79c3e093..f362ecea 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,6 +1,6 @@ """Tests for app.py - you'll add more!""" -from app import add, is_even, reverse_string +from app import add, is_even, reverse_string, multiply class TestMath: @@ -22,3 +22,17 @@ def test_reverse(self): def test_is_even(self): assert is_even(4) is True assert is_even(3) is False + + + class TestMultiply: + """Test for multiply function""" + + def test_multiply_positive_numbers(self): + assert multiply(4, 4) == 16 + + def test_multiply_by_zero(self): + assert multiply(4, 0) == 0 + + def test_multiply_negatives_numbers(self): + assert multiply(-5, -10) == 50 + \ No newline at end of file