<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Thierry RAKOTOMANANA (Thierry)</title>
    <description>The latest articles on DEV Community by Thierry RAKOTOMANANA (Thierry) (@thierryrakt).</description>
    <link>https://dev.to/thierryrakt</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4031871%2F00a86eed-0078-4fd6-ae0c-ee5931b135f0.jpg</url>
      <title>DEV Community: Thierry RAKOTOMANANA (Thierry)</title>
      <link>https://dev.to/thierryrakt</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thierryrakt"/>
    <language>en</language>
    <item>
      <title>Create a Custom AI PR Reviewer from scratch with GitHub Actions</title>
      <dc:creator>Thierry RAKOTOMANANA (Thierry)</dc:creator>
      <pubDate>Thu, 16 Jul 2026 10:37:56 +0000</pubDate>
      <link>https://dev.to/thierryrakt/create-a-custom-ai-pr-reviewer-from-scratch-with-github-actions-46p5</link>
      <guid>https://dev.to/thierryrakt/create-a-custom-ai-pr-reviewer-from-scratch-with-github-actions-46p5</guid>
      <description>&lt;p&gt;Every time I submit a Pull Request (PR) to an open-source project, I watch the CI pipeline kick off. More and more often, an AI assistant like CodeRabbit, Copilot, or Claude pops up a few minutes later to review my changes, categorize bugs, or suggest updates.&lt;/p&gt;

&lt;p&gt;It got me wondering: &lt;strong&gt;How hard is it to build one of these from scratch?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As it turns out, you don't need a heavy, expensive third-party platform. You can build a fully customized, line-by-line AI code reviewer directly inside GitHub Actions using a short Python script and the Gemini API.&lt;/p&gt;

&lt;p&gt;Here is exactly how it works, what the code looks like, and a breakdown of how each piece functions.&lt;/p&gt;




&lt;h2&gt;
  
  
  What We Are Building
&lt;/h2&gt;

&lt;p&gt;The goal is to create a lightweight, dependency-free automation loop that acts like a senior engineer on your team.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trigger on PR Events:&lt;/strong&gt; Step 1.&lt;br&gt;
The workflow triggers whenever a PR is opened, updated, or reopened.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Extract the Git Diff:&lt;/strong&gt; Step 2.&lt;br&gt;
We isolate the exact line changes and trim them to fit comfortably within LLM context constraints.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Send to Gemini with Structured Output:&lt;/strong&gt; Step 3.&lt;br&gt;
An inline Python script passes the diff to Gemini, using a strict JSON schema to guarantee we get back structured comments tied to specific files and lines.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Post Inline Comments:&lt;/strong&gt; Step 4.&lt;br&gt;
A GitHub script reads the JSON payload and attaches native code-review comments directly onto the modified lines in the PR.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Complete Workflow Code
&lt;/h2&gt;

