Repository navigation
fix(ci): improve AI code review workflow - fix bugs #25
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: AI Code Review | |
| on: | |
| workflow_dispatch: | |
| pull_request: | |
| branches: [main] | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| jobs: | |
| ai-code-review: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Get changed files | |
| id: changed | |
| run: | | |
| BASE_REF="${GITHUB_BASE_REF:-main}" | |
| echo "base_ref=$BASE_REF" >> "$GITHUB_OUTPUT" | |
| CHANGED=$(git diff --name-only "origin/$BASE_REF...HEAD") | |
| echo "$CHANGED" > changed_files.txt | |
| COUNT=$(echo "$CHANGED" | wc -l) | |
| echo "count=$COUNT" >> "$GITHUB_OUTPUT" | |
| echo "Changed files ($COUNT):" | |
| echo "$CHANGED" | |
| - name: AI Code Review | |
| id: review | |
| env: | |
| SENSENOVA_API_KEY: ${{ secrets.SENSENOVA_API_KEY }} | |
| run: | | |
| BASE_REF="${GITHUB_BASE_REF:-main}" | |
| # ── Inline system prompt ── | |
| SYSTEM_PROMPT=$(cat .github/prompts/code-review-prompt.md) | |
| REVIEW_FILE="review_output.md" | |
| : > "$REVIEW_FILE" | |
| HAS_REVIEW=false | |
| FILE_COUNT=0 | |
| CHANGED_FILES=$(cat changed_files.txt) | |
| # ── Check API key early ── | |
| if [ -z "$SENSENOVA_API_KEY" ] || [ "${#SENSENOVA_API_KEY}" -lt 8 ]; then | |
| echo "::error::SENSENOVA_API_KEY is not set or invalid. Aborting." | |
| exit 1 | |
| fi | |
| if [ -z "$CHANGED_FILES" ]; then | |
| echo "No files changed, skipping review." >> "$REVIEW_FILE" | |
| else | |
| while IFS= read -r FILE; do | |
| [ -z "$FILE" ] && continue | |
| echo "Reviewing: $FILE" | |
| # ── Get per-file diff ── | |
| DIFF=$(git diff "origin/$BASE_REF...HEAD" -- "$FILE" 2>/dev/null) || { echo " [ERROR] git diff failed for $FILE"; continue; } | |
| # ── Check if binary via git diff ── | |
| if echo "$DIFF" | grep -q "^Binary files "; then | |
| echo " [SKIP] $FILE — binary file (detected by git diff)" | |
| continue | |
| fi | |
| NL=$'\n' | |
| # ── If empty (new file / special chars), show full content ── | |
| if [ -z "$DIFF" ]; then | |
| # Check file type before generating virtual diff | |
| if [ -f "$FILE" ] && file -b --mime-encoding "$FILE" 2>/dev/null | grep -q binary; then | |
| echo " [SKIP] $FILE — binary file (detected by file command)" | |
| continue | |
| fi | |
| LINE_COUNT=$(wc -l < "$FILE" 2>/dev/null) || { echo " [SKIP] $FILE — cannot read"; continue; } | |
| if [ "${LINE_COUNT}" -gt 2000 ]; then | |
| echo " [SKIP] $FILE — too large (${LINE_COUNT} lines, max 2000)" | |
| continue | |
| fi | |
| DIFF="--- /dev/null${NL}+++ b/$FILE${NL}@@ -0,0 +1,$LINE_COUNT @@${NL}$(sed 's/^/+/' "$FILE")" | |
| fi | |
| # ── Build user message ── | |
| USER_MSG="请 review 以下文件变更:${NL}${NL}文件: $FILE${NL}${NL}\`\`\`diff${NL}$DIFF${NL}\`\`\`" | |
| # ── Escape for JSON ── | |
| # Use jq to build the payload safely | |
| PAYLOAD=$(jq -n \ | |
| --arg model "deepseek-v4-flash" \ | |
| --arg system "$SYSTEM_PROMPT" \ | |
| --arg user "$USER_MSG" \ | |
| '{ | |
| model: $model, | |
| messages: [ | |
| {role: "system", content: $system}, | |
| {role: "user", content: $user} | |
| ], | |
| stream: false | |
| }') | |
| # ── Call SenseNova API ── | |
| if [ "${FILE_COUNT:-0}" -gt 0 ]; then | |
| sleep 1 # rate-limit: 1s gap between files | |
| fi | |
| RESPONSE=$(curl -s --max-time 60 --retry 3 --retry-all-errors -w "\n%{http_code}" \ | |
| "https://token.sensenova.cn/v1/chat/completions" \ | |
| -H "Authorization: Bearer $SENSENOVA_API_KEY" \ | |
| -H "Content-Type: application/json" \ | |
| -d "$PAYLOAD") | |
| HTTP_CODE=$(echo "$RESPONSE" | tail -1) | |
| BODY=$(echo "$RESPONSE" | sed '$d') | |
| if [ "$HTTP_CODE" != "200" ]; then | |
| FILE_SIZE=$(wc -c < "$FILE" 2>/dev/null || echo "?") | |
| echo " [ERROR] HTTP $HTTP_CODE for $FILE (${FILE_SIZE}B)" | |
| echo " Response: $(echo "$BODY" | head -3)" | |
| continue | |
| fi | |
| # ── Extract review text from response ── | |
| REVIEW_TEXT=$(echo "$BODY" | jq -r '.choices[0].message.content // empty') | |
| if [ -z "$REVIEW_TEXT" ]; then | |
| echo " [WARN] Empty response for $FILE" | |
| continue | |
| fi | |
| # ── Append to review output ── | |
| { | |
| echo "## 📁 \`$FILE\`" | |
| echo "" | |
| echo "$REVIEW_TEXT" | |
| echo "" | |
| echo "---" | |
| echo "" | |
| } >> "$REVIEW_FILE" | |
| HAS_REVIEW=true | |
| FILE_COUNT=$((FILE_COUNT + 1)) | |
| echo " [OK] Review completed for $FILE" | |
| done <<< "$CHANGED_FILES" | |
| fi | |
| # ── Final summary ── | |
| if [ "$HAS_REVIEW" = false ]; then | |
| printf '%s\n' '## 🤖 AI Code Review' '' 'No reviewable changes found (all files binary, unchanged, or empty).' > "$REVIEW_FILE" | |
| else | |
| SUMMARY=$(printf '# 🤖 AI Code Review\n\n> 共审查 **%s** 个变更文件\n\n' "${FILE_COUNT}") | |
| { | |
| echo "$SUMMARY" | |
| echo "" | |
| cat "$REVIEW_FILE" | |
| } > "${REVIEW_FILE}.tmp" && mv "${REVIEW_FILE}.tmp" "$REVIEW_FILE" | |
| fi | |
| echo "Review written to $REVIEW_FILE" | |
| - name: Upload review artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ai-code-review | |
| path: review_output.md | |
| if-no-files-found: ignore | |
| - name: Post review comment | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const fs = require('fs'); | |
| const reviewFile = 'review_output.md'; | |
| if (!fs.existsSync(reviewFile)) { | |
| console.log('No review output file found, skipping comment.'); | |
| return; | |
| } | |
| const reviewBody = fs.readFileSync(reviewFile, 'utf8').trim(); | |
| if (!reviewBody) { | |
| console.log('Empty review output, skipping comment.'); | |
| return; | |
| } | |
| // Skip comment for cross-repo PRs (fork → upstream): GITHUB_TOKEN lacks write access | |
| const pr = context.payload.pull_request; | |
| if (pr && pr.head.repo.full_name !== pr.base.repo.full_name) { | |
| console.log('Cross-repo PR detected, skipping comment (GITHUB_TOKEN cannot write to upstream).'); | |
| return; | |
| } | |
| // For pull_request events, get the PR number | |
| let issueNumber; | |
| if (pr) { | |
| issueNumber = pr.number; | |
| } else { | |
| console.log('Not a pull request event, skipping comment.'); | |
| return; | |
| } | |
| // Check if we already have a review comment from this workflow | |
| const comments = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| per_page: 100, | |
| }); | |
| const botComment = comments.data.find(c => | |
| c.user.type === 'Bot' && | |
| c.body.startsWith('# 🤖 AI Code Review') | |
| ); | |
| if (botComment) { | |
| // Update existing comment | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: botComment.id, | |
| body: reviewBody, | |
| }); | |
| console.log('Updated existing review comment.'); | |
| } else { | |
| // Create new comment | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| body: reviewBody, | |
| }); | |
| console.log('Created new review comment.'); | |
| } |