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/.github/workflows/stale_code.yml b/.github/workflows/stale_code.yml new file mode 100644 index 00000000..d9efdd66 --- /dev/null +++ b/.github/workflows/stale_code.yml @@ -0,0 +1,49 @@ +name: Stale Code Detector + +on: + # Run every Monday at 9:00 AM UTC + schedule: + - cron: '0 9 * * 1' + # Allow manual triggering for testing + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + detect-stale-code: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install google-genai + + - name: Run stale code detector + env: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: | + python scripts/find_stale.py || true + + - name: Create GitHub Issue + if: hashFiles('stale_code_report.md') != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Check if report has findings (not just "No dead code detected") + if grep -q "Found .* potential issues" stale_code_report.md; then + gh issue create \ + --title "Weekly Stale Code Report - $(date +%Y-%m-%d)" \ + --body-file stale_code_report.md + else + echo "No stale code findings to report" + fi diff --git a/.github/workflows/test-gen.yml b/.github/workflows/test-gen.yml new file mode 100644 index 00000000..6c3ac4ff --- /dev/null +++ b/.github/workflows/test-gen.yml @@ -0,0 +1,79 @@ +name: AI Test Generation + +on: + pull_request: + types: [opened, synchronize] + paths: + - '**.py' + - '!tests/**' + +jobs: + generate-tests: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install -r requirements.txt + + - name: Check coverage threshold + id: coverage-check + continue-on-error: true + run: python scripts/check_coverage.py 80 + + - name: Skip message (coverage sufficient) + if: steps.coverage-check.outcome == 'success' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '**Coverage check passed!** No additional tests needed.' + }) + + + - name: Get changed Python files + if: steps.coverage-check.outcome == 'failure' + id: changed-files + run: | + FILES=$(git diff --name-only origin/${{ github.base_ref }}..HEAD -- '*.py' | grep -v '^tests/' | tr '\n' ' ') + echo "files=$FILES" >> $GITHUB_OUTPUT + + - name: Generate tests + if: steps.coverage-check.outcome == 'failure' && steps.changed-files.outputs.files != '' + env: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: | + python scripts/generate_tests.py ${{ steps.changed-files.outputs.files }} + + - name: Commit generated tests + if: steps.coverage-check.outcome == 'failure' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + if git diff --quiet tests/; then + echo "No new tests generated" + exit 0 + fi + + git add tests/ + git commit -m "Auto-generate tests for new code [skip ci]" + git push diff --git a/Error-pics/error 1 fixed.png b/Error-pics/error 1 fixed.png new file mode 100644 index 00000000..702fe697 Binary files /dev/null and b/Error-pics/error 1 fixed.png differ diff --git a/Error-pics/error 1.png b/Error-pics/error 1.png new file mode 100644 index 00000000..5a5b2249 Binary files /dev/null and b/Error-pics/error 1.png differ diff --git a/Error-pics/error 2.1 poss fix explained.png b/Error-pics/error 2.1 poss fix explained.png new file mode 100644 index 00000000..c5a16ebc Binary files /dev/null and b/Error-pics/error 2.1 poss fix explained.png differ diff --git a/Error-pics/error 2.1 poss fix.png b/Error-pics/error 2.1 poss fix.png new file mode 100644 index 00000000..3627173f Binary files /dev/null and b/Error-pics/error 2.1 poss fix.png differ diff --git a/Error-pics/error 2.1.png b/Error-pics/error 2.1.png new file mode 100644 index 00000000..49eb6384 Binary files /dev/null and b/Error-pics/error 2.1.png differ diff --git a/Error-pics/error 2.png b/Error-pics/error 2.png new file mode 100644 index 00000000..c5fc3efb Binary files /dev/null and b/Error-pics/error 2.png differ diff --git a/Error-pics/error 3 fix replaced code in 2 files.png b/Error-pics/error 3 fix replaced code in 2 files.png new file mode 100644 index 00000000..935a011b Binary files /dev/null and b/Error-pics/error 3 fix replaced code in 2 files.png differ diff --git a/Error-pics/error 3 its working.png b/Error-pics/error 3 its working.png new file mode 100644 index 00000000..6bc061f4 Binary files /dev/null and b/Error-pics/error 3 its working.png differ diff --git a/Error-pics/error 3.png b/Error-pics/error 3.png new file mode 100644 index 00000000..5ef91031 Binary files /dev/null and b/Error-pics/error 3.png differ diff --git a/README.md b/README.md index 2167b620..dafa77f0 100644 --- a/README.md +++ b/README.md @@ -96,3 +96,152 @@ ci-starter/ ā”œā”€ā”€ requirements.txt └── pyproject.toml ``` + +## Troubleshooting Guide + +### Common Errors & Solutions + +Below are real errors encountered during setup and their solutions: + +--- + +#### Error 1: Git User Identity Not Configured + +**Error Message:** +``` +Author identity unknown +*** Please tell me who you are. +fatal: unable to auto-detect email address +``` + +![Error 1](Error-pics/error%201.png) + +**What Happened:** +When trying to commit changes, git doesn't know who you are. Git requires a username and email to associate with commits. + +**Solution:** +Configure your git identity globally: + +```bash +git config --global user.name "your-github-username" +git config --global user.email "your-email@example.com" +``` + +![Error 1 Fixed](Error-pics/error%201%20fixed.png) + +**Prevention:** Always configure git when setting up a new machine or development environment. + +--- + +#### Error 2: Empty Workflow Files on Main Branch + +**Error Message:** +``` +Error: No event triggers defined in `on` +``` + +![Error 2](Error-pics/error%202.png) + +**What Happened:** +The workflow files (`.github/workflows/pr-review.yml` and `.github/workflows/test-gen.yml`) existed on the main branch but were **empty**. When GitHub tries to run workflows for a PR, it uses the workflow files from the target branch (main), not the source branch. Since they were empty, GitHub couldn't find any trigger events. + +**Root Cause:** +The workflow files were created but never properly populated with content on the main branch. This happened because: +1. Files were created with placeholders +2. Content was added to the feature branch +3. But the main branch still had empty files + +![Error 2 Diagnosis](Error-pics/error%202.1.png) + +**Solution:** +1. Switch to main branch +2. Add proper workflow content to both files +3. Commit and push to main + +```bash +git checkout main +# Edit .github/workflows/pr-review.yml and test-gen.yml with proper content +git add .github/workflows/ +git commit -m "Fix empty workflow files on main branch" +git push origin main +``` + +![Error 2 Fix](Error-pics/error%202.1%20poss%20fix.png) + +The workflow files need to have the complete YAML configuration including: +- `name:` - Workflow name +- `on:` - Trigger events (pull_request, push, etc.) +- `jobs:` - The actual jobs to run + +![Error 2 Fix Explained](Error-pics/error%202.1%20poss%20fix%20explained.png) + +--- + +#### Error 3: Workflow Files on Feature Branch + +**Error Message:** +``` +Error: No event triggers defined in `on` +(When pushing to feature branch) +``` + +![Error 3](Error-pics/error%203.png) + +**What Happened:** +The workflow files existed on BOTH the main branch AND the feature branch. When pushing to the feature branch, GitHub tried to run the workflows from that branch. However: +- `pr-review.yml` is configured to trigger ONLY on `pull_request` events +- Pushing to a branch triggers a `push` event, not a `pull_request` event +- GitHub complained because it was trying to run a workflow that had no matching trigger + +**Solution:** +Remove workflow files from the feature branch since they should only exist on the target branch (main): + +```bash +git checkout feature/new-function +git rm .github/workflows/pr-review.yml .github/workflows/test-gen.yml +git commit -m "Remove workflow files from feature branch" +git push +``` + +![Error 3 Fix](Error-pics/error%203%20fix%20replaced%20code%20in%202%20files.png) + +**Best Practice:** +- Workflow files for PRs should live on the target branch (usually `main`) +- Don't include `.github/workflows/` files in feature branches unless you specifically need them +- PR workflows will use the configuration from the target branch, not the source branch + +![Error 3 Success](Error-pics/error%203%20its%20working.png) + +--- + +### Success Checklist + +After fixing all errors, you should see: + +āœ… Green checkmarks on your PR +āœ… AI Code Review bot comment with analysis +āœ… AI Test Generation completing successfully +āœ… All workflow runs passing + +--- + +## Tips for Success + +1. **Always configure git first:** + ```bash + git config --global user.name "username" + git config --global user.email "email@example.com" + ``` + +2. **Keep workflow files on main branch only** (for PR workflows) + +3. **Check workflow files aren't empty:** + ```bash + cat .github/workflows/pr-review.yml # Should show content, not empty + ``` + +4. **Create actual PRs** - Workflows only trigger on real PR events, not just pushes + +5. **Wait for workflows to complete** - Give them 1-2 minutes to run + +6. **Check the Actions tab** for detailed logs if something fails diff --git a/app.py b/app.py index 8f2f7ae1..3fef0fbe 100644 --- a/app.py +++ b/app.py @@ -14,3 +14,17 @@ def is_even(n: int) -> bool: def reverse_string(s: str) -> str: """Reverse a string.""" return s[::-1] + + +def power(base, exponent): + """ + Calculate base raised to the power of exponent. + + Args: + base: The base number + exponent: The power to raise the base to + + Returns: + The result of base^exponent + """ + return base ** exponent diff --git a/more_utils.py b/more_utils.py new file mode 100644 index 00000000..2c4e69a4 --- /dev/null +++ b/more_utils.py @@ -0,0 +1,11 @@ +def is_palindrome(text): + """Check if a string is a palindrome.""" + cleaned = text.lower().replace(" ", "") + return cleaned == cleaned[::-1] + + +def count_vowels(text): + """Count the number of vowels in a string.""" + vowels = "aeiouAEIOU" + return sum(1 for char in text if char in vowels) + diff --git a/requirements.txt b/requirements.txt index 5f456517..13599fb8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ # Development/testing dependencies pytest>=7.0.0 build>=1.0.0 +google-genai>=1.0.0 +pytest-cov>=4.0.0 diff --git a/scripts/ai_review.py b/scripts/ai_review.py new file mode 100644 index 00000000..a1df5639 --- /dev/null +++ b/scripts/ai_review.py @@ -0,0 +1,44 @@ +from google import genai +import sys + +client = genai.Client() + +def review_code(diff_text): + """Send a code diff to Gemini for review.""" + prompt = f"""You are an expert code reviewer. Review the following code diff and provide feedback. + +Focus on: +1. Security vulnerabilities +2. Bug risks +3. Performance issues +4. Best practice violations + +For each issue found, provide: +- Severity: HIGH / MEDIUM / LOW +- Description of the issue +- Suggested fix + +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__": + if len(sys.argv) > 1: + diff_file = sys.argv[1] + with open(diff_file, "r") as f: + diff_content = f.read() + else: + diff_content = sys.stdin.read() + + review = review_code(diff_content) + print(review) diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py new file mode 100644 index 00000000..a939cb82 --- /dev/null +++ b/scripts/check_coverage.py @@ -0,0 +1,40 @@ +import subprocess +import sys +import json + +def get_coverage_percentage(): + """Run pytest with coverage and return the percentage.""" + result = subprocess.run( + ['pytest', '--cov=src', '--cov-report=json', '-q'], + capture_output=True, + text=True + ) + + try: + with open('coverage.json', 'r') as f: + data = json.load(f) + return data['totals']['percent_covered'] + except (FileNotFoundError, KeyError): + return 0.0 + + +def main(): + """Check coverage and exit with appropriate code.""" + threshold = float(sys.argv[1]) if len(sys.argv) > 1 else 80.0 + + coverage = get_coverage_percentage() + print(f"Current coverage: {coverage:.1f}%") + print(f"Threshold: {threshold:.1f}%") + + if coverage >= threshold: + print(f"Coverage is sufficient ({coverage:.1f}% >= {threshold:.1f}%)") + print("Skipping test generation.") + sys.exit(0) + else: + print(f"Coverage below threshold ({coverage:.1f}% < {threshold:.1f}%)") + print("Test generation needed.") + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/scripts/find_stale.py b/scripts/find_stale.py new file mode 100644 index 00000000..8ee7bbc7 --- /dev/null +++ b/scripts/find_stale.py @@ -0,0 +1,199 @@ +import os +import json +import time +from pathlib import Path +from google import genai + +# Create a Gemini client, passing in the API key from environment variables +client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY")) + + +def get_python_files(repo_path: str) -> list[str]: + """Get all Python files in the repository, excluding common ignored directories.""" + python_files = [] + + # Ignore files in repo like venv, .git, etc + for path in Path(repo_path).rglob("*.py"): + # Skip virtual environments and common directories to ignore + if any(part in path.parts for part in ["venv", ".venv", "node_modules", "__pycache__", ".git"]): + continue + python_files.append(str(path)) + + return python_files + + +def read_file_content(file_path: str) -> str: + """Read and return the content of a file.""" + try: + with open(file_path, "r", encoding="utf-8") as f: + return f.read() + except Exception as e: + return f"Error reading file: {e}" + + +def analyze_code_with_gemini(code_content: str, file_path: str) -> dict: + """Send code to Gemini for dead code analysis with time estimates.""" + prompt = f"""Analyze the following Python code and identify any dead code. + +Look for: +1. Unused functions (defined but never called within this file) +2. Dead imports (imported but never used) +3. Unreachable code (code after return statements, etc.) + +For each finding, estimate the cleanup time using these guidelines: +- **simple** (~2-5 min): Single line deletion, unused import, trivial function +- **medium** (~10-15 min): Multiple related items, requires verification, modest refactoring +- **complex** (~30+ min): Deeply coupled code, requires extensive testing, architectural changes + +File: {file_path} + +Code: + +{code_content} + + +Respond in JSON format with this structure: +{{ + "findings": [ + {{ + "type": "unused_function" | "dead_import" | "unreachable_code", + "name": "name of the function/import/code", + "line": line_number, + "description": "brief description of why this is dead code", + "estimated_minutes": number, + "complexity": "simple" | "medium" | "complex", + "reasoning": "explanation for the time estimate" + }} + ] +}} + +If no dead code is found, return: {{"findings": []}} +Only return the JSON, no additional text.""" + + try: + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt + ) + response_text = response.text.strip() + + # Remove markdown code blocks if Gemini added them + if response_text.startswith("```"): + response_text = response_text.split("\n", 1)[1] # Remove ```json + response_text = response_text.rsplit("```", 1)[0] # Remove closing ``` + + return json.loads(response_text) + except Exception as e: + return {"error": str(e), "findings": []} + + +def format_findings_as_markdown(all_findings: list[dict]) -> str: + """Format all findings as markdown for a GitHub Issue with time estimates.""" + if not all_findings: + return "## Stale Code Report\n\nāœ… No dead code detected in this scan." + + # Calculate total cleanup time + total_minutes = sum(f.get("estimated_minutes", 0) for f in all_findings) + total_hours = total_minutes / 60 + + markdown = "## Stale Code Report\n\n" + markdown += f"Found **{len(all_findings)}** potential issues:\n" + markdown += f"**Estimated cleanup time:** {total_minutes} minutes (~{total_hours:.1f} hours)\n\n" + + # Group findings by complexity + by_complexity = {"simple": [], "medium": [], "complex": []} + for finding in all_findings: + complexity = finding.get("complexity", "unknown") + if complexity in by_complexity: + by_complexity[complexity].append(finding) + + complexity_labels = { + "simple": "🟢 Simple (2-5 min each)", + "medium": "🟔 Medium (10-15 min each)", + "complex": "šŸ”“ Complex (30+ min each)" + } + + for complexity, findings in by_complexity.items(): + if not findings: + continue + + label = complexity_labels.get(complexity, complexity) + complexity_total = sum(f.get("estimated_minutes", 0) for f in findings) + markdown += f"### {label} — {len(findings)} items ({complexity_total} min)\n\n" + + for f in findings: + type_emoji = {"unused_function": "šŸ”ø", "dead_import": "šŸ“¦", "unreachable_code": "āš ļø"} + emoji = type_emoji.get(f.get("type"), "•") + + markdown += f"{emoji} **`{f.get('name', 'Unknown')}`** " + markdown += f"({f.get('file', 'unknown')}:{f.get('line', '?')}) — " + markdown += f"~{f.get('estimated_minutes', '?')} min\n" + markdown += f" - {f.get('description', 'No description')}\n" + + if f.get("reasoning"): + markdown += f" - *Why:* {f.get('reasoning')}\n" + + markdown += "\n" + + return markdown + + +def main(): + """Main function to scan repository and report findings.""" + repo_path = os.environ.get("GITHUB_WORKSPACE", os.getcwd()) + + print(f"šŸ” Scanning repository: {repo_path}") + + python_files = get_python_files(repo_path) + print(f"šŸ“ Found {len(python_files)} Python files\n") + + all_findings = [] + + for i, file_path in enumerate(python_files, 1): + print(f"[{i}/{len(python_files)}] Analyzing: {file_path}") + content = read_file_content(file_path) + + if content.startswith("Error"): + print(f" āš ļø Skipped (read error)") + continue + + result = analyze_code_with_gemini(content, file_path) + + if "error" in result: + print(f" āŒ Analysis error: {result['error']}") + continue + + findings_count = len(result.get("findings", [])) + if findings_count > 0: + total_time = sum(f.get("estimated_minutes", 0) for f in result.get("findings", [])) + print(f" šŸ”“ Found {findings_count} issue(s) (~{total_time} min to fix)") + for finding in result.get("findings", []): + finding["file"] = file_path + all_findings.append(finding) + else: + print(f" āœ… No issues found") + + # Rate limiting - be nice to the API + time.sleep(1) + + # Generate markdown report + markdown_report = format_findings_as_markdown(all_findings) + + print("\n" + "=" * 60) + print(markdown_report) + print("=" * 60) + + # Save report to file + report_filename = "stale_code_report.md" + with open(report_filename, "w", encoding="utf-8") as f: + f.write(markdown_report) + + print(f"\nšŸ’¾ Report saved to {report_filename}") + + return all_findings + + +if __name__ == "__main__": + findings = main() + # Exit with error code if dead code was found + exit(1 if findings else 0) diff --git a/scripts/generate_tests.py b/scripts/generate_tests.py new file mode 100644 index 00000000..15922f2a --- /dev/null +++ b/scripts/generate_tests.py @@ -0,0 +1,119 @@ +import ast +import os +import sys +from google.genai import Client + + +def extract_functions(file_path): + """Parse a Python file and extract function definitions.""" + + with open(file_path, "r", encoding="utf-8") as f: + source = f.read() + + tree = ast.parse(source) + functions = [] + + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef): + func_name = node.name + args = [arg.arg for arg in node.args.args] + docstring = ast.get_docstring(node) or "" + func_source = ast.get_source_segment(source, node) + + functions.append({ + "name": func_name, + "args": args, + "docstring": docstring, + "source": func_source + }) + + return functions + + +def generate_tests_for_function(func_info): + """Use Gemini to generate pytest tests for a function.""" + + client = Client(api_key=os.environ.get("GEMINI_API_KEY")) + + prompt = f""" +Generate pytest tests for this Python function. + +Function name: {func_info['name']} +Arguments: {', '.join(func_info['args'])} +Docstring: {func_info['docstring']} + +Source code: + +{func_info['source']} + +Requirements: +1. Generate 3–5 meaningful test cases +2. Include edge cases (empty inputs, None values, etc.) +3. Use descriptive test function names +4. Include assertions that actually test behavior +5. Do NOT generate placeholder tests like assert True + +Return ONLY valid Python pytest code. +""" + + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt + ) + + test_code = response.text.strip() + + # Remove markdown fences if Gemini adds them + test_code = test_code.replace("```python", "").replace("```", "") + + return test_code + + +def main(): + """Main function to generate tests for changed files.""" + + changed_files = sys.argv[1:] if len(sys.argv) > 1 else [] + + if not changed_files: + print("No Python files provided for test generation") + return + + all_tests = [] + + for file_path in changed_files: + if not file_path.endswith(".py"): + continue + if file_path.startswith("tests/"): + continue + + print(f"Analyzing: {file_path}") + + functions = extract_functions(file_path) + + for func in functions: + if func["name"].startswith("_"): + continue + + print(f" Generating tests for: {func['name']}") + + tests = generate_tests_for_function(func) + + all_tests.append( + f"# Tests for {func['name']} from {file_path}\n{tests}" + ) + + if all_tests: + os.makedirs("tests", exist_ok=True) + test_file = "tests/test_generated.py" + + with open(test_file, "w", encoding="utf-8") as f: + f.write("import pytest\n\n") + f.write("\n\n".join(all_tests)) + + print(f"Generated tests written to: {test_file}") + else: + print("No functions found to generate tests for") + + +if __name__ == "__main__": + main()