mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
357 lines
14 KiB
YAML
357 lines
14 KiB
YAML
# 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));
|
||
}
|