diff --git a/README.md b/README.md index eddc538..3f5f5ba 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,24 @@ curl -o ~/.claude/commands/open-code-review.md \ > **Prerequisite**: All integration methods require the `ocr` CLI to be installed and an LLM configured. See [Install](#install) and [Configure LLM](#1-configure-llm) above. +### CI/CD Integration + +OCR can be integrated into CI/CD pipelines to automate code review on Merge Requests / Pull Requests. + +The core command for CI integration: + +```bash +ocr review \ + --from "origin/main" \ + --to "origin/feature-branch" \ + --format json \ + --audience agent +``` + +The `--format json` and `--audience agent` flags output machine-readable results suitable for parsing in CI scripts. + +See the [`examples/`](./examples/) directory for integration examples, including GitHub Actions and GitLab CI. + ## Commands | Command | Alias | Description | diff --git a/README.zh-CN.md b/README.zh-CN.md index c78a77d..f8f1008 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -192,6 +192,24 @@ curl -o ~/.claude/commands/open-code-review.md \ > **前置条件**:所有集成方式都需要安装 `ocr` CLI 并配置 LLM。参见上方[安装](#安装)和[配置 LLM](#1-配置-llm)。 +### CI/CD 集成 + +OCR 可以集成到 CI/CD 流水线中,在 Merge Request / Pull Request 时自动进行代码审查。 + +CI 集成的核心命令: + +```bash +ocr review \ + --from "origin/main" \ + --to "origin/feature-branch" \ + --format json \ + --audience agent +``` + +`--format json` 和 `--audience agent` 参数输出适合 CI 脚本解析的机器可读结果。 + +集成示例请参见 [`examples/`](./examples/) 目录,包括 GitHub Actions 和 GitLab CI。 + ## 命令 | 命令 | 别名 | 描述 | diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..8a4caea --- /dev/null +++ b/examples/README.md @@ -0,0 +1,10 @@ +# CI/CD Integration Examples + +This directory contains examples for integrating OpenCodeReview (OCR) into various CI/CD pipelines. + +## Contents + +- **[github_actions/](./github_actions/)** - GitHub Actions integration example +- **[gitlab_ci/](./gitlab_ci/)** - GitLab CI integration example + +Each subdirectory contains its own README with detailed setup instructions. \ No newline at end of file diff --git a/examples/github_actions/README.md b/examples/github_actions/README.md new file mode 100644 index 0000000..299fd48 --- /dev/null +++ b/examples/github_actions/README.md @@ -0,0 +1,223 @@ +# OpenCodeReview - GitHub Actions Demo + +This demo shows how to integrate OpenCodeReview into your GitHub Actions workflow to automatically review Pull Requests and post review comments. + +## How It Works + +``` +PR Created/Updated → GitHub Actions Triggered → OCR Reviews Diff → Comments Posted on PR + OR +Comment with trigger keyword ↗ +``` + +1. When a PR is opened, synchronized, or reopened, the workflow triggers +2. Alternatively, when a comment containing `/open-code-review` or `@open-code-review` is posted on a PR, the workflow triggers +3. It installs OCR via `npm install -g @alibaba-group/open-code-review` +4. Runs `ocr review --from origin/ --to origin/ --format json` to analyze the diff +5. Parses the JSON output and posts inline review comments on the PR using GitHub's Pull Request Review API + +## Setup + +### 1. Copy the workflow file + +Copy `ocr-review.yml` to your repository's `.github/workflows/` directory: + +```bash +mkdir -p .github/workflows +cp ocr-review.yml .github/workflows/ocr-review.yml +``` + +### 2. Configure secrets + +Go to your repository's **Settings → Secrets and variables → Actions** and add: + +| Secret | Required | Description | +|--------|----------|-------------| +| `OCR_LLM_URL` | Yes | LLM API endpoint URL (e.g., `https://api.openai.com/v1/chat/completions`) | +| `OCR_LLM_AUTH_TOKEN` | Yes | API authentication token | +| `OCR_LLM_MODEL` | No | Model name (defaults to `gpt-4o`) | +| `OCR_LLM_USE_ANTHROPIC` | No | Set to `true` if using Anthropic Claude models | + +> **Note:** `GITHUB_TOKEN` is automatically provided by GitHub Actions with the required `pull-requests: write` permission. +> +> The workflow also configures `llm.extra_body` to disable thinking mode for compatibility with various LLM providers. + +## Customization + +### Change the trigger events + +Modify the `on.pull_request.types` array in the workflow file: + +```yaml +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] +``` + +### Customize comment trigger keywords + +By default, the workflow triggers when a PR comment starts with `/open-code-review` or `@open-code-review`. You can customize these keywords by modifying the `if` condition in the workflow: + +```yaml +if: | + github.event_name == 'pull_request' || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/review')) || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '@mybot')) +``` + +Or use a more flexible pattern with `contains` to trigger on any comment containing the keyword: + +```yaml +if: | + github.event_name == 'pull_request' || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '/review')) +``` + +> **Note:** The condition `github.event.issue.pull_request` ensures the comment is on a PR, not a regular issue. + +### Use a specific OCR version + +```yaml +- name: Install OpenCodeReview + run: npm install -g @alibaba-group/open-code-review@1.0.0 +``` + +### Add custom review rules + +Use the `--rule` flag to pass a custom rules JSON file: + +```yaml +- name: Run OCR review + run: ocr review --rule ./my-rules.json --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }} +``` + +### Limit concurrency + +Adjust the `--concurrency` flag for large PRs to control the number of concurrent LLM requests: + +```yaml +- name: Run OCR review + run: ocr review --concurrency 5 --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }} +``` + +### Provide background context + +Use the `--background` flag to pass additional context that helps OCR better understand the purpose of the changes: + +```yaml +- name: Run OCR review + run: ocr review --background "${{ github.event.pull_request.title }}" --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }} +``` + +This is particularly useful when your PR titles follow semantic conventions (e.g., `feat(auth): add OAuth2 support`) that clearly summarize what the PR implements. The background information helps OCR provide more relevant and context-aware review comments. + +### Customize the review comment author with GitHub App + +By default, review comments are posted using the built-in `GITHUB_TOKEN`, which appears as `github-actions[bot]`. You can customize this by creating a GitHub App and using its credentials instead. + +For more details about GitHub Apps, see the [GitHub Apps documentation](https://docs.github.com/en/apps). + +#### Step 1: Create a GitHub App + +1. Go to your organization or personal account **Settings → Developer settings → GitHub Apps → New GitHub App** +2. Fill in the following: + - **GitHub App name**: e.g., `OpenCodeReview Bot` + - **Homepage URL**: Your repository or documentation URL + - **Webhook**: Uncheck "Active" (not needed for this use case) +3. Under **Repository permissions**, set: + - **Pull requests**: Read and write + - **Contents**: Read-only (for fetching diffs) + - **Metadata**: Read-only (required) +4. Click **Create GitHub App** + +#### Step 2: Generate a Private Key + +1. After creating the app, scroll down to **Private keys** +2. Click **Generate a private key** +3. Download and save the `.pem` file securely + +Note your App ID from the app settings page. + +#### Step 3: Install the App + +1. In the left sidebar, click **Install App** +2. Select the repositories where you want to use OCR +3. After installation, note the **Installation ID** from the URL (e.g., `https://github.com/settings/installations/12345` → Installation ID is `12345`) + +#### Step 4: Configure Repository Secrets + +Add the following secrets to your repository (**Settings → Secrets and variables → Actions**): + +| Secret | Description | +|--------|-------------| +| `GITHUB_APP_ID` | Your GitHub App's ID | +| `GITHUB_APP_PRIVATE_KEY` | Contents of the `.pem` file (including `-----BEGIN RSA PRIVATE KEY-----` and `-----END RSA PRIVATE KEY-----`) | +| `GITHUB_APP_INSTALLATION_ID` | The Installation ID from Step 3 | + +#### Step 5: Update the Workflow + +Add a step to obtain a token from the GitHub App, then use it in the "Post review comments to PR" step: + +```yaml +- name: Get GitHub App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GITHUB_APP_ID }} + private-key: ${{ secrets.GITHUB_APP_PRIVATE_KEY }} + +- name: Post review comments to PR + uses: actions/github-script@v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + # ... existing script +``` + +Now review comments will be posted with your custom GitHub App identity (e.g., `OpenCodeReview Bot`), providing a more professional and distinguishable appearance in your PRs. + +## Example Output + +When a PR is reviewed, comments appear directly in the PR's "Files changed" tab: + +- ✅ If no issues found: A comment saying "No comments generated. Looks good to me." +- 🔍 If issues found: Inline review comments with suggestions using GitHub's native suggestion syntax + +### Inline Comment Example + +The workflow uses GitHub's `suggestion` code block syntax, so reviewers can apply fixes with one click: + +````markdown +**Suggestion:** +```suggestion +// Fixed code here +``` +```` + +## Supported LLM Providers + +OCR supports both OpenAI and Anthropic API formats: + +- **OpenAI-compatible APIs** (default): + - OpenAI (GPT-4o, GPT-4, etc.) + - Azure OpenAI + - Self-hosted models (vLLM, Ollama, etc.) +- **Anthropic APIs** (set `OCR_LLM_USE_ANTHROPIC: true`): + - Anthropic Claude models + +## Troubleshooting + +### Common Issues + +1. **"Failed to parse OCR output"**: Check that `OCR_LLM_URL` and `OCR_LLM_AUTH_TOKEN` secrets are correctly set +2. **"Cannot find merge-base"**: Ensure `fetch-depth: 0` is set in the checkout step +3. **Review comments not appearing on correct lines**: This can happen when the diff has changed since the review started; the workflow handles this gracefully with a fallback to issue comments + +### Debugging + +Enable debug logging by adding to the OCR review step: + +```yaml +env: + OCR_DEBUG: "1" +``` diff --git a/examples/github_actions/ocr-review.yml b/examples/github_actions/ocr-review.yml new file mode 100644 index 0000000..34e0b9d --- /dev/null +++ b/examples/github_actions/ocr-review.yml @@ -0,0 +1,321 @@ +# 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, synchronized, or reopened +# - 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 + +on: + pull_request: + 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' || + (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' + 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_name != 'pull_request' && steps.pr-context.outputs.head_sha || '' }} + + - 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 and head refs from PR context (different for comment triggers) + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE_REF="${{ github.event.pull_request.base.ref }}" + HEAD_REF="${{ github.event.pull_request.head.ref }}" + else + BASE_REF="${{ steps.pr-context.outputs.base_ref }}" + HEAD_REF="${{ steps.pr-context.outputs.head_ref }}" + fi + + echo "Reviewing PR: ${HEAD_REF} against ${BASE_REF}" + + # Run OCR in range mode with JSON output + ocr review \ + --from "origin/${BASE_REF}" \ + --to "origin/${HEAD_REF}" \ + --format json \ + --audience agent \ + > /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 (skip first line which is not valid JSON) + let result; + try { + const raw = fs.readFileSync(path, 'utf8'); + const jsonContent = raw.substring(raw.indexOf('\n') + 1); + result = JSON.parse(jsonContent); + } 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\`\`\`\n${stderr}\n\`\`\`` + }); + } + 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') { + 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(reviewComment); + } + + // Submit as a single PR review with all comments + const totalCount = comments.length; + const inlineCount = reviewComments.length; + const summaryCount = commentsWithoutLine.length; + let summaryBody = `🔍 **OpenCodeReview** found **${totalCount}** issue(s) in this PR.`; + if (totalCount > 0) { + summaryBody += `\n- ✅ ${inlineCount} posted as inline comment(s)`; + summaryBody += `\n- 📝 ${summaryCount} posted as summary (missing line info)`; + } + if (warnings.length > 0) { + summaryBody += `\n\n⚠️ ${warnings.length} warning(s) occurred during review.`; + } + + // Add comments without line info to summary body + for (const { comment, body } of commentsWithoutLine) { + summaryBody += '\n\n---\n\n'; + summaryBody += formatCommentMarkdown(comment); + } + + // 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 + }); + 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 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: reviewComment, error: innerE.message }); + console.log(`Failed to post comment for ${reviewComment.path}: ${innerE.message}`); + } + } + + // Post summary comment with statistics + let finalBody = summaryBody; + 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 details + if (failedComments.length > 0) { + finalBody += '\n\n
❌ Failed Comments Details\n\n'; + for (const { comment, error } of failedComments) { + finalBody += `- \`${comment.path}\`: ${error}\n`; + } + finalBody += '\n
'; + } + + 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 += '```suggestion\n'; + body += comment.suggestion_code; + if (!comment.suggestion_code.endsWith('\n')) body += '\n'; + body += '```'; + } + + return body; + } + + function formatCommentMarkdown(comment) { + let md = `### 📄 \`${comment.path}\``; + if (comment.start_line && comment.end_line) { + md += ` (L${comment.start_line}-L${comment.end_line})`; + } + md += '\n\n'; + md += comment.content || ''; + + if (comment.suggestion_code && comment.existing_code) { + md += '\n\n
💡 Suggested Change\n\n'; + md += '**Before:**\n```\n' + comment.existing_code + '\n```\n\n'; + md += '**After:**\n```\n' + comment.suggestion_code + '\n```\n\n'; + md += '
'; + } + + return md; + } diff --git a/examples/gitlab_ci/.gitlab-ci.yml b/examples/gitlab_ci/.gitlab-ci.yml new file mode 100644 index 0000000..7d915bd --- /dev/null +++ b/examples/gitlab_ci/.gitlab-ci.yml @@ -0,0 +1,225 @@ +# OpenCodeReview - GitLab CI Merge Request Auto-Review Demo +# +# This pipeline automatically reviews Merge Requests using OpenCodeReview +# and posts review comments (discussions) directly on the MR diff. +# +# Required CI/CD Variables (Settings → CI/CD → Variables): +# 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 (mark as "Masked") +# GITLAB_API_TOKEN - GitLab Personal/Project Access Token with "api" scope +# +# Optional CI/CD Variables: +# OCR_LLM_MODEL - Model name (default: gpt-4o) + +stages: + - review + +code-review: + stage: review + image: node:20 + only: + - merge_requests + variables: + GIT_DEPTH: 0 # Full history needed for merge-base diff + script: + # Install OpenCodeReview + - npm install -g @alibaba-group/open-code-review + + # Configure OCR + - mkdir -p ~/.open-code-review + # Gitlab CI/CD does not support confuring variables with value length less than 8, so you can't set use_anthropic as a CI variable + - | + ocr config set llm.url $OCR_LLM_URL + ocr config set llm.auth_token $OCR_LLM_AUTH_TOKEN + ocr config set llm.model $OCR_LLM_MODEL + ocr config set llm.use_anthropic false + ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}' + + # Run OCR review + - | + echo "Reviewing MR: ${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME} against ${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}" + ocr review \ + --from "origin/${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}" \ + --to "origin/${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}" \ + --format json \ + --audience agent \ + > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true + echo "OCR review completed." + cat /tmp/ocr-result.json + + # Post review comments to MR + - | + python3 << 'PYTHON_SCRIPT' + import json + import os + import sys + import urllib.request + import urllib.error + + GITLAB_URL = os.environ.get("CI_SERVER_URL", "https://gitlab.com") + PROJECT_ID = os.environ["CI_PROJECT_ID"] + MR_IID = os.environ["CI_MERGE_REQUEST_IID"] + API_TOKEN = os.environ["GITLAB_API_TOKEN"] + SOURCE_BRANCH = os.environ["CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"] + TARGET_BRANCH = os.environ["CI_MERGE_REQUEST_TARGET_BRANCH_NAME"] + COMMIT_SHA = os.environ["CI_COMMIT_SHA"] + + API_BASE = f"{GITLAB_URL}/api/v4/projects/{PROJECT_ID}/merge_requests/{MR_IID}" + + def api_request(endpoint, data=None, method="POST"): + """Make a GitLab API request.""" + url = f"{API_BASE}{endpoint}" + headers = { + "PRIVATE-TOKEN": API_TOKEN, + "Content-Type": "application/json" + } + body = json.dumps(data).encode("utf-8") if data else None + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + print(f"API error {e.code}: {e.read().decode('utf-8')}", file=sys.stderr) + return None + + def post_note(body): + """Post a general note/comment on the MR.""" + return api_request("/notes", {"body": body}) + + def post_discussion(path, line, body, base_sha=None, start_sha=None, head_sha=None): + """Post an inline discussion on a specific file/line in the MR diff.""" + position = { + "position_type": "text", + "new_path": path, + "old_path": path, + "new_line": line, + "base_sha": base_sha or TARGET_BRANCH, + "start_sha": start_sha or TARGET_BRANCH, + "head_sha": head_sha or COMMIT_SHA, + } + data = { + "body": body, + "position": position + } + return api_request("/discussions", data) + + def format_comment(comment): + """Format a single review comment as markdown.""" + body = comment.get("content", "") + + existing = comment.get("existing_code", "") + suggestion = comment.get("suggestion_code", "") + if suggestion and existing: + body += "\n\n**Suggestion:**\n" + body += f"```suggestion:-0+0\n{suggestion}\n```" + + return body + + def format_comment_fallback(comment): + """Format a comment for fallback (non-inline) display.""" + path = comment.get("path", "unknown") + start_line = comment.get("start_line", 0) + end_line = comment.get("end_line", 0) + content = comment.get("content", "") + + md = f"### 📄 `{path}`" + if start_line and end_line: + md += f" (L{start_line}-L{end_line})" + md += f"\n\n{content}" + + existing = comment.get("existing_code", "") + suggestion = comment.get("suggestion_code", "") + if suggestion and existing: + md += "\n\n
💡 Suggested Change\n\n" + md += f"**Before:**\n```\n{existing}\n```\n\n" + md += f"**After:**\n```\n{suggestion}\n```\n\n" + md += "
" + + return md + + # --- Main --- + + # Read OCR result (skip first line which is summary, not JSON) + try: + with open("/tmp/ocr-result.json", "r") as f: + next(f) # Skip first line + result = json.load(f) + except (FileNotFoundError, json.JSONDecodeError) as e: + print(f"Failed to parse OCR output: {e}", file=sys.stderr) + stderr_content = "" + try: + with open("/tmp/ocr-stderr.log", "r") as f: + stderr_content = f.read().strip() + except FileNotFoundError: + pass + if stderr_content: + post_note(f"⚠️ **OpenCodeReview** encountered an error:\n```\n{stderr_content}\n```") + sys.exit(0) + + comments = result.get("comments", []) + warnings = result.get("warnings", []) + + # No comments - post summary + if not comments: + message = result.get("message", "No comments generated. Looks good to me.") + post_note(f"✅ **OpenCodeReview**: {message}") + print("No review comments to post.") + sys.exit(0) + + # Get MR diff metadata for position calculation + diff_refs = None + try: + versions_url = f"{API_BASE}/versions" + req = urllib.request.Request(versions_url, headers={"PRIVATE-TOKEN": API_TOKEN}) + with urllib.request.urlopen(req) as resp: + versions = json.loads(resp.read().decode("utf-8")) + if versions: + latest = versions[0] + diff_refs = { + "base_sha": latest.get("base_commit_sha", ""), + "start_sha": latest.get("start_commit_sha", ""), + "head_sha": latest.get("head_commit_sha", ""), + } + except Exception as e: + print(f"Warning: Could not fetch MR versions: {e}", file=sys.stderr) + + # Post inline discussions for each comment + success_count = 0 + failed_comments = [] + + for comment in comments: + path = comment.get("path", "") + end_line = comment.get("end_line", 0) + start_line = comment.get("start_line", end_line) + body = format_comment(comment) + + if not path or not end_line: + failed_comments.append(comment) + continue + + kwargs = {} + if diff_refs: + kwargs = diff_refs + + result_resp = post_discussion(path, end_line, body, **kwargs) + if result_resp: + success_count += 1 + else: + failed_comments.append(comment) + + print(f"Successfully posted {success_count}/{len(comments)} inline comments.") + + # Post fallback for any failed inline comments + if failed_comments: + fallback_body = f"🔍 **OpenCodeReview** found issues that could not be posted inline:\n\n---\n\n" + for comment in failed_comments: + fallback_body += format_comment_fallback(comment) + "\n\n---\n\n" + post_note(fallback_body) + + # Post summary + summary = f"🔍 **OpenCodeReview** found **{len(comments)}** issue(s) in this MR." + if warnings: + summary += f"\n\n⚠️ {len(warnings)} warning(s) occurred during review." + post_note(summary) + + PYTHON_SCRIPT diff --git a/examples/gitlab_ci/README.md b/examples/gitlab_ci/README.md new file mode 100644 index 0000000..ae1615f --- /dev/null +++ b/examples/gitlab_ci/README.md @@ -0,0 +1,268 @@ +# OpenCodeReview - GitLab CI Demo + +This demo shows how to integrate OpenCodeReview into your GitLab CI/CD pipeline to automatically review Merge Requests and post review comments as inline discussions. + +## How It Works + +``` +MR Created/Updated → GitLab Pipeline Triggered → OCR Reviews Diff → Discussions Posted on MR +``` + +1. When a Merge Request is opened or updated, the pipeline triggers +2. It installs OCR via npm in a `node:20` Docker image +3. Runs `ocr review --from origin/ --to origin/ --format json` to analyze the diff +4. Parses the JSON output and posts inline discussions on the MR using GitLab's Discussions API + +## Setup + +### 1. Copy the pipeline file + +Copy `.gitlab-ci.yml` to your repository root (or include it via `include:`): + +```bash +cp .gitlab-ci.yml /path/to/your/repo/.gitlab-ci.yml +``` + +Or use GitLab's `include` feature in your existing `.gitlab-ci.yml`: + +```yaml +include: + - local: 'ci_demo/gitlab_ci/.gitlab-ci.yml' +``` + +### 2. Configure CI/CD Variables + +Go to your project's **Settings → CI/CD → Variables** and add: + +| Variable | Required | Masked | Description | +|----------|----------|--------|-------------| +| `OCR_LLM_URL` | Yes | No | LLM API endpoint URL (e.g., `https://api.openai.com/v1/chat/completions`) | +| `OCR_LLM_AUTH_TOKEN` | Yes | Yes | API authentication token | +| `OCR_LLM_MODEL` | No | No | Model name (defaults to `gpt-4o`) | +| `GITLAB_API_TOKEN` | Yes | Yes | GitLab access token with `api` scope | + +> **Note:** GitLab CI/CD does not support variables with values shorter than 8 characters, so `use_anthropic` cannot be set as a CI variable. The pipeline sets it to `false` by default. If you need to use Anthropic Claude models, you'll need to modify the `.gitlab-ci.yml` script directly. +> +> The pipeline also configures `llm.extra_body` to disable thinking mode for compatibility with various LLM providers. + +### 3. Create a GitLab Access Token + +You need a token with `api` scope to post discussions on MRs. Options: + +- **Project Access Token** (recommended): Settings → Access Tokens → Create with `api` scope +- **Personal Access Token**: User Settings → Access Tokens → Create with `api` scope +- **Group Access Token**: For organization-wide usage + +> **Note:** The built-in `CI_JOB_TOKEN` does NOT have sufficient permissions to create MR discussions, which is why a separate token is needed. +> +> **Tip:** For Project Access Tokens and Group Access Tokens, the token name determines the bot name shown in MR discussions. For example, naming your token `OpenCodeReview Bot` will make review comments appear as posted by `OpenCodeReview Bot`. + +## Example Output + +When an MR is reviewed, comments appear as: + +- **Inline discussions**: Directly on the changed lines in the MR diff view +- **Summary note**: A final note summarizing the total number of issues found +- **Fallback notes**: If inline posting fails for specific comments, they appear as regular MR notes with file/line references + +### Inline Discussion Example + +Comments are posted using GitLab's Discussion API with position data, so they appear directly next to the relevant code in the "Changes" tab. + +## Supported LLM Providers + +OCR supports both OpenAI and Anthropic API formats: + +- **OpenAI-compatible APIs** (default): + - OpenAI (GPT-4o, GPT-4, etc.) + - Azure OpenAI + - Self-hosted models (vLLM, Ollama, etc.) +- **Anthropic APIs** (modify `.gitlab-ci.yml` to set `use_anthropic: true`): + - Anthropic Claude models + +## Customization + +### Use a specific OCR version + +```yaml +script: + - npm install -g @alibaba-group/open-code-review@1.0.0 +``` + +### Add custom review rules + +Use the `--rule` flag to pass a custom rules JSON file: + +```yaml +script: + - ocr review --rule ./my-rules.json --from origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME --to origin/$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME +``` + +### Limit concurrency + +Adjust the `--concurrency` flag for large MRs to control the number of concurrent LLM requests: + +```yaml +script: + - ocr review --concurrency 5 --from origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME --to origin/$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME +``` + +### Provide background context + +Use the `--background` flag to pass additional context that helps OCR better understand the purpose of the changes: + +```yaml +script: + - ocr review --background "$CI_MERGE_REQUEST_TITLE" --from origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME --to origin/$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME +``` + +This is particularly useful when your MR titles follow semantic conventions (e.g., `feat(auth): add OAuth2 support`) that clearly summarize what the MR implements. The background information helps OCR provide more relevant and context-aware review comments. + +### Change the trigger events + +By default, the pipeline uses `only: [merge_requests]`, which triggers on **all** MR events (creation, updates, reopen). GitLab CI does not natively support fine-grained control to trigger **only on MR creation**. + +To avoid re-reviewing on every push to an existing MR (and wasting LLM API tokens), you can check for existing OCR reviews **before** running `ocr review`. Use a wrapper script that skips the review step if OCR comments already exist: + +```yaml +script: + # Install OpenCodeReview + - npm install -g @alibaba-group/open-code-review + + # Configure OCR + - mkdir -p ~/.open-code-review + - | + ocr config set llm.url $OCR_LLM_URL + ocr config set llm.auth_token $OCR_LLM_AUTH_TOKEN + ocr config set llm.model $OCR_LLM_MODEL + ocr config set llm.use_anthropic false + ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}' + + # Check for existing OCR reviews and run review only if not found + - | + python3 << 'WRAPPER_SCRIPT' + import json + import os + import subprocess + import sys + import urllib.request + + GITLAB_URL = os.environ.get("CI_SERVER_URL", "https://gitlab.com") + PROJECT_ID = os.environ["CI_PROJECT_ID"] + MR_IID = os.environ["CI_MERGE_REQUEST_IID"] + API_TOKEN = os.environ["GITLAB_API_TOKEN"] + SOURCE_BRANCH = os.environ["CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"] + TARGET_BRANCH = os.environ["CI_MERGE_REQUEST_TARGET_BRANCH_NAME"] + + # Check for existing OCR reviews + url = f"{GITLAB_URL}/api/v4/projects/{PROJECT_ID}/merge_requests/{MR_IID}/notes?per_page=100" + req = urllib.request.Request(url, headers={"PRIVATE-TOKEN": API_TOKEN}) + with urllib.request.urlopen(req) as resp: + notes = json.loads(resp.read().decode("utf-8")) + + for note in notes: + if "OpenCodeReview" in note.get("body", ""): + print("⏭️ OCR has already reviewed this MR. Skipping to save tokens.") + print("Delete previous OCR comments to re-trigger review.") + sys.exit(0) + + # No existing review found - run OCR + print("🔍 No existing OCR review found. Running review...") + result = subprocess.run([ + "ocr", "review", + "--from", f"origin/{TARGET_BRANCH}", + "--to", f"origin/{SOURCE_BRANCH}", + "--format", "json", + "--audience", "agent" + ], capture_output=True, text=True) + + # Save output for the posting script + with open("/tmp/ocr-result.json", "w") as f: + f.write(result.stdout) + with open("/tmp/ocr-stderr.log", "w") as f: + f.write(result.stderr) + + print("OCR review completed.") + WRAPPER_SCRIPT + + # Post review comments to MR + - | + python3 << 'PYTHON_SCRIPT' + ...existing post script... + PYTHON_SCRIPT +``` + +The key logic: the Python wrapper checks for existing OCR comments before running `ocr review`. If found, it exits early with `sys.exit(0)` before consuming any LLM tokens. To re-trigger a review, users can manually delete the previous OCR comments. + +### Self-hosted GitLab + +The script automatically uses `CI_SERVER_URL` to determine the GitLab API base URL, so it works with self-hosted GitLab instances out of the box. + +### Use a Service Account as Review Bot + +By default, review comments are posted using the user who owns the access token configured in `GITLAB_API_TOKEN`. You can create a dedicated service account bot to post reviews with a custom identity, making it easier to distinguish automated reviews from human comments. + +For more details about GitLab service accounts, see the [GitLab Service Accounts documentation](https://docs.gitlab.com/ee/user/profile/service_accounts.html). + +#### Step 1: Create a Service Account + +Create a service account in your project: + +1. Go to your **Project → Settings → Service Accounts** +2. Click **New service account** +3. Fill in the following: + - **Name**: e.g., `OpenCodeReview Bot` (this will be the bot name shown in MR discussions) + - **Username**: Will be auto-generated based on the name +4. Click **Create service account** + +#### Step 2: Invite the Service Account to Your Project + +After the service account is created, invite it to your project with appropriate permissions: + +1. Go to your **Project → Settings → Members** +2. Click **Invite member** +3. Search for the service account by name (e.g., `OpenCodeReview Bot`) +4. Select the service account and assign a role (`Developer` or `Maintainer` required for posting discussions) +5. Click **Invite** + +#### Step 3: Create an Access Token + +Generate an access token for the service account: + +1. Go to your **Project → Settings → Service Accounts** +2. Click on the service account to view its details +3. Click **Add new token** +4. Configure the token: + - **Name**: e.g., `ocr-review-token` + - **Expiration**: As needed + - **Scope**: Select `api` (required for Discussions API) +5. Click **Create token** and copy the token value + +#### Step 4: Update CI/CD Variables + +Update the `GITLAB_API_TOKEN` variable in your project's CI/CD settings: + +Go to **Settings → CI/CD → Variables** and update `GITLAB_API_TOKEN` with the service account's token. + +Now review comments will be posted with your service account identity (e.g., `OpenCodeReview Bot`), providing a clear and professional appearance for automated code reviews. + +## Troubleshooting + +### Common Issues + +1. **"API error 403"**: The `GITLAB_API_TOKEN` lacks `api` scope or doesn't have access to the project +2. **"Failed to parse OCR output"**: Check that `OCR_LLM_URL` and `OCR_LLM_AUTH_TOKEN` variables are correctly set +3. **"Cannot find merge-base"**: Ensure `GIT_DEPTH: 0` is set (full clone) +4. **Inline comments on wrong lines**: GitLab requires exact SHA matching; the script fetches MR version metadata to get correct diff refs + +### Debugging + +Add verbose output to the review step: + +```yaml +script: + - cat /tmp/ocr-result.json + - cat /tmp/ocr-stderr.log +``` + +