برنامه نویسی

با 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. راه اندازی رویدادهای روابط عمومی: مرحله 1.
    هر زمان که یک PR باز شود، به روز شود یا دوباره باز شود، گردش کار آغاز می شود.

  2. استخراج Git Diff: مرحله 2.
    ما تغییرات خط دقیق را جدا می کنیم و آنها را برش می دهیم تا به راحتی در محدودیت های زمینه LLM قرار گیرند.

  3. ارسال به Gemini با خروجی ساختاریافته: مرحله 3.
    یک اسکریپت پایتون درون خطی این تفاوت را به Gemini منتقل می‌کند، با استفاده از یک طرح سختگیرانه JSON برای تضمین بازگرداندن نظرات ساختارمند مرتبط با فایل‌ها و خطوط خاص.

  4. ارسال نظرات درون خطی: مرحله 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

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا