mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
* ci: add rate limit handling and version verification to GitHub Actions workflow - Add version check after OCR installation to verify successful setup - Implement exponential backoff retry logic for GitHub API rate limits - Add delays between individual comment posts to avoid secondary rate limits - GitHub enforces ~80 content-generating requests per minute; spacing calls helps stay under that threshold with 2-second base delay and up to 3 retries * ci: refine rate limit handling in GitHub Actions workflow - Add `|| true` to ocr version check for error isolation - Narrow rate limit detection to 429 and 403 with rate-limit message matching, avoiding retries on permission/auth failures - Extract hardcoded delay constants into env-configurable variables (OCR_RETRY_BASE_DELAY, OCR_MAX_RETRIES, OCR_SUCCESS_DELAY, OCR_FAILURE_DELAY) with sensible defaults - Document optional environment variables in workflow header * docs: add environment variable configuration guide for retry and delay settings * ci: add rate-limit resilience and version check to GitLab CI pipeline - Add `ocr version || true` after install for diagnostic logging - Add `api_request_with_retry` function with exponential backoff for 429 and 403 (rate-limit message matching) errors - Respect GitLab `Retry-After` header when present - Extract delay constants into CI/CD-configurable variables (OCR_RETRY_BASE_DELAY, OCR_MAX_RETRIES, OCR_SUCCESS_DELAY, OCR_FAILURE_DELAY) with defaults - Add pacing delays between successful/failed discussion posts - Document optional CI/CD variables in pipeline header comments * docs: add retry/delay settings section to GitLab CI README * ci: fix rate-limit retry delay exhaustion handling in CI workflows - GitHub Actions: distinguish exhausted rate-limit retries from other errors, apply SUCCESS_DELAY (2s) instead of FAILURE_DELAY (1s) when retries exhausted - GitLab CI: return structured result from api_request_with_retry to differentiate failure types, apply context-aware delays based on rate-limit exhaustion status - Both: prevent perpetuating rate-limit failures by using longer delays after retry exhaustion * ci: align GitHub Actions rate-limit retry with header-based strategy Derive wait durations from response headers (retry-after, x-ratelimit-reset) instead of fixed exponential backoff, add proactive throttle when remaining quota is low, honor batch-level rate limits before per-comment retry, and add support for transient 5xx/408 errors. * feat(gitlab-ci): enhance rate-limit handling with jitter, max retry delay, and proactive throttling - Add ±25% jitter on retry delays to avoid thundering herd problems - Add OCR_MAX_RETRY_DELAY (default 60s) to cap per-retry wait time - Add OCR_RATE_LIMIT_THRESHOLD (default 10) for proactive throttling based on GitLab RateLimit-Remaining response header - Parse Retry-After header properly (handle non-numeric values) - Apply retry logic to all API requests (notes, versions, discussions) - Parse and log RateLimit-Remaining/Limit headers for observability - Double pacing delay when remaining quota drops below threshold - Update README with new configuration variables and behavior docs * fix(examples): sync rate-limit docs with script defaults and add missing variables - GitHub Actions README: fix OCR_RETRY_BASE_DELAY default from 2000 to 60000 (matching script code and header comments) - GitHub Actions README: add missing OCR_RETRY_MAX_DELAY, OCR_LOW_REMAINING_THRESHOLD, OCR_LOW_REMAINING_SPACING variables - GitHub Actions README: add GitHub Rate Limits doc reference link - GitHub Actions yml header: add OCR_LLM_USE_ANTHROPIC and llm.extra_body notes - GitLab CI yml header: add llm.extra_body note - GitLab CI README: add GitLab Rate Limits doc reference link * ci(examples): unify header lookup and add transient retry backoff - GitHub Actions: extract inline header closure into a reusable getHeader helper; use it in both computeRetryDelayMs and logRateLimitQuota for consistent case-insensitive header access. - GitHub Actions: use a 2s transientBase for 5xx/408 exponential backoff instead of the 60s rate-limit base, since server hiccups are typically short-lived and the longer base stalled CI jobs unnecessarily. - GitLab CI: add a _get_header helper and route all header access (Retry-After, RateLimit-*) through it, matching the GitHub Actions approach. - GitLab CI: add transient retry logic for 5xx/408 errors with a 2s base delay, so server errors no longer fail immediately.
344 lines
16 KiB
YAML
344 lines
16 KiB
YAML
# 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.
|
||
# Supports both same-repo and forked MR scenarios.
|
||
#
|
||
# 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")
|
||
#
|
||
# Optional CI/CD Variables:
|
||
# OCR_LLM_MODEL - Model name (default: gpt-4o)
|
||
# GITLAB_API_TOKEN - GitLab Personal/Project Access Token with "api" scope
|
||
# (falls back to CI_JOB_TOKEN if not set)
|
||
#
|
||
# Optional CI/CD Variables (for retry/delay tuning):
|
||
# OCR_RETRY_BASE_DELAY - Base delay (ms) for exponential backoff on rate-limit retries (default: 2000)
|
||
# OCR_MAX_RETRIES - Max retry attempts per comment when rate-limited or transient error (default: 3)
|
||
# OCR_MAX_RETRY_DELAY - Maximum delay (ms) per single retry, caps Retry-After/backoff (default: 60000)
|
||
# OCR_SUCCESS_DELAY - Delay (ms) after a successful comment post to pace requests (default: 2000)
|
||
# OCR_FAILURE_DELAY - Delay (ms) after a non-rate-limit failure to pace subsequent requests (default: 1000)
|
||
# OCR_RATE_LIMIT_THRESHOLD - Proactively slow down when GitLab RateLimit-Remaining is at/below this value (default: 10, set 0 to disable)
|
||
#
|
||
# Note: The pipeline also configures llm.extra_body to '{"thinking": {"type": "disabled"}}'
|
||
# to disable thinking mode for compatibility with various LLM providers.
|
||
#
|
||
# Fork MR Support:
|
||
# The script uses CI_COMMIT_SHA as the diff target to correctly resolve the
|
||
# source commit from forked repos. For some GitLab versions, you may need to enable:
|
||
# Project Settings → CI/CD → General pipelines →
|
||
# "Run pipelines in the parent project for merge requests from forked projects"
|
||
|
||
stages:
|
||
- review
|
||
|
||
code-review:
|
||
stage: review
|
||
interruptible: true
|
||
resource_group: mr-review-$CI_MERGE_REQUEST_IID
|
||
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
|
||
- echo "OpenCodeReview installed with version:" && ocr version || true
|
||
|
||
# Configure OCR
|
||
- mkdir -p ~/.open-code-review
|
||
# Gitlab CI/CD does not support configuring 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 (use CI_COMMIT_SHA for --to to support both same-repo and forked MRs)
|
||
- |
|
||
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 "${CI_COMMIT_SHA}" \
|
||
--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 random
|
||
import sys
|
||
import time
|
||
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"]
|
||
# Fall back to CI_JOB_TOKEN for fork MR pipelines where GITLAB_API_TOKEN is unavailable
|
||
API_TOKEN = os.environ.get("GITLAB_API_TOKEN") or os.environ.get("CI_JOB_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"]
|
||
|
||
# Configurable retry/delay settings (via CI/CD variables, with sensible defaults)
|
||
RETRY_BASE_DELAY = int(os.environ.get("OCR_RETRY_BASE_DELAY", "2000")) / 1000 # ms → seconds
|
||
MAX_RETRIES = int(os.environ.get("OCR_MAX_RETRIES", "3"))
|
||
MAX_RETRY_DELAY = int(os.environ.get("OCR_MAX_RETRY_DELAY", "60000")) / 1000 # ms → seconds, cap per-retry wait
|
||
SUCCESS_DELAY = int(os.environ.get("OCR_SUCCESS_DELAY", "2000")) / 1000
|
||
FAILURE_DELAY = int(os.environ.get("OCR_FAILURE_DELAY", "1000")) / 1000
|
||
# Base delay (seconds) for exponential backoff on transient server errors (5xx / 408).
|
||
# Server hiccups are typically short-lived, so a 2s initial wait (doubling per retry)
|
||
# is sufficient and avoids stalling the CI job unnecessarily.
|
||
TRANSIENT_BASE_DELAY = 2
|
||
# Proactive throttling: when RateLimit-Remaining drops to/below this threshold,
|
||
# the script doubles the pacing delay to avoid hitting 429. Set to 0 to disable.
|
||
RATE_LIMIT_THRESHOLD = int(os.environ.get("OCR_RATE_LIMIT_THRESHOLD", "10"))
|
||
|
||
if not API_TOKEN:
|
||
print("ERROR: No API token available (GITLAB_API_TOKEN or CI_JOB_TOKEN). Cannot post comments.", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
API_BASE = f"{GITLAB_URL}/api/v4/projects/{PROJECT_ID}/merge_requests/{MR_IID}"
|
||
|
||
# Determine auth header: PRIVATE-TOKEN for personal/project tokens, JOB-TOKEN for CI_JOB_TOKEN
|
||
USE_JOB_TOKEN = not os.environ.get("GITLAB_API_TOKEN")
|
||
AUTH_HEADER = "JOB-TOKEN" if USE_JOB_TOKEN else "PRIVATE-TOKEN"
|
||
|
||
def _get_header(headers, name):
|
||
"""Case-insensitive header lookup.
|
||
|
||
urllib normalizes response header keys to title-case (e.g. 'Retry-After'),
|
||
but this defensive check also handles original casing so that retry delay
|
||
computation and quota logging never silently miss a header.
|
||
"""
|
||
if name in headers:
|
||
val = headers[name]
|
||
elif name.lower() in headers:
|
||
val = headers[name.lower()]
|
||
else:
|
||
return None
|
||
return str(val).strip() if val is not None else None
|
||
|
||
def _parse_rate_limit_header(headers, name):
|
||
"""Safely parse a rate-limit response header (e.g. RateLimit-Remaining) as an int."""
|
||
val = _get_header(headers, name)
|
||
if val is None:
|
||
return None
|
||
try:
|
||
return int(val)
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
def api_request_with_retry(endpoint, data=None, method="POST"):
|
||
"""Make a GitLab API request with retry on rate-limit errors.
|
||
Returns a dict: {'success': bool, 'data': response or None,
|
||
'is_rate_limit_exhausted': bool, 'rate_limit_remaining': int or None}
|
||
"""
|
||
for attempt in range(MAX_RETRIES + 1):
|
||
url = f"{API_BASE}{endpoint}"
|
||
headers = {
|
||
AUTH_HEADER: 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:
|
||
resp_data = json.loads(resp.read().decode("utf-8"))
|
||
remaining = _parse_rate_limit_header(resp.headers, 'RateLimit-Remaining')
|
||
limit = _parse_rate_limit_header(resp.headers, 'RateLimit-Limit')
|
||
if remaining is not None and limit is not None:
|
||
print(f"RateLimit: {remaining}/{limit} remaining for {endpoint}", file=sys.stderr)
|
||
return {'success': True, 'data': resp_data, 'is_rate_limit_exhausted': False, 'rate_limit_remaining': remaining}
|
||
except urllib.error.HTTPError as e:
|
||
error_body = e.read().decode('utf-8')
|
||
is_rate_limit = e.code == 429 or (e.code == 403 and any(kw in error_body.lower() for kw in ['retry later', 'rate limit', 'too many requests', 'abuse']))
|
||
# Transient server errors (5xx) and request timeouts (408) are worth retrying
|
||
# with a short exponential backoff, since they are typically short-lived.
|
||
is_transient = (500 <= e.code < 600) or e.code == 408
|
||
rl_remaining = _parse_rate_limit_header(e.headers, 'RateLimit-Remaining')
|
||
if (is_rate_limit or is_transient) and attempt < MAX_RETRIES:
|
||
retry_after = _get_header(e.headers, 'Retry-After')
|
||
if retry_after:
|
||
try:
|
||
delay = float(retry_after)
|
||
except ValueError:
|
||
delay = RETRY_BASE_DELAY * (2 ** attempt) # fallback if Retry-After is not numeric
|
||
elif is_transient:
|
||
# Transient server error: use a shorter base than the rate-limit path.
|
||
# Server hiccups are typically short-lived, so a 2s initial wait
|
||
# (doubling per retry) is sufficient.
|
||
delay = TRANSIENT_BASE_DELAY * (2 ** attempt)
|
||
else:
|
||
delay = RETRY_BASE_DELAY * (2 ** attempt)
|
||
delay = min(delay, MAX_RETRY_DELAY) # cap to prevent long stalls
|
||
delay = delay * (0.75 + random.random() * 0.5) # ±25% jitter to avoid thundering herd
|
||
rl_info = f" (RateLimit-Remaining: {rl_remaining})" if rl_remaining is not None else ""
|
||
reason = "rate limit" if is_rate_limit else f"transient error (HTTP {e.code})"
|
||
print(f"{reason} hit for {endpoint}, retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES}){rl_info}", file=sys.stderr)
|
||
time.sleep(delay)
|
||
else:
|
||
print(f"API error {e.code}: {error_body}", file=sys.stderr)
|
||
return {'success': False, 'data': None, 'is_rate_limit_exhausted': is_rate_limit, 'rate_limit_remaining': rl_remaining}
|
||
return {'success': False, 'data': None, 'is_rate_limit_exhausted': False, 'rate_limit_remaining': None}
|
||
|
||
def post_note(body):
|
||
"""Post a general note/comment on the MR (with rate-limit retry)."""
|
||
return api_request_with_retry("/notes", {"body": body})
|
||
|
||
def post_discussion(path, line, body, base_sha, start_sha, head_sha):
|
||
"""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,
|
||
"start_sha": start_sha,
|
||
"head_sha": head_sha,
|
||
}
|
||
data = {
|
||
"body": body,
|
||
"position": position
|
||
}
|
||
return api_request_with_retry("/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<details><summary>💡 Suggested Change</summary>\n\n"
|
||
md += f"**Before:**\n```\n{existing}\n```\n\n"
|
||
md += f"**After:**\n```\n{suggestion}\n```\n\n"
|
||
md += "</details>"
|
||
|
||
return md
|
||
|
||
# --- Main ---
|
||
|
||
# Read OCR result (skip first line which is summary, not JSON)
|
||
try:
|
||
with open("/tmp/ocr-result.json", "r") as f:
|
||
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 (uses retry to avoid single-point-of-failure)
|
||
diff_refs = None
|
||
versions_resp = api_request_with_retry("/versions", method="GET")
|
||
if versions_resp and versions_resp.get('success'):
|
||
versions = versions_resp.get('data', [])
|
||
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", ""),
|
||
}
|
||
if not diff_refs:
|
||
print("Warning: Could not fetch MR versions. Inline comments will use fallback.", 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 or not diff_refs:
|
||
failed_comments.append(comment)
|
||
continue
|
||
|
||
result_resp = post_discussion(path, end_line, body, **diff_refs)
|
||
if result_resp and result_resp.get('success'):
|
||
success_count += 1
|
||
# Proactive throttling: slow down when GitLab reports low remaining quota
|
||
remaining = result_resp.get('rate_limit_remaining')
|
||
if RATE_LIMIT_THRESHOLD > 0 and remaining is not None and remaining <= RATE_LIMIT_THRESHOLD:
|
||
pace_delay = SUCCESS_DELAY * 2
|
||
print(f"Rate limit quota low ({remaining} remaining), increasing pacing delay to {pace_delay:.1f}s", file=sys.stderr)
|
||
time.sleep(pace_delay)
|
||
else:
|
||
time.sleep(SUCCESS_DELAY) # Pace requests to avoid rate limits
|
||
else:
|
||
failed_comments.append(comment)
|
||
# Apply longer delay when rate-limit retries are exhausted, shorter delay for other errors
|
||
is_rate_limit_exhausted = result_resp.get('is_rate_limit_exhausted', False) if result_resp else False
|
||
post_fail_delay = SUCCESS_DELAY if is_rate_limit_exhausted else FAILURE_DELAY
|
||
time.sleep(post_fail_delay)
|
||
|
||
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 last
|
||
total_count = len(comments)
|
||
failed_count = len(failed_comments)
|
||
summary = f"🔍 **OpenCodeReview** found **{total_count}** issue(s) in this MR."
|
||
if total_count > 0:
|
||
summary += f"\n- ✅ {success_count} posted as inline comment(s)"
|
||
summary += f"\n- 📝 {failed_count} posted as summary (missing line info)"
|
||
if warnings:
|
||
summary += f"\n\n⚠️ {len(warnings)} warning(s) occurred during review."
|
||
post_note(summary)
|
||
|
||
PYTHON_SCRIPT
|