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
+```
+
+