diff --git a/.github/reviewer-rules.md b/.github/reviewer-rules.md new file mode 100644 index 00000000..58e9b795 --- /dev/null +++ b/.github/reviewer-rules.md @@ -0,0 +1,10 @@ +# Code Reviewer Instructions & Domain Knowledge + +## Model & Infrastructure Rules +- We use the `gemini-3.6-flash` model endpoint across our CI/CD workflows. It is valid and working; do not suggest changing model versions. + +## FRC Robotics Best Practices +- **WPILib Command-Based:** Verify subsystems require commands properly (`addRequirements(this)`). +- **CAN Bus Safety:** Ensure CAN IDs and motor controller configurations avoid blocking calls in periodic loops. +- **Null Safety:** Check that sensors and encoders initialized in constructor are null-checked before `.get()`. +- **Educational Tone:** Explain *why* something is an issue rather than just giving a diff. diff --git a/.github/workflows/gemini-review.yml b/.github/workflows/gemini-review.yml new file mode 100644 index 00000000..d2c3ae1e --- /dev/null +++ b/.github/workflows/gemini-review.yml @@ -0,0 +1,186 @@ +name: Gemini FRC Code Review + +on: + pull_request: + types: [opened, synchronize, reopened] + issue_comment: + types: [created] + +jobs: + gemini-review: + if: | + github.event_name == 'pull_request' || + (github.event.issue.pull_request && startsWith(github.event.comment.body, '/gemini')) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + + steps: + # Step 1: Acknowledge Command with Reactions API (if triggered by comment) + - name: Acknowledge Command + if: ${{ github.event_name == 'issue_comment' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api "repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions" \ + -f content="eyes" + + # Step 2: Fetch PR Details, Diff, and Rules + - name: Fetch PR Details, Diff, and Rules + id: fetch_data + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + PR_NUM="${{ github.event.pull_request.number }}" + else + PR_NUM="${{ github.event.issue.number }}" + fi + + echo "PR_NUMBER=$PR_NUM" >> $GITHUB_ENV + + # Get PR Head Commit SHA (required for inline comments) + COMMIT_SHA=$(gh pr view "$PR_NUM" --repo "${{ github.repository }}" --json headRefOid -q .headRefOid) + echo "COMMIT_SHA=$COMMIT_SHA" >> $GITHUB_ENV + + # Download PR diff + gh pr diff "$PR_NUM" --repo "${{ github.repository }}" > pr_diff.txt + head -c 25000 pr_diff.txt > trimmed_diff.txt + + # Fetch reviewer-rules.md if present + gh api "repos/${{ github.repository }}/contents/.github/reviewer-rules.md" \ + -H "Accept: application/vnd.github.raw+json" > rules.txt 2>/dev/null || touch rules.txt + + # Step 3: Run Gemini & Post Inline Review Comments + - name: Post Inline Gemini Review + env: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EVENT_NAME: ${{ github.event_name }} + USER_COMMENT: ${{ github.event.comment.body }} + run: | + python3 - << 'EOF' + import urllib.request, urllib.error, json, os, sys, time, subprocess + + api_key = os.environ.get('GEMINI_API_KEY', '').strip() + pr_number = os.environ.get('PR_NUMBER', '') + commit_sha = os.environ.get('COMMIT_SHA', '') + repo = os.environ.get('GITHUB_REPOSITORY', '') + + if not api_key: + print('Error: GEMINI_API_KEY secret is not set.') + sys.exit(1) + + models_to_try = ['gemini-3.6-flash', 'gemini-3.5-flash', 'gemini-2.0-flash'] + + try: + with open('trimmed_diff.txt', 'r', encoding='utf-8', errors='replace') as f: + diff_text = f.read() + except Exception as e: + print(f'Error reading diff: {e}') + sys.exit(1) + + rules_text = "" + if os.path.exists('rules.txt'): + with open('rules.txt', 'r', encoding='utf-8', errors='replace') as rf: + content = rf.read().strip() + if content: + rules_text = f"\n\nPROJECT RULES & SKILLS:\n{content}" + + if not diff_text.strip(): + print('No code changes detected.') + sys.exit(0) + + system_prompt = f"""You are a lead mentor for an FRC robotics team reviewing a pull request diff. + Provide feedback on WPILib best practices, thread safety, motor safety, logic bugs, and null safety. + {rules_text} + + CRITICAL OUTPUT FORMAT: + You must respond ONLY with a raw JSON object (no markdown code blocks, no backticks, no extra text) matching this schema: + {{ + "summary": "High-level summary for the PR description", + "comments": [ + {{ + "path": "path/to/file.java", + "line": 42, + "body": "Your inline comment or suggestion here." + }} + ] + }} + + NOTE: The 'line' must correspond to a valid line number in the NEW modified file (+ lines in diff). Only comment on lines that need improvement or praise.""" + + prompt = f"{system_prompt}\n\nDiff to Review:\n{diff_text}" + data = json.dumps({'contents': [{'parts': [{'text': prompt}]}]}).encode('utf-8') + + response_json = None + for model in models_to_try: + url = f'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent' + req = urllib.request.Request( + url, + data=data, + headers={ + 'Content-Type': 'application/json', + 'x-goog-api-key': api_key + } + ) + try: + with urllib.request.urlopen(req) as res: + res_data = json.loads(res.read().decode('utf-8')) + raw_text = res_data['candidates'][0]['content']['parts'][0]['text'].strip() + # Clean potential markdown wraps from response + if raw_text.startswith("```json"): + raw_text = raw_text[7:] + if raw_text.startswith("```"): + raw_text = raw_text[3:] + if raw_text.endswith("```"): + raw_text = raw_text[:-3] + response_json = json.loads(raw_text.strip()) + break + except urllib.error.HTTPError as e: + if e.code in [503, 429, 404]: + time.sleep(1) + continue + else: + print(f'HTTP Error {e.code}') + break + except Exception as e: + print(f'Parsing error: {e}') + break + + if not response_json: + print('Failed to generate structured review.') + sys.exit(0) + + # 1. Post High-level Summary Comment + summary = response_json.get('summary', 'PR reviewed.') + subprocess.run([ + 'gh', 'pr', 'comment', pr_number, + '--repo', repo, + '--body', f"🤖 **Gemini Code Review Summary**\n\n{summary}" + ]) + + # 2. Post Inline Comments + comments = response_json.get('comments', []) + for c in comments: + file_path = c.get('path') + line_num = c.get('line') + body_msg = c.get('body') + + if file_path and line_num and body_msg: + # Use GitHub REST API for pull request review comments + cmd = [ + 'gh', 'api', + f'repos/{repo}/pulls/{pr_number}/comments', + '-X', 'POST', + '-f', f'body=🤖 {body_msg}', + '-f', f'commit_id={commit_sha}', + '-f', f'path={file_path}', + '-F', f'line={line_num}', + '-f', 'side=RIGHT' + ] + subprocess.run(cmd) + + EOF