با GitHub Actions یک بازبینی کننده AI PR سفارشی از ابتدا ایجاد کنید

ساخت مرورگر کد هوش مصنوعی سفارشی با Gemini در GitHub Actions
این مقاله راهنمای ساخت یک ربات مرورگر کد (Code Reviewer) خودکار، سبکوزن و بدون وابستگی به سرویسهای شخص ثالث (مانند CodeRabbit یا Copilot) است. از GitHub Actions، Gemini API و یک اسکریپت Python استاندارد استفاده میشود.
چهار مرحله اصلی گردش کار:
۱. راهانداز: با باز، بهروزرسانی یا بازگردانی PR فعال میشود.
۲. استخراج Diff: تغییرات با git diff استخراج و به ۸۰۰ کیلوبایت برش داده میشود تا در محدودیت توکن مدل جا شود.
۳. تحلیل با Gemini: یک اسکریپت Python خطهای تغییر یافته را پردازش میکند (جلوگیری از توهم مدل در نظر دادن خطوط ثابت) و یک پرامپت ساختاریافته با response_schema.JSON به Gemini 1.5 Flash ارسال میکند. مدل با تمرکز بر معماری، امنیت، UI/UX و نگهداریپذیری، نظرات خطبهخط با شدت (High/Medium/Low) برمیگرداند.
۴. ثبت نظرات: اسکریپت github-script خروجی JSON را میخواند و نظرات را به صورت Inline Comments بومی GitHub روی خطوط دقیق ثبت میکند. در صورت وجود خطای جدی، وضعیت Review به REQUEST_CHANGES تنظیم میشود.
مزایای کلیدی: کنترل کامل بر پرامپت و منطق، هزینه پایین (فقط هزینه API Gemini)، عدم نیاز به مجوزهای گسترده مخزن، و امکان سفارشیسازی آسان برای الزامات تیم (مثل تمرکز فقط بر امنیت). کلید API در SecretsGitHub ذخیره میشود.
هر بار که من یک درخواست کشش (PR) را به یک پروژه منبع باز ارسال می کنم، شروع به کار خط لوله CI را تماشا می کنم. بیشتر و بیشتر، دستیار هوش مصنوعی مانند CodeRabbit، Copilot یا Claude چند دقیقه بعد ظاهر می شود تا تغییرات من را بررسی کند، اشکالات را دسته بندی کند یا به روز رسانی ها را پیشنهاد کند.
من را به این فکر واداشت: ساختن یکی از اینها از ابتدا چقدر سخت است؟
همانطور که مشخص است، شما به یک پلتفرم شخص ثالث سنگین و گران قیمت نیاز ندارید. میتوانید با استفاده از یک اسکریپت کوتاه پایتون و Gemini API، یک مرورگر کد هوش مصنوعی کاملاً سفارشیسازیشده و خط به خط مستقیماً در GitHub Actions بسازید.
در اینجا دقیقاً نحوه کار، شکل کد و نحوه عملکرد هر قطعه توضیح داده شده است.
آنچه ما در حال ساختن
هدف ایجاد یک حلقه اتوماسیون سبک وزن و بدون وابستگی است که مانند یک مهندس ارشد در تیم شما عمل می کند.
-
راه اندازی رویدادهای روابط عمومی: مرحله 1.
هر زمان که یک PR باز شود، به روز شود یا دوباره باز شود، گردش کار آغاز می شود. -
استخراج Git Diff: مرحله 2.
ما تغییرات خط دقیق را جدا می کنیم و آنها را برش می دهیم تا به راحتی در محدودیت های زمینه LLM قرار گیرند. -
ارسال به Gemini با خروجی ساختاریافته: مرحله 3.
یک اسکریپت پایتون درون خطی این تفاوت را به Gemini منتقل میکند، با استفاده از یک طرح سختگیرانه JSON برای تضمین بازگرداندن نظرات ساختارمند مرتبط با فایلها و خطوط خاص. -
ارسال نظرات درون خطی: مرحله 4.
یک اسکریپت GitHub بار JSON را می خواند و نظرات بررسی کد بومی را مستقیماً به خطوط اصلاح شده در PR پیوست می کند.
کد گردش کار کامل
یک فایل به نام ایجاد کنید .github/workflows/ai-review.yml در مخزن خود و پیکربندی زیر را قرار دهید:
name: AI PR Review
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
ai-review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get PR diff
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
git diff "$BASE_SHA...$HEAD_SHA" > diff.txt
head -c 800000 diff.txt > diff_trimmed.txt
- name: Ask Gemini for a review
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
run: |
python3 - <<'PYEOF'
import json, os, re, sys, time
import urllib.error
import urllib.request
MAX_DIFF_BYTES = 800_000
def write_outputs(status, summary, comments):
open("status.txt", "w").write(status)
open("review.md", "w").write(summary)
open("comments.json", "w").write(json.dumps(comments))
original_bytes = os.path.getsize("diff.txt")
truncated = original_bytes > MAX_DIFF_BYTES
with open("diff_trimmed.txt", errors="replace") as f:
diff = f.read()
if not diff.strip():
write_outputs("clean", "No changes to review.", [])
sys.exit(0)
def parse_commentable_lines(diff_text):
valid = {}
current_file, new_line = None, None
for line in diff_text.splitlines():
if line.startswith("+++ "):
raw = line[4:].strip()
current_file = None if raw == "/dev/null" else raw[2:] # strip "b/"
if current_file:
valid.setdefault(current_file, set())
new_line = None
continue
m = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
if m:
new_line = int(m.group(1))
continue
if current_file is None or new_line is None:
continue
if line.startswith("+"):
valid[current_file].add(new_line) # only new/added lines are offered
new_line += 1
elif line.startswith(" "):
new_line += 1
return valid
commentable = parse_commentable_lines(diff)
prompt_text = """You are a senior software engineer reviewing a pull request diff.
Focus strictly on:
1. Architecture & Logic: bugs, security issues, missing error handling, race conditions, lifecycle conflicts.
2. UI & Responsiveness
3. UX & Information Density.
4. Maintainability: structural and style problems.
Only flag lines that are actually part of the diff (added or modified lines). Reference the NEW file's line numbers.
Zero emojis. Be brief, direct, pragmatic.
If there is nothing significant, return status "clean" with an empty comments array.
""" + diff
response_schema = {
"type": "OBJECT",
"properties": {
"status": {"type": "STRING", "enum": ["clean", "issues_found"]},
"summary": {"type": "STRING"},
"comments": {
"type": "ARRAY",
"items": {
"type": "OBJECT",
"properties": {
"file": {"type": "STRING"},
"line": {"type": "INTEGER"},
"severity": {"type": "STRING", "enum": ["high", "medium", "low"]},
"comment": {"type": "STRING"},
},
"required": ["file", "line", "comment"],
},
},
},
"required": ["status", "summary", "comments"],
}
req = urllib.request.Request(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent",
data=json.dumps({
"contents": [{"parts": [{"text": prompt_text}]}],
"generationConfig": {
"responseMimeType": "application/json",
"responseSchema": response_schema,
},
}).encode(),
headers={
"Content-Type": "application/json",
"x-goog-api-key": os.environ["GEMINI_API_KEY"],
},
)
def call_with_retries(request, max_attempts=3, base_delay=2):
last_err = None
for attempt in range(1, max_attempts + 1):
try:
with urllib.request.urlopen(request, timeout=180) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
last_err = e
if e.code in (429, 500, 502, 503, 504) and attempt < max_attempts:
time.sleep(base_delay * attempt)
continue
raise
except urllib.error.URLError as e:
last_err = e
if attempt < max_attempts:
time.sleep(base_delay * attempt)
continue
raise
raise last_err
try:
payload = call_with_retries(req)
candidates = payload.get("candidates") or []
if not candidates:
reason = payload.get("promptFeedback", {}).get("blockReason", "unknown")
raise ValueError(f"no candidates returned (blockReason={reason})")
if "content" not in candidates[0] or "parts" not in candidates[0]["content"]:
raise ValueError("response missing content/parts")
raw_text = candidates[0]["content"]["parts"][0].get("text", "").strip()
raw_text = re.sub(r"^```
{% endraw %}
json\s*|\s*
{% raw %}
```$", "", raw_text) # in case it wraps anyway
parsed = json.loads(raw_text)
status = parsed.get("status", "issues_found")
summary = parsed.get("summary", "").strip() or "Review complete."
raw_comments = parsed.get("comments", [])
safe_comments = []
for c in raw_comments:
f, ln = c.get("file"), c.get("line")
if f in commentable and ln in commentable[f]:
severity = c.get("severity", "medium").upper()
safe_comments.append({
"path": f,
"line": ln,
"side": "RIGHT",
"body": f"**[{severity}]** {c.get('comment', '').strip()}",
})
if truncated:
summary = (
f"Note: diff exceeded {MAX_DIFF_BYTES // 1000}KB and was truncated — "
f"this review may not cover the full PR.\n\n" + summary
)
write_outputs(status, summary, safe_comments)
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")[:500]
print(f"::warning::Gemini API HTTP {e.code}: {detail}", file=sys.stderr)
write_outputs("skipped", f"AI review skipped: Gemini API returned HTTP {e.code}.", [])
except urllib.error.URLError as e:
print(f"::warning::Gemini API network error: {e}", file=sys.stderr)
write_outputs("skipped", f"AI review skipped: network error contacting Gemini API ({e.reason}).", [])
except (KeyError, IndexError, ValueError, json.JSONDecodeError) as e:
print(f"::warning::Gemini API unexpected response: {e}", file=sys.stderr)
write_outputs("skipped", f"AI review skipped: unexpected response from Gemini API ({e}).", [])
PYEOF
- name: Submit PR Review
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const status = fs.readFileSync('status.txt', 'utf8').trim();
const summary = fs.readFileSync('review.md', 'utf8');
let comments = [];
try {
comments = JSON.parse(fs.readFileSync('comments.json', 'utf8'));
} catch (e) {
comments = [];
}
const reviewEvent = (status === 'issues_found' && comments.length > 0)
? 'REQUEST_CHANGES'
: 'COMMENT';
const base = {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
event: reviewEvent,
};
try {
await github.rest.pulls.createReview({ ...base, body: summary, comments });
} catch (e) {
core.warning(`Inline comments rejected (${e.message}); falling back to summary only.`);
await github.rest.pulls.createReview({
...base,
body: summary + '\n\n_Some inline comments could not be posted._',
});
}
تجزیه قطعه به قطعه
بیایید دقیقاً نحوه عملکرد هر جزء را بررسی کنیم تا این امر به طور یکپارچه انجام شود.
1. مجوزها و راه اندازی
در بالای گردش کار، راهاندازها و مجوزهای رویداد را تعریف میکنیم:
-
pull_requestانواع اطمینان حاصل می کنند که وقتی کد تغییر می کند یا یک PR جدید فعال می شود، اجرا می شود. - ما به صراحت کار را می دهیم
pull-requests: writeمجوزها بدون این، دونده اجازه نخواهد داشت در موضوع روابط عمومی نظر بگذارد. -
fetch-depth: 0اطمینان حاصل می کند که تاریخچه git کامل پایین کشیده شده است تا بتوانیم شاخه ها را به طور دقیق مقایسه کنیم.
2. استخراج و برش تفاوت
git diff "$BASE_SHA...$HEAD_SHA" > diff.txt
head -c 800000 diff.txt > diff_trimmed.txt
بهجای تکیه بر وابستگیهای شدید اکوسیستم، متن استاندارد git diff را میکشیم. درخواستهای کشش عظیم میتوانند به سرعت از محدودیتهای بزرگ مدل زبان یا محدودیتهای API فراتر بروند، بنابراین head -c 800000 برای جلوگیری از افت ساختاری، حجم محموله را با خیال راحت در حدود 800 کیلوبایت قرار دهید.
3. اسکریپت پایتون (تأیید خطوط و فراخوانی جمینی)
منطق اصلی در یک مرحله پایتون بدون وابستگی با استفاده از کتابخانه های استاندارد اجرا می شود (urllib). سه مشکل اصلی را حل می کند:
-
تطبیق هدف خط (
parse_commentable_lines): LLM ها گاهی اوقات دچار توهم می شوند و سعی می کنند در مورد خطوطی که حتی تغییر نکرده اند اظهار نظر کنند. این تابع هدرهای یکپارچه تفاوت (@@ -line,v +line,v @@) برای ساختن نقشه ای از خطوطی که در واقع وجود دارند جدید نسخه کد ورودی -
قوانین زمینه تزریقی: این
prompt_textنرده های سازه ای را فراهم می کند. میتوانید این مورد را برای مطابقت با الزامات فنی پشته خاص خود (به عنوان مثال، مشاوره در مورد چرخههای عمر رابط کاربری، چارچوبهای CSS یا اولویتهای ساختاری) تطبیق دهید. -
اجرای JSON ساختاریافته: الف را تعریف می کنیم
response_schemaمستقیماً در داخل گزینه های درخواست API. این را مجبور می کندAI modelبرای برگرداندن داده های خام دقیقاً مطابق با یک شی حاوی یک رشته وضعیت، خلاصه علامت گذاری، و آرایه ای از نظرات درون خطی.
اسکریپت با تأیید اینکه بازخورد هوش مصنوعی دقیقاً با خطوط تغییر یافته مطابقت دارد پایان می یابد و بار خروجی را در فایل های متنی محلی می نویسد.
4. ارسال بازخورد به GitHub
const reviewEvent = (status === 'issues_found' && comments.length > 0) ? 'REQUEST_CHANGES' : 'COMMENT';
...
await github.rest.pulls.createReview({ ...base, body: summary, comments });
مرحله نهایی از رسمی استفاده می کند actions/github-script اقدام برای پردازش آن فایل های متنی. اگر هوش مصنوعی خطاهای منطقی واقعی را علامت گذاری کند و نظرات معتبر را پر کند، اسکریپت وضعیت بررسی روابط عمومی را به عنوان علامت گذاری می کند. REQUEST_CHANGES. اگر همه چیز واضح به نظر می رسد، به طور پیش فرض یک ردیابی استاندارد است COMMENT.
اگر ناهماهنگی شاخص خط خاصی رخ دهد، یک بلوک try/catch بهعنوان یک بک گراند برای ارسال بررسی خلاصه سطح بالا اصلی عمل میکند تا بینش هوش مصنوعی را از دست ندهید.
آن را از آن خود کنید
زیبایی این تنظیمات این است که تغییر آن آسان است. با تنظیم prompt_text در بخش بلوک پایتون، میتوانید به راحتی به LLM دستور دهید که به یک زبان خاص بنویسد، به شدت روی آسیبپذیریهای امنیتی تمرکز کند، یا همه چیز را به جز معیارهای عملکرد خود نادیده بگیرد. رها کنید GEMINI_API_KEY به اسرار مخزن GitHub خود بروید، و شما یک حفاظ کد خودکار و خود میزبان دارید که کاملاً تحت کنترل شما است!
لینک Github