&lt;p&gt;Create a file named &lt;code&gt;.github/workflows/ai-review.yml&lt;/code&gt; in your repository and paste the following configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AI PR Review&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;opened&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;synchronize&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;reopened&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ai-review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Get PR diff&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;BASE_SHA&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.base.sha }}&lt;/span&gt;
          &lt;span class="na"&gt;HEAD_SHA&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.head.sha }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git diff "$BASE_SHA...$HEAD_SHA" &amp;gt; diff.txt&lt;/span&gt;
          &lt;span class="s"&gt;head -c 800000 diff.txt &amp;gt; diff_trimmed.txt&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ask Gemini for a review&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GEMINI_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GEMINI_API_KEY }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;python3 - &amp;lt;&amp;lt;'PYEOF'&lt;/span&gt;
          &lt;span class="s"&gt;import json, os, re, sys, time&lt;/span&gt;
          &lt;span class="s"&gt;import urllib.error&lt;/span&gt;
          &lt;span class="s"&gt;import urllib.request&lt;/span&gt;

          &lt;span class="s"&gt;MAX_DIFF_BYTES = 800_000&lt;/span&gt;

          &lt;span class="s"&gt;def write_outputs(status, summary, comments):&lt;/span&gt;
              &lt;span class="s"&gt;open("status.txt", "w").write(status)&lt;/span&gt;
              &lt;span class="s"&gt;open("review.md", "w").write(summary)&lt;/span&gt;
              &lt;span class="s"&gt;open("comments.json", "w").write(json.dumps(comments))&lt;/span&gt;

          &lt;span class="s"&gt;original_bytes = os.path.getsize("diff.txt")&lt;/span&gt;
          &lt;span class="s"&gt;truncated = original_bytes &amp;gt; MAX_DIFF_BYTES&lt;/span&gt;

          &lt;span class="s"&gt;with open("diff_trimmed.txt", errors="replace") as f:&lt;/span&gt;
              &lt;span class="s"&gt;diff = f.read()&lt;/span&gt;

          &lt;span class="s"&gt;if not diff.strip():&lt;/span&gt;
              &lt;span class="s"&gt;write_outputs("clean", "No changes to review.", [])&lt;/span&gt;
              &lt;span class="s"&gt;sys.exit(0)&lt;/span&gt;

          &lt;span class="s"&gt;def parse_commentable_lines(diff_text):&lt;/span&gt;
              &lt;span class="s"&gt;valid = {}&lt;/span&gt;
              &lt;span class="s"&gt;current_file, new_line = None, None&lt;/span&gt;
              &lt;span class="s"&gt;for line in diff_text.splitlines():&lt;/span&gt;
                  &lt;span class="s"&gt;if line.startswith("+++ "):&lt;/span&gt;
                      &lt;span class="s"&gt;raw = line[4:].strip()&lt;/span&gt;
                      &lt;span class="s"&gt;current_file = None if raw == "/dev/null" else raw[2:]  # strip "b/"&lt;/span&gt;
                      &lt;span class="s"&gt;if current_file:&lt;/span&gt;
                          &lt;span class="s"&gt;valid.setdefault(current_file, set())&lt;/span&gt;
                      &lt;span class="s"&gt;new_line = None&lt;/span&gt;
                      &lt;span class="s"&gt;continue&lt;/span&gt;
                  &lt;span class="s"&gt;m = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)&lt;/span&gt;
                  &lt;span class="s"&gt;if m:&lt;/span&gt;
                      &lt;span class="s"&gt;new_line = int(m.group(1))&lt;/span&gt;
                      &lt;span class="s"&gt;continue&lt;/span&gt;
                  &lt;span class="s"&gt;if current_file is None or new_line is None:&lt;/span&gt;
                      &lt;span class="s"&gt;continue&lt;/span&gt;
                  &lt;span class="s"&gt;if line.startswith("+"):&lt;/span&gt;
                      &lt;span class="s"&gt;valid[current_file].add(new_line)  # only new/added lines are offered&lt;/span&gt;
                      &lt;span class="s"&gt;new_line += 1&lt;/span&gt;
                  &lt;span class="s"&gt;elif line.startswith(" "):&lt;/span&gt;
                      &lt;span class="s"&gt;new_line += 1&lt;/span&gt;
              &lt;span class="s"&gt;return valid&lt;/span&gt;

          &lt;span class="s"&gt;commentable = parse_commentable_lines(diff)&lt;/span&gt;

          &lt;span class="s"&gt;prompt_text = """You are a senior software engineer reviewing a pull request diff.&lt;/span&gt;

          &lt;span class="s"&gt;Focus strictly on:&lt;/span&gt;
          &lt;span class="s"&gt;1. Architecture &amp;amp; Logic: bugs, security issues, missing error handling, race conditions, lifecycle conflicts.&lt;/span&gt;
          &lt;span class="s"&gt;2. UI &amp;amp; Responsiveness&lt;/span&gt;
          &lt;span class="s"&gt;3. UX &amp;amp; Information Density.&lt;/span&gt;
          &lt;span class="s"&gt;4. Maintainability: structural and style problems.&lt;/span&gt;

          &lt;span class="s"&gt;Only flag lines that are actually part of the diff (added or modified lines). Reference the NEW file's line numbers.&lt;/span&gt;
          &lt;span class="s"&gt;Zero emojis. Be brief, direct, pragmatic.&lt;/span&gt;
          &lt;span class="s"&gt;If there is nothing significant, return status "clean" with an empty comments array.&lt;/span&gt;
          &lt;span class="s"&gt;""" + diff&lt;/span&gt;

          &lt;span class="s"&gt;response_schema = {&lt;/span&gt;
              &lt;span class="s"&gt;"type": "OBJECT",&lt;/span&gt;
              &lt;span class="s"&gt;"properties": {&lt;/span&gt;
                  &lt;span class="s"&gt;"status": {"type": "STRING", "enum": ["clean", "issues_found"]},&lt;/span&gt;
                  &lt;span class="s"&gt;"summary": {"type": "STRING"},&lt;/span&gt;
                  &lt;span class="s"&gt;"comments": {&lt;/span&gt;
                      &lt;span class="s"&gt;"type": "ARRAY",&lt;/span&gt;
                      &lt;span class="s"&gt;"items": {&lt;/span&gt;
                          &lt;span class="s"&gt;"type": "OBJECT",&lt;/span&gt;
                          &lt;span class="s"&gt;"properties": {&lt;/span&gt;
                              &lt;span class="s"&gt;"file": {"type": "STRING"},&lt;/span&gt;
                              &lt;span class="s"&gt;"line": {"type": "INTEGER"},&lt;/span&gt;
                              &lt;span class="s"&gt;"severity": {"type": "STRING", "enum": ["high", "medium", "low"]},&lt;/span&gt;
                              &lt;span class="s"&gt;"comment": {"type": "STRING"},&lt;/span&gt;
                          &lt;span class="s"&gt;},&lt;/span&gt;
                          &lt;span class="s"&gt;"required": ["file", "line", "comment"],&lt;/span&gt;
                      &lt;span class="s"&gt;},&lt;/span&gt;
                  &lt;span class="s"&gt;},&lt;/span&gt;
              &lt;span class="s"&gt;},&lt;/span&gt;
              &lt;span class="s"&gt;"required": ["status", "summary", "comments"],&lt;/span&gt;
          &lt;span class="s"&gt;}&lt;/span&gt;

          &lt;span class="s"&gt;req = urllib.request.Request(&lt;/span&gt;
              &lt;span class="s"&gt;"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent",&lt;/span&gt;
              &lt;span class="s"&gt;data=json.dumps({&lt;/span&gt;
                  &lt;span class="s"&gt;"contents": [{"parts": [{"text": prompt_text}]}],&lt;/span&gt;
                  &lt;span class="s"&gt;"generationConfig": {&lt;/span&gt;
                      &lt;span class="s"&gt;"responseMimeType": "application/json",&lt;/span&gt;
                      &lt;span class="s"&gt;"responseSchema": response_schema,&lt;/span&gt;
                  &lt;span class="s"&gt;},&lt;/span&gt;
              &lt;span class="s"&gt;}).encode(),&lt;/span&gt;
              &lt;span class="s"&gt;headers={&lt;/span&gt;
                  &lt;span class="s"&gt;"Content-Type": "application/json",&lt;/span&gt;
                  &lt;span class="s"&gt;"x-goog-api-key": os.environ["GEMINI_API_KEY"],&lt;/span&gt;
              &lt;span class="s"&gt;},&lt;/span&gt;
          &lt;span class="s"&gt;)&lt;/span&gt;

          &lt;span class="s"&gt;def call_with_retries(request, max_attempts=3, base_delay=2):&lt;/span&gt;
              &lt;span class="s"&gt;last_err = None&lt;/span&gt;
              &lt;span class="s"&gt;for attempt in range(1, max_attempts + 1):&lt;/span&gt;
                  &lt;span class="s"&gt;try:&lt;/span&gt;
                      &lt;span class="s"&gt;with urllib.request.urlopen(request, timeout=180) as resp:&lt;/span&gt;
                          &lt;span class="s"&gt;return json.loads(resp.read())&lt;/span&gt;
                  &lt;span class="s"&gt;except urllib.error.HTTPError as e:&lt;/span&gt;
                      &lt;span class="s"&gt;last_err = e&lt;/span&gt;
                      &lt;span class="s"&gt;if e.code in (429, 500, 502, 503, 504) and attempt &amp;lt; max_attempts:&lt;/span&gt;
                          &lt;span class="s"&gt;time.sleep(base_delay * attempt)&lt;/span&gt;
                          &lt;span class="s"&gt;continue&lt;/span&gt;
                      &lt;span class="s"&gt;raise&lt;/span&gt;
                  &lt;span class="s"&gt;except urllib.error.URLError as e:&lt;/span&gt;
                      &lt;span class="s"&gt;last_err = e&lt;/span&gt;
                      &lt;span class="s"&gt;if attempt &amp;lt; max_attempts:&lt;/span&gt;
                          &lt;span class="s"&gt;time.sleep(base_delay * attempt)&lt;/span&gt;
                          &lt;span class="s"&gt;continue&lt;/span&gt;
                      &lt;span class="s"&gt;raise&lt;/span&gt;
              &lt;span class="s"&gt;raise last_err&lt;/span&gt;

          &lt;span class="s"&gt;try:&lt;/span&gt;
              &lt;span class="s"&gt;payload = call_with_retries(req)&lt;/span&gt;

              &lt;span class="s"&gt;candidates = payload.get("candidates") or []&lt;/span&gt;
              &lt;span class="s"&gt;if not candidates:&lt;/span&gt;
                  &lt;span class="s"&gt;reason = payload.get("promptFeedback", {}).get("blockReason", "unknown")&lt;/span&gt;
                  &lt;span class="s"&gt;raise ValueError(f"no candidates returned (blockReason={reason})")&lt;/span&gt;
              &lt;span class="s"&gt;if "content" not in candidates[0] or "parts" not in candidates[0]["content"]:&lt;/span&gt;
                  &lt;span class="s"&gt;raise ValueError("response missing content/parts")&lt;/span&gt;

              &lt;span class="s"&gt;raw_text = candidates[0]["content"]["parts"][0].get("text", "").strip()&lt;/span&gt;
              &lt;span class="s"&gt;raw_text = re.sub(r"^```&lt;/span&gt;
&lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="err"&gt;%&lt;/span&gt; &lt;span class="nv"&gt;endraw %&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="s"&gt;json\s*|\s*&lt;/span&gt;
&lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="err"&gt;%&lt;/span&gt; &lt;span class="nv"&gt;raw %&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;```&lt;/span&gt;&lt;span class="s"&gt;$", "", raw_text)&lt;/span&gt;  &lt;span class="c1"&gt;# in case it wraps anyway&lt;/span&gt;
              &lt;span class="s"&gt;parsed = json.loads(raw_text)&lt;/span&gt;

              &lt;span class="s"&gt;status = parsed.get("status", "issues_found")&lt;/span&gt;
              &lt;span class="s"&gt;summary = parsed.get("summary", "").strip() or "Review complete."&lt;/span&gt;
              &lt;span class="s"&gt;raw_comments = parsed.get("comments", [])&lt;/span&gt;

              &lt;span class="s"&gt;safe_comments = []&lt;/span&gt;
              &lt;span class="s"&gt;for c in raw_comments&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
                  &lt;span class="s"&gt;f, ln = c.get("file"), c.get("line")&lt;/span&gt;
                  &lt;span class="s"&gt;if f in commentable and ln in commentable[f]&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
                      &lt;span class="s"&gt;severity = c.get("severity", "medium").upper()&lt;/span&gt;
                      &lt;span class="s"&gt;safe_comments.append({&lt;/span&gt;
                          &lt;span class="s"&gt;"path"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;f,&lt;/span&gt;
                          &lt;span class="s"&gt;"line"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ln,&lt;/span&gt;
                          &lt;span class="s"&gt;"side"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RIGHT"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;
                          &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;f"**[{severity}]** {c.get('comment', '').strip()}",&lt;/span&gt;
                      &lt;span class="s"&gt;})&lt;/span&gt;

              &lt;span class="s"&gt;if truncated&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
                  &lt;span class="s"&gt;summary = (&lt;/span&gt;
                      &lt;span class="s"&gt;f"Note&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;diff exceeded {MAX_DIFF_BYTES // 1000}KB and was truncated — "&lt;/span&gt;
                      &lt;span class="s"&gt;f"this review may not cover the full PR.\n\n" + summary&lt;/span&gt;
                  &lt;span class="s"&gt;)&lt;/span&gt;

              &lt;span class="s"&gt;write_outputs(status, summary, safe_comments)&lt;/span&gt;

          &lt;span class="s"&gt;except urllib.error.HTTPError as e&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
              &lt;span class="s"&gt;detail = e.read().decode(errors="replace")[:500]&lt;/span&gt;
              &lt;span class="s"&gt;print(f"::warning::Gemini API HTTP {e.code}&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;detail&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;file=sys.stderr)&lt;/span&gt;
              &lt;span class="s"&gt;write_outputs("skipped", f"AI review skipped&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gemini API returned HTTP {e.code}.", [])&lt;/span&gt;

          &lt;span class="s"&gt;except urllib.error.URLError as e&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
              &lt;span class="s"&gt;print(f"::warning::Gemini API network error&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;e&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;file=sys.stderr)&lt;/span&gt;
              &lt;span class="s"&gt;write_outputs("skipped", f"AI review skipped&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;network error contacting Gemini API ({e.reason}).", [])&lt;/span&gt;

          &lt;span class="s"&gt;except (KeyError, IndexError, ValueError, json.JSONDecodeError) as e&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
              &lt;span class="s"&gt;print(f"::warning::Gemini API unexpected response&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;e&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;file=sys.stderr)&lt;/span&gt;
              &lt;span class="s"&gt;write_outputs("skipped", f"AI review skipped&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unexpected response from Gemini API ({e}).", [])&lt;/span&gt;
          &lt;span class="s"&gt;PYEOF&lt;/span&gt;

      &lt;span class="s"&gt;- name&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Submit PR Review&lt;/span&gt;
        &lt;span class="s"&gt;uses&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/github-script@v7&lt;/span&gt;
        &lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;const fs = require('fs');&lt;/span&gt;

            &lt;span class="s"&gt;const status = fs.readFileSync('status.txt', 'utf8').trim();&lt;/span&gt;
            &lt;span class="s"&gt;const summary = fs.readFileSync('review.md', 'utf8');&lt;/span&gt;
            &lt;span class="s"&gt;let comments = [];&lt;/span&gt;
            &lt;span class="s"&gt;try {&lt;/span&gt;
              &lt;span class="s"&gt;comments = JSON.parse(fs.readFileSync('comments.json', 'utf8'));&lt;/span&gt;
            &lt;span class="s"&gt;} catch (e) {&lt;/span&gt;
              &lt;span class="s"&gt;comments = [];&lt;/span&gt;
            &lt;span class="s"&gt;}&lt;/span&gt;

            &lt;span class="s"&gt;const reviewEvent = (status === 'issues_found' &amp;amp;&amp;amp; comments.length &amp;gt; 0)&lt;/span&gt;
              &lt;span class="s"&gt;? 'REQUEST_CHANGES'&lt;/span&gt;
              &lt;span class="s"&gt;: 'COMMENT';&lt;/span&gt;

            &lt;span class="s"&gt;const base = {&lt;/span&gt;
              &lt;span class="s"&gt;owner: context.repo.owner,&lt;/span&gt;
              &lt;span class="s"&gt;repo: context.repo.repo,&lt;/span&gt;
              &lt;span class="s"&gt;pull_number: context.issue.number,&lt;/span&gt;
              &lt;span class="s"&gt;event: reviewEvent,&lt;/span&gt;
            &lt;span class="s"&gt;};&lt;/span&gt;

            &lt;span class="s"&gt;try {&lt;/span&gt;
              &lt;span class="s"&gt;await github.rest.pulls.createReview({ ...base, body: summary, comments });&lt;/span&gt;
            &lt;span class="s"&gt;} catch (e) {&lt;/span&gt;
              &lt;span class="s"&gt;core.warning(`Inline comments rejected (${e.message}); falling back to summary only.`);&lt;/span&gt;
              &lt;span class="s"&gt;await github.rest.pulls.createReview({&lt;/span&gt;
                &lt;span class="s"&gt;...base,&lt;/span&gt;
                &lt;span class="s"&gt;body: summary + '\n\n_Some inline comments could not be posted._',&lt;/span&gt;
              &lt;span class="s"&gt;});&lt;/span&gt;
            &lt;span class="s"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Piece-by-Piece Breakdown
&lt;/h2&gt;

&lt;p&gt;Let’s look at exactly how each component functions to pull this off seamlessly.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Permissions and Setup
&lt;/h3&gt;

&lt;p&gt;At the top of the workflow, we define the event triggers and permissions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pull_request&lt;/code&gt; types ensure this runs when code changes or a new PR goes live.&lt;/li&gt;
&lt;li&gt;We explicitly give the job &lt;code&gt;pull-requests: write&lt;/code&gt; permissions. Without this, the runner won't be allowed to leave comments on the PR thread.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fetch-depth: 0&lt;/code&gt; ensures the full git history is pulled down so we can accurately compare branches.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Extracting and Trimming the Diff
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;git diff "$BASE_SHA...$HEAD_SHA" &amp;gt; diff.txt&lt;/span&gt;
&lt;span class="s"&gt;head -c 800000 diff.txt &amp;gt; diff_trimmed.txt&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of relying on heavy ecosystem dependencies, we pull standard git diff text. Huge pull requests can quickly exceed large language model limits or API limits, so &lt;code&gt;head -c 800000&lt;/code&gt; safely caps the payload size at around 800KB to prevent structural drops.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The Python Script (Validating Lines and Calling Gemini)
&lt;/h3&gt;

&lt;p&gt;The core logic runs in a dependency-free Python step using standard libraries (&lt;code&gt;urllib&lt;/code&gt;). It handles three main problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Line Target Matching (&lt;code&gt;parse_commentable_lines&lt;/code&gt;):&lt;/strong&gt; LLMs sometimes hallucinate and try to comment on lines that weren't even changed. This function parses the unified diff headers (&lt;code&gt;@@ -line,v +line,v @@&lt;/code&gt;) to build a map of lines that actually exist in the &lt;em&gt;new&lt;/em&gt; incoming code version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Injecting Context Rules:&lt;/strong&gt; The &lt;code&gt;prompt_text&lt;/code&gt; provides structural guardrails. You can adapt this to fit your specific stack's technical requirements (e.g., advising on UI lifecycles, CSS frameworks, or structural preferences).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforcing Structured JSON:&lt;/strong&gt; We define a &lt;code&gt;response_schema&lt;/code&gt; directly inside the API request options. This forces the &lt;code&gt;AI model&lt;/code&gt; to return raw data exactly matching an object containing a status string, markdown summary, and an array of inline comments.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The script ends by verifying that the AI's feedback strictly lines up with changed lines and writes the output payload into local text files.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Submitting the Feedback to GitHub
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reviewEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;issues_found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;REQUEST_CHANGES&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;COMMENT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;github&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pulls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createReview&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;comments&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final step uses the official &lt;code&gt;actions/github-script&lt;/code&gt; action to process those text files. If the AI flags real logic errors and populates valid comments, the script marks the PR review state as &lt;code&gt;REQUEST_CHANGES&lt;/code&gt;. If everything looks clear, it defaults to a standard tracking &lt;code&gt;COMMENT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If a specific line index mismatch occurs, a try/catch block acts as a fallback to post the core high-level summary review anyway so you don't lose the AI insight.&lt;/p&gt;




&lt;h2&gt;
  
  
  Make it Your Own
&lt;/h2&gt;

&lt;p&gt;The beauty of this setup is how easy it is to alter. By adjusting the &lt;code&gt;prompt_text&lt;/code&gt; section in the Python block, you can easily instruct the LLM to write in a specific language, focus tightly on security vulnerabilities, or ignore everything except your performance benchmarks. Drop the &lt;code&gt;GEMINI_API_KEY&lt;/code&gt; into your GitHub repository secrets, and you have an automated, self-hosted code guardrail running completely under your control!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ThierryRakotomanana/articles/blob/main/published/ai/ai-pr-reviewer.md" rel="noopener noreferrer"&gt;Github link&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>github</category>
      <category>python</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
