{
  "id": "5huhBp1j8OAjtrj9",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Automated Github PR Review System",
  "tags": [],
  "nodes": [
    {
      "id": "bdbad7ce-ded6-4067-aec0-c351df1e053b",
      "name": "1. Trigger: PR Opened or Updated",
      "type": "n8n-nodes-base.githubTrigger",
      "position": [
        -464,
        -80
      ],
      "parameters": {
        "owner": {
          "__rl": true,
          "mode": "list",
          "value": "guptadivyanshu765",
          "cachedResultUrl": "https://github.com/guptadivyanshu765",
          "cachedResultName": "guptadivyanshu765"
        },
        "events": [
          "pull_request"
        ],
        "options": {},
        "repository": {
          "__rl": true,
          "mode": "list",
          "value": "gst-mcp",
          "cachedResultUrl": "https://github.com/guptadivyanshu765/gst-mcp",
          "cachedResultName": "gst-mcp"
        }
      },
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "63822df2-b211-47bb-82ad-c2ffa2df9658",
      "name": "2. Filter: Opened/Synchronize Only",
      "type": "n8n-nodes-base.if",
      "position": [
        -240,
        -80
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "or",
          "conditions": [
            {
              "id": "3f4eea74-d4c4-4bf9-b94e-0e8c45438c41",
              "operator": {
                "name": "filter.operator.equals",
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{$json.body.action}}",
              "rightValue": "opened"
            },
            {
              "id": "de86b8be-7db8-47f5-8b49-b903aaf5fe43",
              "operator": {
                "name": "filter.operator.equals",
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{$json.body.action}}",
              "rightValue": "synchronize"
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "7af40130-1108-4cc4-b90e-1fdd999c416f",
      "name": "3. Fetch PR Diff",
      "type": "n8n-nodes-base.github",
      "position": [
        64,
        -96
      ],
      "parameters": {
        "owner": {
          "__rl": true,
          "mode": "list",
          "value": "guptadivyanshu765",
          "cachedResultUrl": "https://github.com/guptadivyanshu765",
          "cachedResultName": "guptadivyanshu765"
        },
        "resource": "pullRequest",
        "operation": "getDiff",
        "repository": {
          "__rl": true,
          "mode": "list",
          "value": "gst-mcp",
          "cachedResultUrl": "https://github.com/guptadivyanshu765/gst-mcp",
          "cachedResultName": "gst-mcp"
        },
        "pullRequestNumber": "={{ $json.body.pull_request.number }}"
      },
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "1f0a1b09-903b-4ed6-9b71-8c80daa4d5dc",
      "name": "5. AI Security  Code Review",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        576,
        304
      ],
      "parameters": {
        "text": "=={{ \"PR Title: \" + $json.prTitle + \"\\nAuthor: \" + $json.prAuthor + \"\\nRepository: \" + $json.repoFullName + \"\\n\\nDiff:\\n\" + $json.formattedDiff }}",
        "options": {
          "systemMessage": "Act as a senior security and code reviewer. You will be given a pull request's title, author, repository, and diff.\n\nAnalyze the diff for: hardcoded secrets, API keys, or passwords, SQL injection risk, unsafe eval/exec usage, missing input validation, insecure auth logic, overly broad permissions, obvious logic bugs, missing error handling.\n\nDetermine an overall verdict (pass, needs_changes, or blocked), list every specific issue found with its file, an approximate line number in the file where the issue occurs (integer, best estimate from the diff hunk headers), severity, and a short description, and write a 2-3 sentence summary of your findings.\n\nBase your analysis only on the actual diff content provided. Do not invent issues that aren't present in the diff.\n\nYour response must strictly follow the structured output format required by the system. Do not return markdown or prose."
        },
        "promptType": "define",
        "hasOutputParser": true
      },
      "typeVersion": 3.1
    },
    {
      "id": "046afdc7-048c-45da-8705-b7a062eb2884",
      "name": "AI Model (DeepSeek)",
      "type": "@n8n/n8n-nodes-langchain.lmChatDeepSeek",
      "position": [
        448,
        448
      ],
      "parameters": {
        "model": "deepseek-v4-flash",
        "options": {}
      },
      "credentials": {
        "deepSeekApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "6474e8ce-2916-4ecf-a47d-21ef8d7ba299",
      "name": "Output Schema (Verdict, Issues, Summary)",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "position": [
        848,
        448
      ],
      "parameters": {
        "jsonSchemaExample": "{\n  \"verdict\": \"pass\",\n  \"issues\": [\n    { \"file\": \"example.py\", \"line\": 12, \"severity\": \"high\", \"description\": \"example issue\" }\n  ],\n  \"summary\": \"example summary\"\n}"
      },
      "typeVersion": 1.3
    },
    {
      "id": "7b559637-7c45-4ed9-8a52-68ae4ea03ad4",
      "name": "11. Route by Verdict",
      "type": "n8n-nodes-base.switch",
      "position": [
        1984,
        -112
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "outputKey": "blocked",
              "conditions": {
                "options": {
                  "version": 3,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "405984dd-e6fe-4a2c-8567-9c1e016ea0d0",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $('AI Agent').item.json.output.verdict }}",
                    "rightValue": "blocked"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "needs_changes",
              "conditions": {
                "options": {
                  "version": 3,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "74cb50f1-f3d0-49ca-a723-bbc6b42eec5b",
                    "operator": {
                      "name": "filter.operator.equals",
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $('AI Agent').item.json.output.verdict }}",
                    "rightValue": "needs_changes"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "pass",
              "conditions": {
                "options": {
                  "version": 3,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "33d27c3b-00b5-404d-8b48-b13e89ffc8ca",
                    "operator": {
                      "name": "filter.operator.equals",
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $('AI Agent').item.json.output.verdict }}",
                    "rightValue": "pass"
                  }
                ]
              },
              "renameOutput": true
            }
          ]
        },
        "options": {}
      },
      "typeVersion": 3.4
    },
    {
      "id": "455698f9-8f0f-4f77-bd08-f08929efd6e3",
      "name": "12a. Notify Blocked (Email)",
      "type": "n8n-nodes-base.gmail",
      "position": [
        2208,
        -288
      ],
      "parameters": {
        "sendTo": "user@example.com",
        "message": "=<p><b>PR #{{ $('Github Trigger').item.json.body.pull_request.number }}</b> in {{ $('Github Trigger').item.json.body.repository.full_name }}, opened by {{ $('Github Trigger').item.json.body.pull_request.user.login }}, was <b>BLOCKED</b> by AI review.</p><p><b>Summary:</b><br>{{ $('AI Agent').item.json.output.summary }}</p><p><b>Issues found:</b><br>{{ $('AI Agent').item.json.output.issues.map(i => `[${i.severity.toUpperCase()}] ${i.file}: ${i.description}`).join('<br>') }}</p><p><a href=\"{{ $('Github Trigger').item.json.body.pull_request.html_url }}\">View Pull Request on GitHub</a></p>",
        "options": {},
        "subject": "=\ud83d\udea8 PR Blocked by AI Review: {{ $('Github Trigger').item.json.body.pull_request.title }}"
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "status_check_node",
      "name": "6. Post Commit Status Check",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "Posts a commit status check to GitHub so a 'blocked'/'needs_changes' verdict shows as a failed check on the PR (state=failure), and 'pass' shows success. Enable branch protection on main requiring this check ('ai-pr-reviewer' context) to actually block merges.",
      "position": [
        928,
        -96
      ],
      "parameters": {
        "url": "=https://api.github.com/repos/guptadivyanshu765/gst-mcp/statuses/{{ $('Github Trigger').item.json.body.pull_request.head.sha }}",
        "method": "POST",
        "options": {},
        "jsonBody": "={{ JSON.stringify({ state: $json.output.verdict === 'pass' ? 'success' : 'failure', description: 'AI Review: ' + $json.output.verdict, context: 'ai-pr-reviewer' }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "githubApi"
      },
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "ac613049-ded5-4ea7-bd9c-20aedf4fec74",
      "name": "12b. Notify Needs Changes (Email)",
      "type": "n8n-nodes-base.gmail",
      "position": [
        2208,
        -96
      ],
      "parameters": {
        "sendTo": "user@example.com",
        "message": "=<p><b>PR #{{ $('Github Trigger').item.json.body.pull_request.number }}</b> in {{ $('Github Trigger').item.json.body.repository.full_name }}, opened by {{ $('Github Trigger').item.json.body.pull_request.user.login }}, <b>NEEDS CHANGES</b> before merge.</p><p><b>Summary:</b><br>{{ $('AI Agent').item.json.output.summary }}</p><p><b>Issues found:</b><br>{{ $('AI Agent').item.json.output.issues.map(i => `[${i.severity.toUpperCase()}] ${i.file}: ${i.description}`).join('<br>') }}</p><p><a href=\"{{ $('Github Trigger').item.json.body.pull_request.html_url }}\">View Pull Request on GitHub</a></p>",
        "options": {},
        "subject": "=\u26a0\ufe0f PR Needs Changes: {{ $('Github Trigger').item.json.body.pull_request.title }}"
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "70614b3a-6000-4f2c-af36-94d5c67c8720",
      "name": "12c. Notify Passed (Email)",
      "type": "n8n-nodes-base.gmail",
      "position": [
        2208,
        96
      ],
      "parameters": {
        "sendTo": "user@example.com",
        "message": "=<p><b>PR #{{ $('Github Trigger').item.json.body.pull_request.number }}</b> in {{ $('Github Trigger').item.json.body.repository.full_name }}, opened by {{ $('Github Trigger').item.json.body.pull_request.user.login }}, <b>PASSED</b> AI review.</p><p><b>Summary:</b><br>{{ $('AI Agent').item.json.output.summary }}</p><p><a href=\"{{ $('Github Trigger').item.json.body.pull_request.html_url }}\">View Pull Request on GitHub</a></p>",
        "options": {},
        "subject": "=\u2705 PR Passed AI Review: {{ $('Github Trigger').item.json.body.pull_request.title }}"
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "pr_summary_comment_node",
      "name": "7. Post Summary Comment on PR",
      "type": "n8n-nodes-base.github",
      "position": [
        1088,
        -96
      ],
      "parameters": {
        "body": "=## \ud83e\udd16 AI PR Review: {{ $('AI Agent').item.json.output.verdict.toUpperCase() }}\n\n**Summary:** {{ $('AI Agent').item.json.output.summary }}\n\n**Issues found:** {{ $('AI Agent').item.json.output.issues.length }}\n{{ $('AI Agent').item.json.output.issues.map(i => `- **[${i.severity.toUpperCase()}]** \\`${i.file}\\` (line ${i.line}): ${i.description}`).join('\\n') }}\n\n---\n*Automated review. See inline comments on the diff for issue locations.*",
        "owner": {
          "__rl": true,
          "mode": "list",
          "value": "guptadivyanshu765",
          "cachedResultName": "guptadivyanshu765"
        },
        "operation": "createComment",
        "repository": {
          "__rl": true,
          "mode": "list",
          "value": "gst-mcp",
          "cachedResultName": "gst-mcp"
        },
        "issueNumber": "={{ $('Github Trigger').item.json.body.pull_request.number }}"
      },
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "if_has_issues_node",
      "name": "8. Check: Any Issues Found",
      "type": "n8n-nodes-base.if",
      "position": [
        1312,
        -96
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "condition-1786127162496-m6gz53rns",
              "operator": {
                "type": "number",
                "operation": "gt"
              },
              "leftValue": "={{ $('AI Agent').item.json.output.issues.length }}",
              "rightValue": 0
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "prepare_inline_comments_node",
      "name": "9. Map Issues to Diff Positions",
      "type": "n8n-nodes-base.code",
      "position": [
        1536,
        -160
      ],
      "parameters": {
        "jsCode": "const rawDiff = $('Get a pull request diff').item.json.diff;\nconst issues = $('AI Agent').item.json.output.issues;\nconst commitSha = $('Github Trigger').item.json.body.pull_request.head.sha;\n\n// Build a map: filename -> [{ fileLine, diffPosition }]\n// GitHub's review comment API needs 'position': the line's position counted\n// from the start of the diff hunk, not the file's actual line number.\nfunction buildPositionMap(diffText) {\n  const files = {};\n  const lines = diffText.split('\\n');\n  let currentFile = null;\n  let diffPosition = 0;\n  let newLineNum = 0;\n\n  for (const line of lines) {\n    if (line.startsWith('diff --git')) {\n      const match = line.match(/b\\/(.+)$/);\n      currentFile = match ? match[1].trim() : null;\n      diffPosition = 0;\n      if (currentFile && !files[currentFile]) files[currentFile] = [];\n    } else if (line.startsWith('@@')) {\n      diffPosition++;\n      const m = line.match(/\\+(\\d+)/);\n      newLineNum = m ? parseInt(m[1], 10) : 0;\n    } else if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('index ')) {\n      // metadata lines, not counted toward position\n    } else if (line.startsWith('+')) {\n      diffPosition++;\n      if (currentFile) files[currentFile].push({ fileLine: newLineNum, diffPosition });\n      newLineNum++;\n    } else if (line.startsWith('-')) {\n      diffPosition++;\n      // removed line: counts toward position but has no new-file line number\n    } else {\n      diffPosition++;\n      if (currentFile) files[currentFile].push({ fileLine: newLineNum, diffPosition });\n      newLineNum++;\n    }\n  }\n  return files;\n}\n\nconst positionMap = buildPositionMap(rawDiff);\n\nconst results = [];\nfor (const issue of issues) {\n  const fileEntries = positionMap[issue.file];\n  if (!fileEntries || fileEntries.length === 0) continue; // file not in diff, can't anchor a comment\n\n  // pick the entry whose file line is closest to the LLM's guessed line\n  let best = fileEntries[fileEntries.length - 1];\n  let bestDiff = Infinity;\n  for (const entry of fileEntries) {\n    const d = Math.abs(entry.fileLine - (issue.line || entry.fileLine));\n    if (d < bestDiff) {\n      bestDiff = d;\n      best = entry;\n    }\n  }\n\n  results.push({\n    json: {\n      path: issue.file,\n      position: best.diffPosition,\n      body: `**[${issue.severity.toUpperCase()}]** ${issue.description}`,\n      commit_id: commitSha\n    }\n  });\n}\n\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "post_inline_comment_node",
      "name": "10. Post Inline Comments on Flagged Lines",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1760,
        -160
      ],
      "parameters": {
        "url": "=https://api.github.com/repos/guptadivyanshu765/gst-mcp/pulls/{{ $('Github Trigger').item.json.body.pull_request.number }}/comments",
        "method": "POST",
        "options": {},
        "jsonBody": "={{ JSON.stringify({ body: $json.body, commit_id: $json.commit_id, path: $json.path, position: $json.position }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "githubApi"
      },
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "sticky_overview",
      "name": "Sticky Note - Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1376,
        -768
      ],
      "parameters": {
        "color": 7,
        "width": 660,
        "height": 1836,
        "content": "## \ud83e\udd16 Automated GitHub PR Security & Code Reviewer\n\nThis workflow turns every pull request into an instantly-reviewed one. The moment a PR opens or updates, AI analyzes the diff for security and code issues, then reports back on GitHub itself and by email, no one has to remember to run a manual security pass.\n\n### \ud83e\udde9 Why This Isn't Just Another Linter\nMost automated PR checks (CodeQL, linters, test runners) only catch known rule-based patterns. This workflow instead:\n\u2022 Uses an LLM to reason about context, not just pattern-match, catching things like a hardcoded secret OR invalid syntax mixed into the wrong file type\n\u2022 Anchors feedback to the exact diff position, not a guessed file line, so inline comments always land on the right line\n\u2022 Posts a real GitHub commit status (not just a comment), so it can become an enforced merge gate, not just advice someone can ignore\n\u2022 This combination took inline comment accuracy from \"guessed line, often wrong\" to reliably correct by parsing the actual diff hunk positions\n\n### \ud83d\udd0d What It Does\nThis workflow:\n\u2022 Triggers on every PR opened or updated (synchronize)\n\u2022 Fetches the full diff and sends it to an AI reviewer\n\u2022 Posts a pass/needs_changes/blocked commit status check on the PR\n\u2022 Posts a summary comment with the verdict and every issue found\n\u2022 Posts individual inline comments directly on each flagged line\n\u2022 Emails the relevant verdict-specific report so nothing gets missed\n\n### \u2699\ufe0f How It Works\n\u2022 GitHub Trigger fires on pull_request events, filtered to opened/synchronize only\n\u2022 The PR diff is fetched and formatted (truncated if huge) for the AI\n\u2022 An AI Agent reviews it against a structured checklist (secrets, injection risk, unsafe eval, missing validation, broken auth, logic bugs, missing error handling) and returns a strict JSON verdict\n\u2022 A commit status check posts immediately based on the verdict\n\u2022 A summary comment posts, then each issue is mapped to its real diff position and posted as an inline comment\n\u2022 A Switch node routes to one of three email notifications based on the final verdict\n\n### \ud83d\udcaa Key Capabilities\n\u2022 Structured, enforced JSON output (verdict, severity-tagged issues, summary), not free-form prose\n\u2022 Diff-position-aware inline commenting, computed from the real diff text, not guessed\n\u2022 Real GitHub status check integration, can be wired into branch protection to actually block merges\n\u2022 Three-way verdict routing (blocked / needs_changes / pass) with tailored notifications for each\n\u2022 Advisory by design: a human always makes the final merge call, this only removes the guesswork\n\n### \ud83d\udce6 Requirements\n\u2022 GitHub fine-grained PAT with: Pull requests (R/W), Contents (Read-only), Metadata (Read-only), Webhooks (R/W), Commit statuses (R/W)\n\u2022 AI model credential (DeepSeek used here, swap for any OpenAI-compatible provider)\n\u2022 Gmail account for notifications (or swap for Slack, same routing applies)\n\u2022 (Recommended) GitHub branch protection rule requiring the \"ai-pr-reviewer\" status check\n\n### \ud83d\udca1 Example Use Cases\n\u2022 Small teams/startups without a dedicated security reviewer on every PR\n\u2022 Citizen-developer / low-code automation programs where non-engineers are shipping workflows with live API access\n\u2022 Open-source repos needing a consistent first-pass review before a maintainer's time is spent\n\u2022 Any team wanting a documented, auditable trail of what was flagged and why on every merge"
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_notifications",
      "name": "Sticky Note - Notifications",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1952,
        -528
      ],
      "parameters": {
        "color": 2,
        "width": 624,
        "height": 836,
        "content": "## \ud83d\udce7 Notifications\nOne branch per verdict: blocked / needs changes / passed.\n\nSwap these Gmail nodes for Slack (or both) if you prefer chat-based alerts \u2014 same routing, just a different output node."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_group_trigger",
      "name": "Sticky Group - Trigger",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -544,
        -320
      ],
      "parameters": {
        "color": 2,
        "width": 480,
        "height": 408,
        "content": "## Trigger & filter incoming PRs\n\nFires on every pull_request event, then filters down to only 'opened' or 'synchronize' so a fresh push gets re-reviewed without re-triggering on every PR event type (labels, comments, etc)."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_group_fetch",
      "name": "Sticky Group - Fetch Diff",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        16,
        -320
      ],
      "parameters": {
        "color": 2,
        "width": 420,
        "height": 408,
        "content": "## Fetch and prepare the diff\n\nPulls the raw unified diff for the PR, attaches PR title/author/repo metadata, and truncates it if huge so it fits cleanly inside the AI's context window."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_group_ai",
      "name": "Sticky Group - AI Review",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        368,
        112
      ],
      "parameters": {
        "color": 2,
        "width": 608,
        "height": 472,
        "content": "## AI review with structured output\n\nAn AI Agent reviews the diff against a security + code quality checklist. The Structured Output Parser enforces a strict verdict/issues/summary JSON shape, including a line number per issue, used later to place inline comments."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_group_status",
      "name": "Sticky Group - Status  Summary",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        864,
        -336
      ],
      "parameters": {
        "color": 2,
        "width": 612,
        "height": 424,
        "content": "## Post status check & summary comment\n\nImmediately posts a pass/fail commit status to GitHub, then posts one overview comment on the PR listing the verdict and every issue found."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky_group_inline",
      "name": "Sticky Group - Inline Comments",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1504,
        -352
      ],
      "parameters": {
        "color": 2,
        "width": 420,
        "height": 440,
        "content": "## Map & post inline comments\n\nSkips cleanly if no issues were found. Otherwise parses the real diff text to compute each issue's exact diff position, then posts a review comment directly on that line."
      },
      "typeVersion": 1
    },
    {
      "id": "b4c637de-9578-4743-b224-7457bd979727",
      "name": "4. Format Diff for AI",
      "type": "n8n-nodes-base.code",
      "position": [
        288,
        -96
      ],
      "parameters": {
        "jsCode": "const diffText = $input.first().json.diff;\n\n// Pull PR metadata from the original GitHub Trigger payload\nconst trigger = $('Github Trigger').first().json.body;\n\nconst prNumber = trigger.pull_request.number;\nconst prTitle = trigger.pull_request.title;\nconst prAuthor = trigger.pull_request.user.login;\nconst repoFullName = trigger.repository.full_name;\n\n// Truncate diff if it's huge, so it doesn't blow the LLM context window\nconst MAX_DIFF_LENGTH = 8000;\nconst truncated = diffText.length > MAX_DIFF_LENGTH;\nconst formattedDiff = truncated\n  ? diffText.slice(0, MAX_DIFF_LENGTH) + '\\n\\n... [diff truncated, too large to show in full]'\n  : diffText;\n\nreturn [{\n  json: {\n    prNumber,\n    prTitle,\n    prAuthor,\n    repoFullName,\n    formattedDiff,\n    truncated\n  }\n}];"
      },
      "typeVersion": 2
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "73e20fc8-644e-4c76-9c12-071bf2aab1f3",
  "nodeGroups": [],
  "connections": {
    "3. Fetch PR Diff": {
      "main": [
        [
          {
            "node": "4. Format Diff for AI",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Model (DeepSeek)": {
      "ai_languageModel": [
        [
          {
            "node": "5. AI Security  Code Review",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "11. Route by Verdict": {
      "main": [
        [
          {
            "node": "12a. Notify Blocked (Email)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "12b. Notify Needs Changes (Email)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "12c. Notify Passed (Email)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "4. Format Diff for AI": {
      "main": [
        [
          {
            "node": "5. AI Security  Code Review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "8. Check: Any Issues Found": {
      "main": [
        [
          {
            "node": "9. Map Issues to Diff Positions",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "11. Route by Verdict",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "5. AI Security  Code Review": {
      "main": [
        [
          {
            "node": "6. Post Commit Status Check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "6. Post Commit Status Check": {
      "main": [
        [
          {
            "node": "7. Post Summary Comment on PR",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "7. Post Summary Comment on PR": {
      "main": [
        [
          {
            "node": "8. Check: Any Issues Found",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "9. Map Issues to Diff Positions": {
      "main": [
        [
          {
            "node": "10. Post Inline Comments on Flagged Lines",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "1. Trigger: PR Opened or Updated": {
      "main": [
        [
          {
            "node": "2. Filter: Opened/Synchronize Only",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "2. Filter: Opened/Synchronize Only": {
      "main": [
        [
          {
            "node": "3. Fetch PR Diff",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Output Schema (Verdict, Issues, Summary)": {
      "ai_outputParser": [
        [
          {
            "node": "5. AI Security  Code Review",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "10. Post Inline Comments on Flagged Lines": {
      "main": [
        [
          {
            "node": "11. Route by Verdict",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}