open-code-review/examples/github_actions/ocr-review.yml

357 lines
14 KiB
YAML
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# OpenCodeReview - GitHub Actions PR Auto-Review Demo
#
# This workflow automatically reviews pull requests using OpenCodeReview
# and posts review comments directly on the PR.
#
# Triggers:
# - PR opened (uses pull_request_target for fork secret access)
# - Comment on PR containing '/open-code-review' or '@open-code-review'
#
# Required secrets:
# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions)
# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API
#
# Optional secrets:
# OCR_LLM_MODEL - Model name (default: gpt-4o)
#
# Note: GITHUB_TOKEN is automatically provided by GitHub Actions.
name: OpenCodeReview PR Review
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
on:
# Use pull_request_target instead of pull_request so that secrets are
# available even for PRs from forks. This is safe because OCR only reads
# the diff and does not execute any code from the PR.
pull_request_target:
types: [opened]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
jobs:
code-review:
runs-on: ubuntu-latest
# Run on PR events, or on comments starting with trigger keywords
if: |
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/open-code-review')) ||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '@open-code-review'))
steps:
- name: Get PR context
id: pr-context
if: github.event_name != 'pull_request_target'
uses: actions/github-script@v7
with:
script: |
// For issue_comment events, get PR info
const prNumber = context.issue.number;
const { data: pullRequest } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
core.setOutput('base_ref', pullRequest.base.ref);
core.setOutput('head_ref', pullRequest.head.ref);
core.setOutput('head_sha', pullRequest.head.sha);
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history needed for merge-base diff
ref: ${{ github.event.pull_request.head.sha || steps.pr-context.outputs.head_sha }}
- name: Fetch PR head ref (ensures fork commits are available)
run: git fetch origin pull/${{ github.event.pull_request.number || github.event.issue.number }}/head
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install OpenCodeReview
run: npm install -g @alibaba-group/open-code-review
- name: Configure OCR
run: |
ocr config set llm.url ${{ secrets.OCR_LLM_URL }}
ocr config set llm.auth_token ${{ secrets.OCR_LLM_AUTH_TOKEN }}
ocr config set llm.model ${{ secrets.OCR_LLM_MODEL }}
ocr config set llm.use_anthropic ${{ secrets.OCR_LLM_USE_ANTHROPIC }}
ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}'
- name: Run OpenCodeReview
id: review
run: |
# Get base ref and head SHA from PR context (different for comment triggers)
# Note: We use HEAD_SHA instead of origin/${HEAD_REF} to support fork PRs,
# because fork branches don't exist on the origin remote.
if [ "${{ github.event_name }}" = "pull_request_target" ]; then
BASE_REF="${{ github.event.pull_request.base.ref }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
else
BASE_REF="${{ steps.pr-context.outputs.base_ref }}"
HEAD_SHA="${{ steps.pr-context.outputs.head_sha }}"
fi
echo "Reviewing PR: ${HEAD_SHA} against origin/${BASE_REF}"
# Run OCR in range mode with JSON output
ocr review \
--from "origin/${BASE_REF}" \
--to "${HEAD_SHA}" \
--format json \
> /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true
echo "OCR review completed. Output:"
cat /tmp/ocr-result.json
echo "OCR review completed. Error log:"
cat /tmp/ocr-stderr.log
- name: Post review comments to PR
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const path = '/tmp/ocr-result.json';
// Read OCR output
let result;
try {
const raw = fs.readFileSync(path, 'utf8');
result = JSON.parse(raw);
} catch (e) {
console.log('Failed to parse OCR output:', e.message);
// Post a simple comment if parsing fails
const stderr = fs.readFileSync('/tmp/ocr-stderr.log', 'utf8').trim();
if (stderr) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `⚠️ **OpenCodeReview** encountered an error:\n${fencedBlock(stderr)}`
});
}
return;
}
const comments = result.comments || [];
const warnings = result.warnings || [];
// If no comments, post a summary
if (comments.length === 0) {
const message = result.message || 'No comments generated. Looks good to me.';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `✅ **OpenCodeReview**: ${message}`
});
return;
}
// Prepare PR review with inline comments
const prNumber = context.issue.number;
let commitSha;
// Get commit SHA from event context
if (context.eventName === 'pull_request_target') {
commitSha = context.payload.pull_request.head.sha;
} else {
// For comment events, we need to fetch the PR
const { data: pullRequest } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
commitSha = pullRequest.head.sha;
}
// Build review comments array for the PR review API
// Only inline comments with line info can be posted via createReview
const reviewComments = [];
const commentsWithoutLine = [];
for (const comment of comments) {
const body = formatComment(comment);
// Check if comment has valid line information for inline comment (line >= 1)
const hasValidLine = (comment.start_line >= 1) || (comment.end_line >= 1);
if (!hasValidLine) {
commentsWithoutLine.push({ comment, body });
continue;
}
const reviewComment = {
path: comment.path,
body: body
};
// Use line range if available
if (comment.start_line >= 1 && comment.end_line >= 1 && comment.start_line !== comment.end_line) {
reviewComment.start_line = comment.start_line;
reviewComment.line = comment.end_line;
reviewComment.start_side = 'RIGHT';
reviewComment.side = 'RIGHT';
} else if (comment.end_line >= 1) {
reviewComment.line = comment.end_line;
reviewComment.side = 'RIGHT';
} else if (comment.start_line >= 1) {
reviewComment.line = comment.start_line;
reviewComment.side = 'RIGHT';
}
reviewComments.push({ comment, reviewComment });
}
// Submit as a single PR review with all comments
const totalCount = comments.length;
const inlineCount = reviewComments.length;
const summaryCount = commentsWithoutLine.length;
let summaryBody = buildSummaryBody(totalCount, inlineCount, summaryCount, warnings);
// Add comments without line info to summary body
summaryBody += formatSummaryComments(commentsWithoutLine);
// Statistics tracking
let successCount = 0;
let failedCount = 0;
const failedComments = [];
try {
await github.rest.pulls.createReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
commit_id: commitSha,
body: summaryBody,
event: 'COMMENT',
comments: reviewComments.map(({ reviewComment }) => reviewComment)
});
successCount = reviewComments.length;
console.log(`Successfully posted review with ${successCount} inline comments (${commentsWithoutLine.length} in summary)`);
} catch (e) {
console.log('Failed to post review with inline comments:', e.message);
console.log('Falling back to posting comments individually...');
// Fallback: post comments one by one
for (const { comment, reviewComment } of reviewComments) {
try {
await github.rest.pulls.createReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
commit_id: commitSha,
body: '',
event: 'COMMENT',
comments: [reviewComment]
});
successCount++;
console.log(`Successfully posted comment for ${reviewComment.path}`);
} catch (innerE) {
failedCount++;
failedComments.push({ comment, error: innerE.message });
console.log(`Failed to post comment for ${reviewComment.path}: ${innerE.message}`);
}
}
// Post summary comment with statistics
let finalBody = buildSummaryBody(totalCount, successCount, commentsWithoutLine.length + failedComments.length, warnings);
finalBody += formatSummaryComments(commentsWithoutLine);
finalBody += `\n\n---\n\n📊 **Posting Statistics:**`;
finalBody += `\n- ✅ Successfully posted: ${successCount} comment(s)`;
if (failedCount > 0) {
finalBody += `\n- ❌ Failed to post: ${failedCount} comment(s)`;
}
// Add failed comments as summary content so review feedback is not lost.
if (failedComments.length > 0) {
finalBody += '\n\n---\n\n### ⚠️ Inline comments shown in summary';
for (const { comment, error } of failedComments) {
finalBody += '\n\n---\n\n';
finalBody += formatCommentMarkdown(comment, error);
}
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: finalBody
});
}
function formatComment(comment) {
let body = comment.content || '';
// Add code suggestion if available
if (comment.suggestion_code && comment.existing_code) {
body += '\n\n**Suggestion:**\n';
body += fencedBlock(comment.suggestion_code, 'suggestion');
}
return body;
}
function formatCommentMarkdown(comment, error) {
let md = `### 📄 \`${comment.path}\``;
if (comment.start_line && comment.end_line) {
md += ` (L${comment.start_line}-L${comment.end_line})`;
}
md += '\n\n';
if (error) {
md += `⚠️ GitHub could not post this as an inline comment: ${error}\n\n`;
}
md += comment.content || '';
if (comment.suggestion_code && comment.existing_code) {
md += '\n\n<details><summary>💡 Suggested Change</summary>\n\n';
md += '**Before:**\n' + fencedBlock(comment.existing_code) + '\n\n';
md += '**After:**\n' + fencedBlock(comment.suggestion_code) + '\n\n';
md += '</details>';
}
return md;
}
function buildSummaryBody(totalCount, inlineCount, summaryCount, warnings) {
let body = `🔍 **OpenCodeReview** found **${totalCount}** issue(s) in this PR.`;
if (totalCount > 0) {
body += `\n- ✅ ${inlineCount} posted as inline comment(s)`;
body += `\n- 📝 ${summaryCount} posted as summary`;
}
if (warnings.length > 0) {
body += `\n\n⚠ ${warnings.length} warning(s) occurred during review.`;
}
return body;
}
function formatSummaryComments(summaryComments) {
let body = '';
for (const { comment } of summaryComments) {
body += '\n\n---\n\n';
body += formatCommentMarkdown(comment);
}
return body;
}
function fencedBlock(content, language = '') {
const text = String(content || '');
const fence = safeFence(text);
let block = fence + language + '\n' + text;
if (!text.endsWith('\n')) block += '\n';
return block + fence;
}
function safeFence(content) {
const matches = String(content || '').match(/`+/g) || [];
const maxTicks = matches.reduce((max, ticks) => Math.max(max, ticks.length), 0);
return '`'.repeat(Math.max(3, maxTicks + 1));
}