Skip to content

Skill Code Review

Skill Code Review #94

# Skill Code Review Workflow
#
# Automated code review using Google Gemini, validating PRs against project
# rules (CLAUDE.md, SKILL_DESIGN_PRINCIPLES.md, pack-level AGENTS.md).
#
# Trigger methods:
# 1. PR comment: Maintainer comments "/skill-code-review"
# 2. Manual dispatch: Triggered from Actions tab or `gh workflow run`
#
# Security model:
# - issue_comment runs from main branch (fork code never executes with secrets)
# - Only maintainers listed in MAINTAINERS file can trigger via PR comments
name: Skill Code Review
on:
issue_comment:
types: [created]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to review'
required: true
type: number
permissions:
contents: read
pull-requests: write
concurrency:
group: skill-code-review-${{ github.event.issue.number || inputs.pr_number }}
cancel-in-progress: true
# ---------------------------------------------------------------------------
# Gate job: validates the /skill-code-review command and authorizes the user.
# Only runs for issue_comment events.
# ---------------------------------------------------------------------------
jobs:
gate:
if: >
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '/skill-code-review')
runs-on: ubuntu-latest
outputs:
authorized: ${{ steps.auth.outputs.authorized }}
pr_number: ${{ steps.pr.outputs.number }}
head_sha: ${{ steps.pr.outputs.head_sha }}
steps:
# Checkout only the MAINTAINERS file from main — never from the PR branch
- name: Checkout MAINTAINERS from main
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
sparse-checkout: MAINTAINERS
sparse-checkout-cone-mode: false
- name: Check authorization
id: auth
env:
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
run: |
if ! [ -f MAINTAINERS ]; then
echo "::error::MAINTAINERS file not found in default branch"
echo "authorized=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if grep -v '^#' MAINTAINERS | grep -v '^$' | grep -qx "$COMMENT_AUTHOR"; then
echo "authorized=true" >> "$GITHUB_OUTPUT"
echo "✅ $COMMENT_AUTHOR is authorized"
else
echo "authorized=false" >> "$GITHUB_OUTPUT"
echo "❌ $COMMENT_AUTHOR is not in MAINTAINERS"
fi
- name: Reject unauthorized user
if: steps.auth.outputs.authorized == 'false'
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api --method POST \
"/repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions" \
-f content="-1"
gh pr comment "${{ github.event.issue.number }}" \
--repo "${{ github.repository }}" \
--body "❌ @${{ github.event.comment.user.login }} is not authorized to trigger code reviews. Only maintainers listed in \`MAINTAINERS\` can use \`/skill-code-review\`."
- name: React with eyes
if: steps.auth.outputs.authorized == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api --method POST \
"/repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions" \
-f content="eyes"
- name: Get PR info
if: steps.auth.outputs.authorized == 'true'
id: pr
env:
GH_TOKEN: ${{ github.token }}
run: |
PR_NUMBER="${{ github.event.issue.number }}"
echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
HEAD_SHA=$(gh api "/repos/${{ github.repository }}/pulls/$PR_NUMBER" --jq '.head.sha')
echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
echo "PR #$PR_NUMBER — HEAD: $HEAD_SHA"
# ---------------------------------------------------------------------------
# Review job: fetches the PR diff, builds context, and sends to Gemini.
# ---------------------------------------------------------------------------
review:
needs: gate
if: |
always() && (
(needs.gate.result == 'success' && needs.gate.outputs.authorized == 'true') ||
github.event_name == 'workflow_dispatch'
)
runs-on: ubuntu-latest
steps:
- name: Resolve inputs
id: inputs
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "pr_number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT"
else
echo "pr_number=${{ needs.gate.outputs.pr_number }}" >> "$GITHUB_OUTPUT"
echo "head_sha=${{ needs.gate.outputs.head_sha }}" >> "$GITHUB_OUTPUT"
fi
- name: Checkout PR head
uses: actions/checkout@v4
with:
ref: ${{ steps.inputs.outputs.head_sha || '' }}
fetch-depth: 1
- name: Get PR diff
id: diff
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh api \
-H "Accept: application/vnd.github.v3.diff" \
"/repos/${{ github.repository }}/pulls/${{ steps.inputs.outputs.pr_number }}" \
> /tmp/pr.diff
DIFF_SIZE=$(wc -c < /tmp/pr.diff)
echo "diff_size=$DIFF_SIZE" >> "$GITHUB_OUTPUT"
if [ "$DIFF_SIZE" -gt 200000 ]; then
head -c 200000 /tmp/pr.diff > /tmp/pr_truncated.diff
echo "truncated=true" >> "$GITHUB_OUTPUT"
else
cp /tmp/pr.diff /tmp/pr_truncated.diff
echo "truncated=false" >> "$GITHUB_OUTPUT"
fi
- name: Skip if empty diff
if: steps.diff.outputs.diff_size == '0'
run: echo "No diff found, skipping review."
- name: Build review context
if: steps.diff.outputs.diff_size != '0'
run: |
set -euo pipefail
{
echo "=== PROJECT RULES: CLAUDE.md ==="
cat CLAUDE.md 2>/dev/null || echo "(not found)"
echo ""
echo "=== PROJECT RULES: SKILL_DESIGN_PRINCIPLES.md ==="
cat SKILL_DESIGN_PRINCIPLES.md 2>/dev/null || echo "(not found)"
} > /tmp/project_rules.txt
CHANGED_PACKS=$(grep -oP '^diff --git a/([^/]+)/' /tmp/pr.diff | sort -u | sed 's|diff --git a/||;s|/||')
for pack in $CHANGED_PACKS; do
if [ -f "$pack/AGENTS.md" ]; then
{
echo ""
echo "=== PACK RULES: $pack/AGENTS.md ==="
cat "$pack/AGENTS.md"
} >> /tmp/project_rules.txt
fi
done
head -c 50000 /tmp/project_rules.txt > /tmp/project_rules_truncated.txt
- name: Build system instruction
if: steps.diff.outputs.diff_size != '0'
run: |
set -euo pipefail
cp .github/gemini-review-prompt.md /tmp/system_instruction.txt
if [ "${{ steps.diff.outputs.truncated }}" = "true" ]; then
printf '\n\nNote: This diff was truncated due to size. Review covers the first ~200KB only.' \
>> /tmp/system_instruction.txt
fi
{
cat /tmp/system_instruction.txt
printf '\n\n---\n\nPROJECT RULES REFERENCE:\n\n'
cat /tmp/project_rules_truncated.txt
} > /tmp/system_full.txt
# Call the Gemini API with retry for transient errors
- name: Review with Gemini
id: gemini
if: steps.diff.outputs.diff_size != '0'
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
CODE_REVIEW_LLM_MODEL: ${{ vars.CODE_REVIEW_LLM_MODEL }}
run: |
set -euo pipefail
if [ -z "$GEMINI_API_KEY" ]; then
echo "::error::GEMINI_API_KEY secret is not configured"
echo "skipped=true" >> "$GITHUB_OUTPUT"
exit 0
fi
MODEL="${CODE_REVIEW_LLM_MODEL:-gemini-3.1-pro-preview}"
echo "Using model: $MODEL"
jq -n \
--rawfile system /tmp/system_full.txt \
--rawfile diff /tmp/pr_truncated.diff \
'{
"systemInstruction": {
"parts": [{ "text": $system }]
},
"contents": [{
"parts": [{
"text": ("Review the following pull request diff:\n\n" + $diff)
}]
}],
"generationConfig": {
"temperature": 0.2,
"maxOutputTokens": 8192
}
}' > /tmp/request.json
MAX_ATTEMPTS=5
RETRY_DELAY=60
TRANSIENT_CODES="429 500 502 503 504"
for attempt in $(seq 1 $MAX_ATTEMPTS); do
HTTP_CODE=$(curl -s -o /tmp/response.json -w "%{http_code}" -X POST \
"https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent" \
-H "Content-Type: application/json" \
-H "x-goog-api-key: ${GEMINI_API_KEY}" \
-d @/tmp/request.json)
if [ "$HTTP_CODE" -eq 200 ]; then
break
fi
if echo "$TRANSIENT_CODES" | grep -qw "$HTTP_CODE"; then
if [ "$attempt" -lt "$MAX_ATTEMPTS" ]; then
echo "::warning::Gemini API returned HTTP $HTTP_CODE (attempt $attempt/$MAX_ATTEMPTS), retrying in ${RETRY_DELAY}s..."
sleep $RETRY_DELAY
continue
fi
fi
echo "::error::Gemini API returned HTTP $HTTP_CODE after $attempt attempt(s)"
echo "failed=true" >> "$GITHUB_OUTPUT"
echo "skipped=false" >> "$GITHUB_OUTPUT"
exit 1
done
REVIEW=$(jq -r '.candidates[0].content.parts[0].text // empty' /tmp/response.json)
if [ -z "$REVIEW" ]; then
echo "::error::Gemini returned empty review"
echo "failed=true" >> "$GITHUB_OUTPUT"
echo "skipped=false" >> "$GITHUB_OUTPUT"
exit 1
fi
echo "$REVIEW" > /tmp/review.md
echo "failed=false" >> "$GITHUB_OUTPUT"
echo "skipped=false" >> "$GITHUB_OUTPUT"
- name: Post or update review comment
if: steps.diff.outputs.diff_size != '0' && always()
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ steps.inputs.outputs.pr_number }}
GEMINI_SKIPPED: ${{ steps.gemini.outputs.skipped }}
GEMINI_FAILED: ${{ steps.gemini.outputs.failed }}
run: |
set -euo pipefail
MARKER="<!-- gemini-code-review -->"
WORKFLOW_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
TIMESTAMP=$(date -u '+%Y-%m-%d %H:%M UTC')
EXISTING_COMMENT_ID=$(gh api --paginate \
"/repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
--jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" \
| head -1)
EXISTING_BODY=""
RUN_NUMBER=1
if [ -n "$EXISTING_COMMENT_ID" ]; then
EXISTING_BODY=$(gh api \
"/repos/${{ github.repository }}/issues/comments/$EXISTING_COMMENT_ID" \
--jq '.body')
RUN_NUMBER=$(( $(echo "$EXISTING_BODY" | grep -c '### 🔄 Run #' || true) + 1 ))
fi
{
if [ "$RUN_NUMBER" -eq 1 ]; then
echo "$MARKER"
fi
echo "### 🔄 Run #${RUN_NUMBER} — ${TIMESTAMP}"
echo ""
echo "## Gemini Code Review"
echo ""
if [ -f "/tmp/review.md" ]; then
echo "<details>"
echo "<summary>📝 Review details</summary>"
echo ""
cat /tmp/review.md
echo ""
echo "</details>"
elif [ "$GEMINI_SKIPPED" = "true" ]; then
echo "⚠️ Skipped — \`GEMINI_API_KEY\` secret is not configured."
else
echo "⚠️ Code review unavailable — check the [workflow run]($WORKFLOW_URL) for details."
fi
echo ""
echo "> [Workflow run]($WORKFLOW_URL)"
} > /tmp/new_report.md
if [ -n "$EXISTING_COMMENT_ID" ]; then
{
echo "$EXISTING_BODY"
echo ""
echo "---"
echo ""
cat /tmp/new_report.md
} > /tmp/comment.md
gh api \
--method PATCH \
"/repos/${{ github.repository }}/issues/comments/$EXISTING_COMMENT_ID" \
-f body="$(cat /tmp/comment.md)"
else
gh pr comment "$PR_NUMBER" \
--repo "${{ github.repository }}" \
--body-file /tmp/new_report.md
fi
- name: React to PR
if: always()
uses: actions/github-script@v7
with:
script: |
const result = '${{ job.status }}';
const prNumber = Number('${{ steps.inputs.outputs.pr_number }}');
await github.rest.reactions.createForIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
content: result === 'success' ? '+1' : '-1'
});