@alibaba-group/open-code-review 1.1.8 → 1.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,321 @@
1
+ # OpenCodeReview - GitHub Actions PR Auto-Review Demo
2
+ #
3
+ # This workflow automatically reviews pull requests using OpenCodeReview
4
+ # and posts review comments directly on the PR.
5
+ #
6
+ # Triggers:
7
+ # - PR opened, synchronized, or reopened
8
+ # - Comment on PR containing '/open-code-review' or '@open-code-review'
9
+ #
10
+ # Required secrets:
11
+ # OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions)
12
+ # OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API
13
+ #
14
+ # Optional secrets:
15
+ # OCR_LLM_MODEL - Model name (default: gpt-4o)
16
+ #
17
+ # Note: GITHUB_TOKEN is automatically provided by GitHub Actions.
18
+
19
+ name: OpenCodeReview PR Review
20
+
21
+ on:
22
+ pull_request:
23
+ types: [opened]
24
+ issue_comment:
25
+ types: [created]
26
+
27
+ permissions:
28
+ contents: read
29
+ pull-requests: write
30
+
31
+ jobs:
32
+ code-review:
33
+ runs-on: ubuntu-latest
34
+ # Run on PR events, or on comments starting with trigger keywords
35
+ if: |
36
+ github.event_name == 'pull_request' ||
37
+ (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/open-code-review')) ||
38
+ (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '@open-code-review'))
39
+ steps:
40
+ - name: Get PR context
41
+ id: pr-context
42
+ if: github.event_name != 'pull_request'
43
+ uses: actions/github-script@v7
44
+ with:
45
+ script: |
46
+ // For issue_comment events, get PR info
47
+ const prNumber = context.issue.number;
48
+ const { data: pullRequest } = await github.rest.pulls.get({
49
+ owner: context.repo.owner,
50
+ repo: context.repo.repo,
51
+ pull_number: prNumber
52
+ });
53
+ core.setOutput('base_ref', pullRequest.base.ref);
54
+ core.setOutput('head_ref', pullRequest.head.ref);
55
+ core.setOutput('head_sha', pullRequest.head.sha);
56
+
57
+ - name: Checkout repository
58
+ uses: actions/checkout@v4
59
+ with:
60
+ fetch-depth: 0 # Full history needed for merge-base diff
61
+ ref: ${{ github.event_name != 'pull_request' && steps.pr-context.outputs.head_sha || '' }}
62
+
63
+ - name: Setup Node.js
64
+ uses: actions/setup-node@v4
65
+ with:
66
+ node-version: '20'
67
+
68
+ - name: Install OpenCodeReview
69
+ run: npm install -g @alibaba-group/open-code-review
70
+
71
+ - name: Configure OCR
72
+ run: |
73
+ ocr config set llm.url ${{ secrets.OCR_LLM_URL }}
74
+ ocr config set llm.auth_token ${{ secrets.OCR_LLM_AUTH_TOKEN }}
75
+ ocr config set llm.model ${{ secrets.OCR_LLM_MODEL }}
76
+ ocr config set llm.use_anthropic ${{ secrets.OCR_LLM_USE_ANTHROPIC }}
77
+ ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}'
78
+
79
+ - name: Run OpenCodeReview
80
+ id: review
81
+ run: |
82
+ # Get base and head refs from PR context (different for comment triggers)
83
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
84
+ BASE_REF="${{ github.event.pull_request.base.ref }}"
85
+ HEAD_REF="${{ github.event.pull_request.head.ref }}"
86
+ else
87
+ BASE_REF="${{ steps.pr-context.outputs.base_ref }}"
88
+ HEAD_REF="${{ steps.pr-context.outputs.head_ref }}"
89
+ fi
90
+
91
+ echo "Reviewing PR: ${HEAD_REF} against ${BASE_REF}"
92
+
93
+ # Run OCR in range mode with JSON output
94
+ ocr review \
95
+ --from "origin/${BASE_REF}" \
96
+ --to "origin/${HEAD_REF}" \
97
+ --format json \
98
+ --audience agent \
99
+ > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true
100
+
101
+ echo "OCR review completed. Output:"
102
+ cat /tmp/ocr-result.json
103
+ echo "OCR review completed. Error log:"
104
+ cat /tmp/ocr-stderr.log
105
+
106
+ - name: Post review comments to PR
107
+ uses: actions/github-script@v7
108
+ with:
109
+ github-token: ${{ secrets.GITHUB_TOKEN }}
110
+ script: |
111
+ const fs = require('fs');
112
+ const path = '/tmp/ocr-result.json';
113
+
114
+ // Read OCR output (skip first line which is not valid JSON)
115
+ let result;
116
+ try {
117
+ const raw = fs.readFileSync(path, 'utf8');
118
+ const jsonContent = raw.substring(raw.indexOf('\n') + 1);
119
+ result = JSON.parse(jsonContent);
120
+ } catch (e) {
121
+ console.log('Failed to parse OCR output:', e.message);
122
+ // Post a simple comment if parsing fails
123
+ const stderr = fs.readFileSync('/tmp/ocr-stderr.log', 'utf8').trim();
124
+ if (stderr) {
125
+ await github.rest.issues.createComment({
126
+ owner: context.repo.owner,
127
+ repo: context.repo.repo,
128
+ issue_number: context.issue.number,
129
+ body: `āš ļø **OpenCodeReview** encountered an error:\n\`\`\`\n${stderr}\n\`\`\``
130
+ });
131
+ }
132
+ return;
133
+ }
134
+
135
+ const comments = result.comments || [];
136
+ const warnings = result.warnings || [];
137
+
138
+ // If no comments, post a summary
139
+ if (comments.length === 0) {
140
+ const message = result.message || 'No comments generated. Looks good to me.';
141
+ await github.rest.issues.createComment({
142
+ owner: context.repo.owner,
143
+ repo: context.repo.repo,
144
+ issue_number: context.issue.number,
145
+ body: `āœ… **OpenCodeReview**: ${message}`
146
+ });
147
+ return;
148
+ }
149
+
150
+ // Prepare PR review with inline comments
151
+ const prNumber = context.issue.number;
152
+ let commitSha;
153
+
154
+ // Get commit SHA from event context
155
+ if (context.eventName === 'pull_request') {
156
+ commitSha = context.payload.pull_request.head.sha;
157
+ } else {
158
+ // For comment events, we need to fetch the PR
159
+ const { data: pullRequest } = await github.rest.pulls.get({
160
+ owner: context.repo.owner,
161
+ repo: context.repo.repo,
162
+ pull_number: prNumber
163
+ });
164
+ commitSha = pullRequest.head.sha;
165
+ }
166
+
167
+ // Build review comments array for the PR review API
168
+ // Only inline comments with line info can be posted via createReview
169
+ const reviewComments = [];
170
+ const commentsWithoutLine = [];
171
+
172
+ for (const comment of comments) {
173
+ const body = formatComment(comment);
174
+
175
+ // Check if comment has valid line information for inline comment (line >= 1)
176
+ const hasValidLine = (comment.start_line >= 1) || (comment.end_line >= 1);
177
+ if (!hasValidLine) {
178
+ commentsWithoutLine.push({ comment, body });
179
+ continue;
180
+ }
181
+
182
+ const reviewComment = {
183
+ path: comment.path,
184
+ body: body
185
+ };
186
+
187
+ // Use line range if available
188
+ if (comment.start_line >= 1 && comment.end_line >= 1 && comment.start_line !== comment.end_line) {
189
+ reviewComment.start_line = comment.start_line;
190
+ reviewComment.line = comment.end_line;
191
+ reviewComment.start_side = 'RIGHT';
192
+ reviewComment.side = 'RIGHT';
193
+ } else if (comment.end_line >= 1) {
194
+ reviewComment.line = comment.end_line;
195
+ reviewComment.side = 'RIGHT';
196
+ } else if (comment.start_line >= 1) {
197
+ reviewComment.line = comment.start_line;
198
+ reviewComment.side = 'RIGHT';
199
+ }
200
+
201
+ reviewComments.push(reviewComment);
202
+ }
203
+
204
+ // Submit as a single PR review with all comments
205
+ const totalCount = comments.length;
206
+ const inlineCount = reviewComments.length;
207
+ const summaryCount = commentsWithoutLine.length;
208
+ let summaryBody = `šŸ” **OpenCodeReview** found **${totalCount}** issue(s) in this PR.`;
209
+ if (totalCount > 0) {
210
+ summaryBody += `\n- āœ… ${inlineCount} posted as inline comment(s)`;
211
+ summaryBody += `\n- šŸ“ ${summaryCount} posted as summary (missing line info)`;
212
+ }
213
+ if (warnings.length > 0) {
214
+ summaryBody += `\n\nāš ļø ${warnings.length} warning(s) occurred during review.`;
215
+ }
216
+
217
+ // Add comments without line info to summary body
218
+ for (const { comment, body } of commentsWithoutLine) {
219
+ summaryBody += '\n\n---\n\n';
220
+ summaryBody += formatCommentMarkdown(comment);
221
+ }
222
+
223
+ // Statistics tracking
224
+ let successCount = 0;
225
+ let failedCount = 0;
226
+ const failedComments = [];
227
+
228
+ try {
229
+ await github.rest.pulls.createReview({
230
+ owner: context.repo.owner,
231
+ repo: context.repo.repo,
232
+ pull_number: prNumber,
233
+ commit_id: commitSha,
234
+ body: summaryBody,
235
+ event: 'COMMENT',
236
+ comments: reviewComments
237
+ });
238
+ successCount = reviewComments.length;
239
+ console.log(`Successfully posted review with ${successCount} inline comments (${commentsWithoutLine.length} in summary)`);
240
+ } catch (e) {
241
+ console.log('Failed to post review with inline comments:', e.message);
242
+ console.log('Falling back to posting comments individually...');
243
+
244
+ // Fallback: post comments one by one
245
+ for (const reviewComment of reviewComments) {
246
+ try {
247
+ await github.rest.pulls.createReview({
248
+ owner: context.repo.owner,
249
+ repo: context.repo.repo,
250
+ pull_number: prNumber,
251
+ commit_id: commitSha,
252
+ body: '',
253
+ event: 'COMMENT',
254
+ comments: [reviewComment]
255
+ });
256
+ successCount++;
257
+ console.log(`Successfully posted comment for ${reviewComment.path}`);
258
+ } catch (innerE) {
259
+ failedCount++;
260
+ failedComments.push({ comment: reviewComment, error: innerE.message });
261
+ console.log(`Failed to post comment for ${reviewComment.path}: ${innerE.message}`);
262
+ }
263
+ }
264
+
265
+ // Post summary comment with statistics
266
+ let finalBody = summaryBody;
267
+ finalBody += `\n\n---\n\nšŸ“Š **Posting Statistics:**`;
268
+ finalBody += `\n- āœ… Successfully posted: ${successCount} comment(s)`;
269
+ if (failedCount > 0) {
270
+ finalBody += `\n- āŒ Failed to post: ${failedCount} comment(s)`;
271
+ }
272
+
273
+ // Add failed comments details
274
+ if (failedComments.length > 0) {
275
+ finalBody += '\n\n<details><summary>āŒ Failed Comments Details</summary>\n\n';
276
+ for (const { comment, error } of failedComments) {
277
+ finalBody += `- \`${comment.path}\`: ${error}\n`;
278
+ }
279
+ finalBody += '\n</details>';
280
+ }
281
+
282
+ await github.rest.issues.createComment({
283
+ owner: context.repo.owner,
284
+ repo: context.repo.repo,
285
+ issue_number: prNumber,
286
+ body: finalBody
287
+ });
288
+ }
289
+
290
+ function formatComment(comment) {
291
+ let body = comment.content || '';
292
+
293
+ // Add code suggestion if available
294
+ if (comment.suggestion_code && comment.existing_code) {
295
+ body += '\n\n**Suggestion:**\n';
296
+ body += '```suggestion\n';
297
+ body += comment.suggestion_code;
298
+ if (!comment.suggestion_code.endsWith('\n')) body += '\n';
299
+ body += '```';
300
+ }
301
+
302
+ return body;
303
+ }
304
+
305
+ function formatCommentMarkdown(comment) {
306
+ let md = `### šŸ“„ \`${comment.path}\``;
307
+ if (comment.start_line && comment.end_line) {
308
+ md += ` (L${comment.start_line}-L${comment.end_line})`;
309
+ }
310
+ md += '\n\n';
311
+ md += comment.content || '';
312
+
313
+ if (comment.suggestion_code && comment.existing_code) {
314
+ md += '\n\n<details><summary>šŸ’” Suggested Change</summary>\n\n';
315
+ md += '**Before:**\n```\n' + comment.existing_code + '\n```\n\n';
316
+ md += '**After:**\n```\n' + comment.suggestion_code + '\n```\n\n';
317
+ md += '</details>';
318
+ }
319
+
320
+ return md;
321
+ }
@@ -0,0 +1,225 @@
1
+ # OpenCodeReview - GitLab CI Merge Request Auto-Review Demo
2
+ #
3
+ # This pipeline automatically reviews Merge Requests using OpenCodeReview
4
+ # and posts review comments (discussions) directly on the MR diff.
5
+ #
6
+ # Required CI/CD Variables (Settings → CI/CD → Variables):
7
+ # OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions)
8
+ # OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API (mark as "Masked")
9
+ # GITLAB_API_TOKEN - GitLab Personal/Project Access Token with "api" scope
10
+ #
11
+ # Optional CI/CD Variables:
12
+ # OCR_LLM_MODEL - Model name (default: gpt-4o)
13
+
14
+ stages:
15
+ - review
16
+
17
+ code-review:
18
+ stage: review
19
+ image: node:20
20
+ only:
21
+ - merge_requests
22
+ variables:
23
+ GIT_DEPTH: 0 # Full history needed for merge-base diff
24
+ script:
25
+ # Install OpenCodeReview
26
+ - npm install -g @alibaba-group/open-code-review
27
+
28
+ # Configure OCR
29
+ - mkdir -p ~/.open-code-review
30
+ # 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
31
+ - |
32
+ ocr config set llm.url $OCR_LLM_URL
33
+ ocr config set llm.auth_token $OCR_LLM_AUTH_TOKEN
34
+ ocr config set llm.model $OCR_LLM_MODEL
35
+ ocr config set llm.use_anthropic false
36
+ ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}'
37
+
38
+ # Run OCR review
39
+ - |
40
+ echo "Reviewing MR: ${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME} against ${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}"
41
+ ocr review \
42
+ --from "origin/${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}" \
43
+ --to "origin/${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}" \
44
+ --format json \
45
+ --audience agent \
46
+ > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true
47
+ echo "OCR review completed."
48
+ cat /tmp/ocr-result.json
49
+
50
+ # Post review comments to MR
51
+ - |
52
+ python3 << 'PYTHON_SCRIPT'
53
+ import json
54
+ import os
55
+ import sys
56
+ import urllib.request
57
+ import urllib.error
58
+
59
+ GITLAB_URL = os.environ.get("CI_SERVER_URL", "https://gitlab.com")
60
+ PROJECT_ID = os.environ["CI_PROJECT_ID"]
61
+ MR_IID = os.environ["CI_MERGE_REQUEST_IID"]
62
+ API_TOKEN = os.environ["GITLAB_API_TOKEN"]
63
+ SOURCE_BRANCH = os.environ["CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"]
64
+ TARGET_BRANCH = os.environ["CI_MERGE_REQUEST_TARGET_BRANCH_NAME"]
65
+ COMMIT_SHA = os.environ["CI_COMMIT_SHA"]
66
+
67
+ API_BASE = f"{GITLAB_URL}/api/v4/projects/{PROJECT_ID}/merge_requests/{MR_IID}"
68
+
69
+ def api_request(endpoint, data=None, method="POST"):
70
+ """Make a GitLab API request."""
71
+ url = f"{API_BASE}{endpoint}"
72
+ headers = {
73
+ "PRIVATE-TOKEN": API_TOKEN,
74
+ "Content-Type": "application/json"
75
+ }
76
+ body = json.dumps(data).encode("utf-8") if data else None
77
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
78
+ try:
79
+ with urllib.request.urlopen(req) as resp:
80
+ return json.loads(resp.read().decode("utf-8"))
81
+ except urllib.error.HTTPError as e:
82
+ print(f"API error {e.code}: {e.read().decode('utf-8')}", file=sys.stderr)
83
+ return None
84
+
85
+ def post_note(body):
86
+ """Post a general note/comment on the MR."""
87
+ return api_request("/notes", {"body": body})
88
+
89
+ def post_discussion(path, line, body, base_sha=None, start_sha=None, head_sha=None):
90
+ """Post an inline discussion on a specific file/line in the MR diff."""
91
+ position = {
92
+ "position_type": "text",
93
+ "new_path": path,
94
+ "old_path": path,
95
+ "new_line": line,
96
+ "base_sha": base_sha or TARGET_BRANCH,
97
+ "start_sha": start_sha or TARGET_BRANCH,
98
+ "head_sha": head_sha or COMMIT_SHA,
99
+ }
100
+ data = {
101
+ "body": body,
102
+ "position": position
103
+ }
104
+ return api_request("/discussions", data)
105
+
106
+ def format_comment(comment):
107
+ """Format a single review comment as markdown."""
108
+ body = comment.get("content", "")
109
+
110
+ existing = comment.get("existing_code", "")
111
+ suggestion = comment.get("suggestion_code", "")
112
+ if suggestion and existing:
113
+ body += "\n\n**Suggestion:**\n"
114
+ body += f"```suggestion:-0+0\n{suggestion}\n```"
115
+
116
+ return body
117
+
118
+ def format_comment_fallback(comment):
119
+ """Format a comment for fallback (non-inline) display."""
120
+ path = comment.get("path", "unknown")
121
+ start_line = comment.get("start_line", 0)
122
+ end_line = comment.get("end_line", 0)
123
+ content = comment.get("content", "")
124
+
125
+ md = f"### šŸ“„ `{path}`"
126
+ if start_line and end_line:
127
+ md += f" (L{start_line}-L{end_line})"
128
+ md += f"\n\n{content}"
129
+
130
+ existing = comment.get("existing_code", "")
131
+ suggestion = comment.get("suggestion_code", "")
132
+ if suggestion and existing:
133
+ md += "\n\n<details><summary>šŸ’” Suggested Change</summary>\n\n"
134
+ md += f"**Before:**\n```\n{existing}\n```\n\n"
135
+ md += f"**After:**\n```\n{suggestion}\n```\n\n"
136
+ md += "</details>"
137
+
138
+ return md
139
+
140
+ # --- Main ---
141
+
142
+ # Read OCR result (skip first line which is summary, not JSON)
143
+ try:
144
+ with open("/tmp/ocr-result.json", "r") as f:
145
+ next(f) # Skip first line
146
+ result = json.load(f)
147
+ except (FileNotFoundError, json.JSONDecodeError) as e:
148
+ print(f"Failed to parse OCR output: {e}", file=sys.stderr)
149
+ stderr_content = ""
150
+ try:
151
+ with open("/tmp/ocr-stderr.log", "r") as f:
152
+ stderr_content = f.read().strip()
153
+ except FileNotFoundError:
154
+ pass
155
+ if stderr_content:
156
+ post_note(f"āš ļø **OpenCodeReview** encountered an error:\n```\n{stderr_content}\n```")
157
+ sys.exit(0)
158
+
159
+ comments = result.get("comments", [])
160
+ warnings = result.get("warnings", [])
161
+
162
+ # No comments - post summary
163
+ if not comments:
164
+ message = result.get("message", "No comments generated. Looks good to me.")
165
+ post_note(f"āœ… **OpenCodeReview**: {message}")
166
+ print("No review comments to post.")
167
+ sys.exit(0)
168
+
169
+ # Get MR diff metadata for position calculation
170
+ diff_refs = None
171
+ try:
172
+ versions_url = f"{API_BASE}/versions"
173
+ req = urllib.request.Request(versions_url, headers={"PRIVATE-TOKEN": API_TOKEN})
174
+ with urllib.request.urlopen(req) as resp:
175
+ versions = json.loads(resp.read().decode("utf-8"))
176
+ if versions:
177
+ latest = versions[0]
178
+ diff_refs = {
179
+ "base_sha": latest.get("base_commit_sha", ""),
180
+ "start_sha": latest.get("start_commit_sha", ""),
181
+ "head_sha": latest.get("head_commit_sha", ""),
182
+ }
183
+ except Exception as e:
184
+ print(f"Warning: Could not fetch MR versions: {e}", file=sys.stderr)
185
+
186
+ # Post inline discussions for each comment
187
+ success_count = 0
188
+ failed_comments = []
189
+
190
+ for comment in comments:
191
+ path = comment.get("path", "")
192
+ end_line = comment.get("end_line", 0)
193
+ start_line = comment.get("start_line", end_line)
194
+ body = format_comment(comment)
195
+
196
+ if not path or not end_line:
197
+ failed_comments.append(comment)
198
+ continue
199
+
200
+ kwargs = {}
201
+ if diff_refs:
202
+ kwargs = diff_refs
203
+
204
+ result_resp = post_discussion(path, end_line, body, **kwargs)
205
+ if result_resp:
206
+ success_count += 1
207
+ else:
208
+ failed_comments.append(comment)
209
+
210
+ print(f"Successfully posted {success_count}/{len(comments)} inline comments.")
211
+
212
+ # Post fallback for any failed inline comments
213
+ if failed_comments:
214
+ fallback_body = f"šŸ” **OpenCodeReview** found issues that could not be posted inline:\n\n---\n\n"
215
+ for comment in failed_comments:
216
+ fallback_body += format_comment_fallback(comment) + "\n\n---\n\n"
217
+ post_note(fallback_body)
218
+
219
+ # Post summary
220
+ summary = f"šŸ” **OpenCodeReview** found **{len(comments)}** issue(s) in this MR."
221
+ if warnings:
222
+ summary += f"\n\nāš ļø {len(warnings)} warning(s) occurred during review."
223
+ post_note(summary)
224
+
225
+ PYTHON_SCRIPT