{
  "id": "3y8HmuJr5BXYuHGo",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Review GitLab merge requests with parallel AI reviewers",
  "tags": [],
  "nodes": [
    {
      "id": "7b4c951f-df41-479b-894b-a2807530a58f",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -3120,
        -384
      ],
      "parameters": {
        "width": 768,
        "height": 848,
        "content": "## How it works\n\nThis workflow runs an AI-assisted review for GitLab merge requests.\n\nWhen a reviewer posts the trigger comment in a merge request discussion, the workflow fetches the changed files, filters unsupported diffs, and prepares review context for each file.\n\nEach changed file is analyzed in parallel by three reviewers:\n- Bug reviewer\n- Security reviewer\n- Maintainability reviewer\n\nTheir findings are merged and then checked by a verifier, which removes weak, duplicate, or pre-existing issues.\n\nVerified findings that pass the posting checks can be posted as inline GitLab comments.\nA separate summary reply is posted back to the original trigger discussion.\n\n## Setup\n\nBefore using this template:\n\n- Connect your GitLab credential\n- Connect your LLM credential\n- Update the values in **Workflow Configuration** for your environment:\n  - GitLab base URL\n  - Review trigger phrase\n  - Start / summary / no-issues messages\n  - Minimum confidence required for posting. \n- Review the prompts and model settings used by each reviewer and the verifier\n- Test the workflow on a sample merge request discussion before enabling it\n\nThe verifier assigns a confidence score to each finding.\nOnly findings with status=keep and final_confidence at or above this threshold are posted. \nFindings with a resolved inline position are posted as inline comments; otherwise they are posted as reply comments.\n\nLower threshold values increase recall but may allow more false positives.\nHigher threshold values are stricter and may suppress borderline findings."
      },
      "typeVersion": 1
    },
    {
      "id": "e8c95236-4071-43ce-926f-4fa1a7c50dd1",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2256,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 880,
        "height": 448,
        "content": "## Trigger the review\n\nPost the configured trigger comment in a GitLab merge request discussion to start the workflow.\n\nDefault trigger: `+0`\nChange it in **Workflow Configuration**."
      },
      "typeVersion": 1
    },
    {
      "id": "3529266d-af18-4585-989b-01af994b9c18",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1312,
        -112
      ],
      "parameters": {
        "color": 7,
        "width": 800,
        "height": 560,
        "content": "## Fetch and filter changed files\n\nLoad the merge request changes, split them into one file per item, and skip unsupported diffs such as renamed, deleted, or non-commentable files."
      },
      "typeVersion": 1
    },
    {
      "id": "a3ada654-1ded-4275-8d84-ed3b6e5b3f61",
      "name": "GitLab Discussion Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        -2160,
        240
      ],
      "parameters": {
        "path": "8a1a0ef5-a1ab-49e0-9cd5-321d0c4bc080",
        "options": {},
        "httpMethod": "POST"
      },
      "typeVersion": 2
    },
    {
      "id": "8dbf3590-f83f-437e-bcca-88846af6ff2c",
      "name": "Check Review Trigger Comment",
      "type": "n8n-nodes-base.if",
      "position": [
        -1648,
        240
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "617eb2c5-dd4b-4e28-b533-0c32ea6ca961",
              "operator": {
                "name": "filter.operator.equals",
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.body.object_attributes.note }}",
              "rightValue": "={{ $('Workflow Configuration').first().json.reviewTriggerPhrase }}"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "4a7768fd-9c71-4e5c-ae77-06395b94d044",
      "name": "Post Review Started Reply",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -1232,
        48
      ],
      "parameters": {
        "url": "={{ $('Workflow Configuration').first().json.gitlabBaseUrl }}/projects/{{ $('GitLab Discussion Webhook').item.json.body.project.id }}/merge_requests/{{ $('GitLab Discussion Webhook').item.json.body.merge_request.iid }}/discussions/{{ $('GitLab Discussion Webhook').item.json.body.object_attributes.discussion_id }}/notes",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "multipart-form-data",
        "authentication": "predefinedCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "body",
              "value": "={{ $('Workflow Configuration').first().json.reviewStartedMessage }}"
            }
          ]
        },
        "nodeCredentialType": "gitlabApi"
      },
      "credentials": {
        "gitlabApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "ced91e4b-aea1-48d5-baca-11e61cf53e11",
      "name": "Fetch Merge Request Changes",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -1232,
        224
      ],
      "parameters": {
        "url": "={{ $('Workflow Configuration').first().json.gitlabBaseUrl }}/projects/{{ $json[\"body\"][\"project_id\"] }}/merge_requests/{{ $json[\"body\"][\"merge_request\"][\"iid\"] }}/changes",
        "options": {},
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "gitlabApi"
      },
      "credentials": {
        "gitlabApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "bb0320ac-0086-484f-aba4-812dd83ada77",
      "name": "Split Changed Files",
      "type": "n8n-nodes-base.splitOut",
      "position": [
        -960,
        224
      ],
      "parameters": {
        "options": {},
        "fieldToSplitOut": "changes"
      },
      "typeVersion": 1
    },
    {
      "id": "eb1c3141-4dbc-44e5-b232-2b96c6aca112",
      "name": "Filter Supported Diffs",
      "type": "n8n-nodes-base.if",
      "position": [
        -720,
        224
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c6e1430b-84a7-47ce-8fe9-7b94da0f2d31",
              "operator": {
                "type": "boolean",
                "operation": "false",
                "singleValue": true
              },
              "leftValue": "={{ $json.renamed_file }}",
              "rightValue": ""
            },
            {
              "id": "bf6e9eb9-d72d-459c-a722-9614bab8842c",
              "operator": {
                "type": "boolean",
                "operation": "false",
                "singleValue": true
              },
              "leftValue": "={{ $json.deleted_file }}",
              "rightValue": ""
            },
            {
              "id": "501623a9-9515-4034-bb13-a5a6a4f924eb",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ /(^|\\n)@@ -\\d+(?:,\\d+)? \\+\\d+(?:,\\d+)? @@/m.test($json.diff ?? '') }}",
              "rightValue": "@@"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "3e8222aa-d68a-4366-9167-f039831d8ed5",
      "name": "Prepare Review Context",
      "type": "n8n-nodes-base.code",
      "position": [
        -288,
        208
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const diff = $input.item.json.diff ?? '';\n\nconst lines = diff\n  .replace(/\\n\\\\ No newline at end of file/g, '')\n  .split('\\n');\n\nlet originalCode = '';\nlet newCode = '';\nlet newCodeLogicalLine = 0;\nconst newCodeLineMap = {};\n\nlet oldLine = 0;\nlet newLine = 0;\n\nlet reviewableNewCode = '';\n\nfor (const line of lines) {\n  const hunk = line.match(/^@@ -(\\d+)(?:,\\d+)? \\+(\\d+)(?:,\\d+)? @@/);\n  if (hunk) {\n    oldLine = Number(hunk[1]);\n    newLine = Number(hunk[2]);\n    continue;\n  }\n\n  if (line.startsWith('+++') || line.startsWith('---')) {\n    continue;\n  }\n\n  if (line.startsWith('-')) {\n    originalCode += line + '\\n';\n    oldLine += 1;\n    continue;\n  }\n\n  if (line.startsWith('+')) {\n    newCode += line + '\\n';\n    newCodeLogicalLine += 1;\n    newCodeLineMap[newCodeLogicalLine] = newLine;\n    reviewableNewCode += `[L${newLine}] ${line}\\n`;\n    newLine += 1;\n    continue;\n  }\n\n  // context line\n  newCode += line + '\\n';\n  newCodeLogicalLine += 1;\n  newCodeLineMap[newCodeLogicalLine] = newLine;\n  reviewableNewCode += `[L${newLine}] ${line}\\n`;\n  oldLine += 1;\n  newLine += 1;\n}\n\nreturn {\n  path: $input.item.json.new_path || $input.item.json.old_path || '',\n  oldPath: $input.item.json.old_path || '',\n  newPath: $input.item.json.new_path || '',\n  gitDiff: diff,\n  originalCode,\n  newCode,\n  newCodeLineMap,\n  reviewableNewCode,\n  mrTitle: $('GitLab Discussion Webhook').item.json.body.merge_request.title || '',\n  mrDescription: $('GitLab Discussion Webhook').item.json.body.merge_request.description || '',\n  projectId: $('GitLab Discussion Webhook').item.json.body.project.id,\n  mrIid: $('GitLab Discussion Webhook').item.json.body.merge_request.iid,\n  discussionId: $('GitLab Discussion Webhook').item.json.body.object_attributes.discussion_id,\n  startSha: $('Fetch Merge Request Changes').item.json.diff_refs.start_sha || '',\n  headSha: $('Fetch Merge Request Changes').item.json.diff_refs.head_sha || '',\n  baseSha: $('Fetch Merge Request Changes').item.json.diff_refs.base_sha || ''\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "f979e02c-9f39-41ac-80af-5b02b7735101",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        96,
        -288
      ],
      "parameters": {
        "color": 7,
        "width": 608,
        "height": 1184,
        "content": "## Run parallel AI reviewers\n\nAnalyze each changed file with bug, security, and maintainability reviewers.\nEach reviewer returns structured findings for final verification."
      },
      "typeVersion": 1
    },
    {
      "id": "e2045ae9-9360-4b10-84ba-001dbfe67613",
      "name": "Merge Reviewer Results",
      "type": "n8n-nodes-base.merge",
      "position": [
        928,
        192
      ],
      "parameters": {
        "numberInputs": 3
      },
      "typeVersion": 3.2
    },
    {
      "id": "833740be-aae9-45e3-ba88-9d326cfb0cb9",
      "name": "Combine Findings",
      "type": "n8n-nodes-base.code",
      "position": [
        1184,
        208
      ],
      "parameters": {
        "jsCode": "const allItems = $input.all();\n\nlet merged = [];\n\n// Assuming that the reviewer\u2019s output is stored in item.json.output\nfor (const item of allItems) {\n  const src = item.json.output ?? item.json;\n  const findings = src.findings || [];\n  merged = merged.concat(findings);\n}\n\nconst uniqueMap = new Map();\n\nfor (const f of merged) {\n  const key = `${f.title}_${f.path}_${f.line}`;\n  if (!uniqueMap.has(key)) {\n    uniqueMap.set(key, f);\n  }\n}\n\nreturn [\n  {\n    json: {\n      path: $('Prepare Review Context').first().json.path || '',\n      oldPath: $('Prepare Review Context').first().json.oldPath || '',\n      newPath: $('Prepare Review Context').first().json.newPath || '',\n      gitDiff: $('Prepare Review Context').first().json.gitDiff || '',\n      originalCode: $('Prepare Review Context').first().json.originalCode || '',\n      newCode: $('Prepare Review Context').first().json.newCode || '',\n      newCodeLineMap: $('Prepare Review Context').first().json.newCodeLineMap || {},\n      reviewableNewCode: $('Prepare Review Context').first().json.reviewableNewCode || '',\n      mrTitle: $('Prepare Review Context').first().json.mrTitle || '',\n      mrDescription: $('Prepare Review Context').first().json.mrDescription || '',\n      projectId: $('Prepare Review Context').first().json.projectId || '',\n      mrIid: $('Prepare Review Context').first().json.mrIid || '',\n      discussionId: $('Prepare Review Context').first().json.discussionId || '',\n      startSha: $('Prepare Review Context').first().json.startSha || '',\n      headSha: $('Prepare Review Context').first().json.headSha || '',\n      baseSha: $('Prepare Review Context').first().json.baseSha || '',\n      findings: Array.from(uniqueMap.values())\n    }\n  }\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "90de14bc-3f94-44bd-95c3-9ffbf2840ee4",
      "name": "Analyze Bugs",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "onError": "continueRegularOutput",
      "maxTries": 2,
      "position": [
        288,
        -128
      ],
      "parameters": {
        "text": "={{ JSON.stringify($json, null, 2) }}",
        "options": {
          "systemMessage": "You are the Bug Reviewer for a pull request.\n\nGoal:\nDetect only newly introduced functional or logical bugs caused by this change.\n\nFocus areas:\n- Incorrect logic\n- Wrong conditions or boundary checks\n- Incorrect calculations (length, offsets, indices)\n- Off-by-one errors\n- Wrong assumptions about input values\n\nDo NOT report:\n- Style issues\n- Pure maintainability concerns\n- Security concerns unless they are primarily functional bugs in this change\n\nInput:\n- path: {{ $json.path }}\n- gitDiff: {{ $json.gitDiff }}\n- originalCode: {{ $json.originalCode }}\n- reviewableNewCode: {{ $json.reviewableNewCode }}\n- mr: {{ $json.mrTitle }}, {{ $json.mrDescription }}\n\nRules:\n- Report only issues introduced by this change\n- Do not speculate beyond the provided diff and code\n- If there is no real bug, return no findings\n- Use reviewableNewCode for line selection\n- line must be the L-number from reviewableNewCode\n- Prefer the exact changed statement over nearby context\n- reviewer must be \"bug\"\n- category must be \"bug\"\n- severity must be one of: high, medium, low\n- confidence must be an integer from 0 to 100\n- path must be the input path\n- Return a single JSON object matching the structured output schema exactly\n- Do not wrap the result in an \"output\" field\n- Return JSON only\n- Do not add explanations or markdown\n- If there is no real issue, return no findings.\n- Do not omit required fields when returning a finding."
        },
        "promptType": "define",
        "hasOutputParser": true
      },
      "retryOnFail": true,
      "typeVersion": 3.1,
      "alwaysOutputData": true
    },
    {
      "id": "d8a1225a-8bfc-4973-9374-7aed82883310",
      "name": "Bug Reviewer Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatAzureOpenAi",
      "position": [
        208,
        48
      ],
      "parameters": {
        "model": "gpt-5.4-mini",
        "options": {
          "topP": 1
        }
      },
      "credentials": {
        "azureOpenAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "109ef89e-9a1b-44fd-a002-8e8bb6ae7c19",
      "name": "Parse Bug Review Output",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "maxTries": 2,
      "position": [
        528,
        48
      ],
      "parameters": {
        "jsonSchemaExample": "{\n  \"reviewer\": \"bug\",\n  \"findings\": [\n    {\n      \"title\": \"CRC is computed over the wrong byte range\",\n      \"severity\": \"high\",\n      \"confidence\": 98,\n      \"path\": \"project/src/packet.c\",\n      \"line\": 42,\n      \"category\": \"bug\",\n      \"why\": \"The CRC input length was changed so one required byte is no longer included in the checksum.\",\n      \"suggestion\": \"Restore the original CRC input length so the decoder validates the intended packet range.\"\n    }\n  ]\n}"
      },
      "retryOnFail": true,
      "typeVersion": 1.3,
      "alwaysOutputData": true
    },
    {
      "id": "222b4278-17ea-4390-aed2-48c8d72e4282",
      "name": "Analyze Security Risks",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "onError": "continueRegularOutput",
      "maxTries": 2,
      "position": [
        288,
        208
      ],
      "parameters": {
        "text": "={{ JSON.stringify($json, null, 2) }}",
        "options": {
          "systemMessage": "You are the Security Reviewer for a pull request.\n\nGoal:\nDetect only newly introduced security risks caused by this change.\n\nFocus areas:\n- Missing validation of external input\n- Buffer overflow or out-of-bounds access\n- Unsafe memory operations\n- Injection risks\n- Sensitive data exposure\n- Missing checks that allow malformed input to cause damage\n\nDo NOT report:\n- General bugs unless they are real security risks\n- Pure maintainability concerns\n- Style issues\n\nInput:\n- path: {{ $json.path }}\n- gitDiff: {{ $json.gitDiff }}\n- originalCode: {{ $json.originalCode }}\n- reviewableNewCode: {{ $json.reviewableNewCode }}\n- mr: {{ $json.mrTitle }}, {{ $json.mrDescription }}\n\nRules:\n- Report only real security risks introduced by this change\n- If the issue is only a bug and not a real security risk, do not report it\n- If there is no real security issue, return no findings\n- Use reviewableNewCode for line selection\n- line must be the L-number from reviewableNewCode\n- Prefer the exact changed statement over nearby context\n- reviewer must be \"security\"\n- category must be \"security\"\n- severity must be one of: high, medium, low\n- confidence must be an integer from 0 to 100\n- path must be the input path\n- Return a single JSON object matching the structured output schema exactly\n- Do not wrap the result in an \"output\" field\n- Return JSON only\n- Do not add explanations or markdown\n- If there is no real issue, return no findings.\n- Do not omit required fields when returning a finding."
        },
        "promptType": "define",
        "hasOutputParser": true
      },
      "notesInFlow": false,
      "retryOnFail": true,
      "typeVersion": 3.1,
      "alwaysOutputData": true
    },
    {
      "id": "2e4fe6a8-ed29-41f2-9987-73e9d45028eb",
      "name": "Security Reviewer Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatAzureOpenAi",
      "position": [
        208,
        384
      ],
      "parameters": {
        "model": "gpt-5.4-mini",
        "options": {
          "topP": 1
        }
      },
      "credentials": {
        "azureOpenAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "7810d8b4-07d2-4eda-8e61-7a7d855e0645",
      "name": "Parse Security Review Output",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "position": [
        528,
        384
      ],
      "parameters": {
        "jsonSchemaExample": "{\n  \"reviewer\": \"security\",\n  \"findings\": [\n    {\n      \"title\": \"Missing upper-bound validation before payload copy\",\n      \"severity\": \"high\",\n      \"confidence\": 95,\n      \"path\": \"project/src/packet.c\",\n      \"line\": 46,\n      \"category\": \"security\",\n      \"why\": \"The decoded length is copied into the payload buffer without checking that it fits the destination buffer.\",\n      \"suggestion\": \"Validate that len does not exceed the payload buffer size before calling memcpy.\"\n    }\n  ]\n}"
      },
      "typeVersion": 1.3,
      "alwaysOutputData": true
    },
    {
      "id": "a91382fc-e303-4dff-91f8-1cd15d2de5c5",
      "name": "Analyze Maintainability Risks",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "onError": "continueRegularOutput",
      "maxTries": 2,
      "position": [
        288,
        560
      ],
      "parameters": {
        "text": "={{ JSON.stringify($json, null, 2) }}",
        "options": {
          "systemMessage": "You are the Maintainability Reviewer for a pull request.\n\nGoal:\nDetect only meaningful maintainability issues introduced by this change.\n\nFocus areas:\n- Missing defensive checks\n- Risky assumptions not validated\n- Changes that significantly reduce readability\n- Logic that becomes harder to test or reason about\n- Changes that increase future defect risk even if they are not immediate bugs\n\nDo NOT report:\n- Formatting issues\n- Naming preferences\n- Minor readability suggestions\n- General advice not tied to changed code\n- Pure bug or security issues unless the maintainability concern is clearly distinct\n\nInput:\n- path: {{ $json.path }}\n- gitDiff: {{ $json.gitDiff }}\n- originalCode: {{ $json.originalCode }}\n- reviewableNewCode: {{ $json.reviewableNewCode }}\n- mr: {{ $json.mrTitle }}, {{ $json.mrDescription }}\n\nRules:\n- Report only meaningful issues introduced by this change\n- If the issue is weak, subjective, or minor, do not report it\n- If there is no real maintainability issue, return no findings\n- Use reviewableNewCode for line selection\n- line must be the L-number from reviewableNewCode\n- Prefer the exact changed statement over nearby context\n- reviewer must be \"maintainability\"\n- category must be \"maintainability\"\n- severity must be one of: medium, low\n- confidence must be an integer from 0 to 100\n- path must be the input path\n- Return a single JSON object matching the structured output schema exactly\n- Do not wrap the result in an \"output\" field\n- Return JSON only\n- Do not add explanations\n- If there is no real issue, return no findings.\n- Do not omit required fields when returning a finding."
        },
        "promptType": "define",
        "hasOutputParser": true
      },
      "notesInFlow": false,
      "retryOnFail": true,
      "typeVersion": 3.1,
      "alwaysOutputData": true
    },
    {
      "id": "6ce560ca-d1b0-450d-ab26-f1aa13f5b7ba",
      "name": "Maintainability Reviewer Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatAzureOpenAi",
      "position": [
        208,
        736
      ],
      "parameters": {
        "model": "gpt-5.4-mini",
        "options": {
          "topP": 1
        }
      },
      "credentials": {
        "azureOpenAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "bf350a82-ed47-425c-ab99-0da161717062",
      "name": "Parse Maintainability Review Output",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "position": [
        512,
        736
      ],
      "parameters": {
        "jsonSchemaExample": "{\n  \"reviewer\": \"maintainability\",\n  \"findings\": [\n    {\n      \"title\": \"Length validation is missing in the decode path\",\n      \"severity\": \"medium\",\n      \"confidence\": 88,\n      \"path\": \"project/src/packet.c\",\n      \"line\": 46,\n      \"category\": \"maintainability\",\n      \"why\": \"The decode path relies on an unchecked length value before copying data into the payload buffer.\",\n      \"suggestion\": \"Add an explicit upper-bound check for len before copying the payload.\"\n    }\n  ]\n}"
      },
      "typeVersion": 1.3,
      "alwaysOutputData": true
    },
    {
      "id": "3d6a3c26-9f14-4f61-bc1f-20d6f0186575",
      "name": "Verify Findings",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "onError": "continueRegularOutput",
      "maxTries": 2,
      "position": [
        1632,
        208
      ],
      "parameters": {
        "text": "={{ JSON.stringify($json, null, 2) }}",
        "options": {
          "systemMessage": "You are the verifier for pull request review findings.\n\nGoal:\nValidate and normalize findings from the reviewers.\n\nInput:\n- path: {{ $json.path }}\n- gitDiff: {{ $json.gitDiff }}\n- originalCode: {{ $json.originalCode }}\n- reviewableNewCode: {{ $json.reviewableNewCode }}\n- findings: {{ JSON.stringify($json.findings, null, 2) }}\n\nTasks:\n- Keep only findings grounded in the diff\n- Remove speculative or weak findings\n- Remove duplicates\n- Prefer the most useful classification when findings overlap\n- Normalize severity and confidence\n- Produce concise, GitLab-ready English comments\n\nRules:\n\n[General]\n- Reviewer output may be imperfect; always re-evaluate findings\n- Drop findings that are not clearly supported by the diff\n- Path must not be empty; if missing or unknown, use input path\n\n[Line selection]\n- Use reviewableNewCode for line selection\n- line must be the L-number from reviewableNewCode\n- Prefer the exact changed statement over nearby context\n- If unclear, select the closest relevant changed line only when the finding clearly applies\n- If no specific changed line can be identified, set line to null and post_inline to false\n\n[Status]\n- status must be one of: keep, drop, duplicate, pre_existing\n- Use \"keep\" only for findings worth posting after verification\n- Never use \"open\" or any other value\n\n[Severity normalization]\n- final_severity must be one of: high, medium, low\n- Always re-evaluate severity; do not trust reviewer severity\n\nUse the following strict criteria:\n\nhigh:\n- Causes incorrect behavior, broken functionality, or protocol mismatch\n- Can lead to crash, data corruption, or memory safety issues\n- Security vulnerabilities (e.g., OOB write, injection)\n\nmedium:\n- Potential bug, missing validation, or fragile logic\n- May become a bug under certain conditions\n- Maintainability issues that can realistically cause future defects\n\nlow:\n- Readability, naming, minor structure or style issues\n- No realistic impact on correctness\n\n- When multiple findings describe the same root problem:\n  - Keep the most critical one\n  - Downgrade or mark others as duplicate\n  - Avoid multiple high severity findings for the same root cause\n\n[Confidence normalization]\n- final_confidence must be one of: 0, 25, 50, 75, 100\n- Use 100 only when directly proven by the diff with almost no ambiguity\n- Use 75 when strongly supported and very likely correct\n- Use 50 when plausible but requires assumptions or missing context\n- Use 25 when weak signal; generally should be dropped unless still useful\n- Use 0 when unsupported or incorrect\n\n- Findings with final_confidence = 0 should normally have status = \"drop\"\n\n[Inline decision]\n- post_inline must be boolean\n- Set post_inline = true only when:\n  - status = \"keep\"\n  - AND a specific changed line is clearly identified\n- Otherwise set post_inline = false\n- Do NOT use severity or confidence to decide inline vs reply\n\n[Output quality]\n- title must be concise English\n- comment must be short, practical, and directly actionable\n- Avoid repeating the same issue in multiple findings\n\n[Output format]\n- Return a single JSON object matching the structured output schema exactly\n- Do not wrap the result in an \"output\" field\n- Return JSON only\n- Do not add explanations or markdown\n- Do not omit any required field in validated_findings\n- If no finding should remain, return an empty validated_findings array"
        },
        "promptType": "define",
        "hasOutputParser": true
      },
      "retryOnFail": true,
      "typeVersion": 3.1,
      "alwaysOutputData": true
    },
    {
      "id": "800ab383-e391-407a-85ec-8dc5b8361974",
      "name": "Verifier Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatAzureOpenAi",
      "position": [
        1552,
        416
      ],
      "parameters": {
        "model": "gpt-5.4-mini",
        "options": {
          "topP": 1
        }
      },
      "credentials": {
        "azureOpenAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "c424f47a-cbe8-41bd-bcd5-83f7b1793fde",
      "name": "Parse Verification Output",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "position": [
        1840,
        416
      ],
      "parameters": {
        "jsonSchemaExample": "{\n  \"validated_findings\": [\n    {\n      \"title\": \"Restore CRC length and byte order\",\n      \"path\": \"project/src/packet.c\",\n      \"line\": 42,\n      \"final_severity\": \"high\",\n      \"final_confidence\": 100,\n      \"status\": \"keep\",\n      \"post_inline\": true,\n      \"comment\": \"The CRC validation now uses the wrong byte range and byte order, so valid packets can be rejected. Restore the original CRC length and trailer byte order.\"\n    },\n    {\n      \"title\": \"Clarify unchecked length assumption\",\n      \"path\": \"project/src/packet.c\",\n      \"line\": null,\n      \"final_severity\": \"low\",\n      \"final_confidence\": 50,\n      \"status\": \"keep\",\n      \"post_inline\": false,\n      \"comment\": \"The safety of this path depends on an external length guarantee. Consider documenting or enforcing that contract closer to the use site.\"\n    }\n  ]\n}"
      },
      "typeVersion": 1.3,
      "alwaysOutputData": true
    },
    {
      "id": "3ee34738-ec78-48ff-b1f3-b257c2a0929a",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1488,
        -16
      ],
      "parameters": {
        "color": 7,
        "width": 752,
        "height": 592,
        "content": "## Verify and normalize findings\n\nRe-check each finding against the diff, remove weak or duplicate issues, and normalize the final severity and confidence."
      },
      "typeVersion": 1
    },
    {
      "id": "24d31f5a-dbf6-448f-9141-d562b336813b",
      "name": "Normalize Verified Findings",
      "type": "n8n-nodes-base.code",
      "position": [
        2000,
        208
      ],
      "parameters": {
        "jsCode": "const out = $json.output ?? $json;\nconst meta = $('Combine Findings').first().json;\n\nconst validatedFindings = (out.validated_findings || []).map(f => {\n  const rawLine = f.line;\n  const line = Number(rawLine);\n  const lineMap = meta.newCodeLineMap || {};\n  const mappedLine = Number.isInteger(line) && line > 0\n    ? Number(lineMap[line] ?? line)\n    : null;\n\n  const normalizedLine =\n    Number.isInteger(mappedLine) && mappedLine > 0 ? mappedLine : null;\n\n  const normalizedStatus = f.status || 'drop';\n  const normalizedInline =\n    normalizedStatus === 'keep' &&\n    normalizedLine !== null &&\n    f.post_inline === true;\n\n  return {\n    ...f,\n    path: f.path && f.path !== 'unknown' ? f.path : (meta.path || ''),\n    oldPath: meta.oldPath || '',\n    newPath: meta.newPath || '',\n    projectId: meta.projectId || '',\n    mrIid: meta.mrIid || '',\n    discussionId: meta.discussionId || '',\n    startSha: meta.startSha || '',\n    headSha: meta.headSha || '',\n    baseSha: meta.baseSha || '',\n    line: normalizedLine,\n    post_inline: normalizedInline\n  };\n});\n\nconst keepCount = validatedFindings.filter(f => f.status === 'keep').length;\n\nreturn [\n  {\n    json: {\n      validated_findings: validatedFindings,\n      keep_count: keepCount\n    }\n  }\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "2d6de0fe-3507-470c-bd76-df6365079d53",
      "name": "Check Findings Exist",
      "type": "n8n-nodes-base.if",
      "position": [
        2416,
        352
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "90a27f07-8e7f-4b1d-bbde-05e0f73846f9",
              "operator": {
                "type": "number",
                "operation": "gte"
              },
              "leftValue": "={{$json.keep_count}}",
              "rightValue": 1
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "35f1a3ee-f0bf-42a6-a0a9-193a477708ef",
      "name": "Build Summary Comment",
      "type": "n8n-nodes-base.code",
      "position": [
        3360,
        432
      ],
      "parameters": {
        "jsCode": "return {\n  text: $('Workflow Configuration').first().json.summaryMessage\n    || '\u2705 Review finished. See inline comments for details.'\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "5ac8e5c4-d800-4f15-9ced-1bc334832716",
      "name": "Build No-Issues Comment",
      "type": "n8n-nodes-base.code",
      "position": [
        3360,
        640
      ],
      "parameters": {
        "jsCode": "return {\n  text: $('Workflow Configuration').first().json.noIssuesMessage\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "26500903-ef4a-42d8-bdb0-9b50504d1dd9",
      "name": "Post Summary Reply",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        3664,
        544
      ],
      "parameters": {
        "url": "={{ $('Workflow Configuration').first().json.gitlabBaseUrl }}/projects/{{ $('Combine Findings').item.json.projectId }}/merge_requests/{{ $('Combine Findings').item.json.mrIid }}/discussions/{{ $('Combine Findings').item.json.discussionId }}/notes",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "multipart-form-data",
        "authentication": "predefinedCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "body",
              "value": "={{ $json.text }}"
            }
          ]
        },
        "nodeCredentialType": "gitlabApi"
      },
      "credentials": {
        "gitlabApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "a6977948-6c59-4b2a-a609-61a2b18e9b91",
      "name": "Split Verified Findings",
      "type": "n8n-nodes-base.splitOut",
      "position": [
        2704,
        208
      ],
      "parameters": {
        "options": {},
        "fieldToSplitOut": "validated_findings"
      },
      "typeVersion": 1
    },
    {
      "id": "d01239bd-af12-48f5-b5eb-d7ef74889886",
      "name": "Filter Publishable Findings",
      "type": "n8n-nodes-base.if",
      "position": [
        2944,
        208
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "67f9d8b0-7268-4068-8572-873a8e4f8f43",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{$json.status}}",
              "rightValue": "keep"
            },
            {
              "id": "7d9ed55a-e8d5-491d-b7b0-9f26b466280c",
              "operator": {
                "type": "number",
                "operation": "gte"
              },
              "leftValue": "={{$json.final_confidence}}",
              "rightValue": "={{ $('Workflow Configuration').first().json.minConfidenceToPost }}"
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "1a91dabf-b880-4c18-b97c-bc6cb1ff764a",
      "name": "Build Inline Comment",
      "type": "n8n-nodes-base.code",
      "position": [
        3616,
        -224
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const iconMap = {\n  high: \"\ud83d\udd34\",\n  medium: \"\ud83d\udfe1\",\n  low: \"\ud83d\udd35\",\n};\n\nconst icon = iconMap[$json.final_severity] || \"\ud83d\udd35\";\n\nreturn {\n  ...$json,\n  text: `${icon} **${$json.title}**\\n\\n${$json.comment}`\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "0ed853a6-492a-4a20-aacf-d07c6c46e9a1",
      "name": "Post Inline Review Comment",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4192,
        -400
      ],
      "parameters": {
        "url": "={{ $('Workflow Configuration').first().json.gitlabBaseUrl }}/projects/{{ $json.projectId }}/merge_requests/{{ $json.mrIid }}/discussions",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "multipart-form-data",
        "authentication": "predefinedCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "body",
              "value": "={{ $json.text }}"
            },
            {
              "name": "position[position_type]",
              "value": "text"
            },
            {
              "name": "position[old_path]",
              "value": "={{ $json.oldPath }}"
            },
            {
              "name": "position[new_path]",
              "value": "={{ $json.newPath }}"
            },
            {
              "name": "position[start_sha]",
              "value": "={{ $json.startSha }}"
            },
            {
              "name": "position[head_sha]",
              "value": "={{ $json.headSha }}"
            },
            {
              "name": "position[base_sha]",
              "value": "={{ $json.baseSha }}"
            },
            {
              "name": "position[new_line]",
              "value": "={{ $json.positionNewLine ?? '' }}"
            },
            {
              "name": "position[old_line]",
              "value": "={{ $json.positionOldLine ?? '' }}"
            }
          ]
        },
        "nodeCredentialType": "gitlabApi"
      },
      "credentials": {
        "gitlabApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "726cc9e8-312f-4bac-b824-c0019a54cabe",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2320,
        -16
      ],
      "parameters": {
        "color": 7,
        "width": 848,
        "height": 592,
        "content": "## Decide what to publish\n\nPost only findings with `status = keep` and confidence at or above the configured threshold.\n\nIf a valid diff position is found, post an inline comment.\nOtherwise, post a reply comment in the trigger discussion."
      },
      "typeVersion": 1
    },
    {
      "id": "49308ef9-c8a9-4480-9bf7-cb76200dc741",
      "name": "Resolve GitLab Diff Position",
      "type": "n8n-nodes-base.code",
      "position": [
        3344,
        -224
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const diff = $('Combine Findings').first().json.gitDiff || '';\nconst targetLine = Number($json.line);\n\nfunction buildNewSideMap(gitDiff) {\n  const lines = gitDiff.replace(/\\n\\\\ No newline at end of file/g, '').split('\\n');\n\n  let oldLine = 0;\n  let newLine = 0;\n  const map = new Map();\n\n  for (const line of lines) {\n    const hunk = line.match(/^@@ -(\\d+)(?:,\\d+)? \\+(\\d+)(?:,\\d+)? @@/);\n    if (hunk) {\n      oldLine = Number(hunk[1]);\n      newLine = Number(hunk[2]);\n      continue;\n    }\n\n    if (line.startsWith('+++') || line.startsWith('---')) {\n      continue;\n    }\n\n    if (line.startsWith('+')) {\n      map.set(newLine, {\n        kind: 'added',\n        newLine,\n        oldLine: null,\n      });\n      newLine += 1;\n      continue;\n    }\n\n    if (line.startsWith('-')) {\n      oldLine += 1;\n      continue;\n    }\n\n    // context line\n    map.set(newLine, {\n      kind: 'context',\n      newLine,\n      oldLine,\n    });\n    oldLine += 1;\n    newLine += 1;\n  }\n\n  return map;\n}\n\nconst lineMap = buildNewSideMap(diff);\nconst pos = lineMap.get(targetLine);\n\nreturn {\n  ...$json,\n  positionKind: pos?.kind || 'unknown',\n  positionNewLine: pos?.newLine ?? null,\n  positionOldLine: pos?.oldLine ?? null,\n  canPostInline: !!pos\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "77fb631b-0c24-4bf4-a115-77b420b407ac",
      "name": "Check Inline Position",
      "type": "n8n-nodes-base.if",
      "position": [
        3872,
        -224
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "692505f7-cf7f-457e-b0b0-6d200026be6d",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{$json.canPostInline}}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "05e25cbd-06e6-44e9-a33f-3269835c552d",
      "name": "Workflow Configuration",
      "type": "n8n-nodes-base.set",
      "position": [
        -1904,
        240
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "588e5976-88ab-4cff-8db7-5394f8bf59b2",
              "name": "gitlabBaseUrl",
              "type": "string",
              "value": "https://gitlab.example.com/api/v4"
            },
            {
              "id": "c3a738df-33de-4cb7-af13-d3a42008b769",
              "name": "reviewTriggerPhrase",
              "type": "string",
              "value": "+0"
            },
            {
              "id": "1bd7d1e8-1c9e-4c05-ab54-78a8b97f76ac",
              "name": "minConfidenceToPost",
              "type": "number",
              "value": 75
            },
            {
              "id": "570ecfa2-f25c-4810-8c74-8af77c19f07c",
              "name": "reviewStartedMessage",
              "type": "string",
              "value": "\ud83d\udd0d I'm reviewing the code right now. Please hang tight for a moment."
            },
            {
              "id": "a4fcbea2-ea1d-4931-b6ff-35a0245c04a4",
              "name": "noIssuesMessage",
              "type": "string",
              "value": "\ud83d\udfe2 The changes look good this time!"
            },
            {
              "id": "77ba45e7-b313-45a3-bfb8-05323d66a5d0",
              "name": "summaryMessage",
              "type": "string",
              "value": "\u2705 Review complete!"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "b3812b08-44e5-48dc-9e52-1c885051b13d",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -448,
        -112
      ],
      "parameters": {
        "color": 7,
        "width": 464,
        "height": 560,
        "content": "## Prepare review context\n\nConvert each diff into a clean review payload with file paths, code snippets, merge request metadata, and diff SHAs needed later for GitLab comments."
      },
      "typeVersion": 1
    },
    {
      "id": "1d5b16f8-004b-4040-b426-751ebad11b7c",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3248,
        -592
      ],
      "parameters": {
        "color": 7,
        "width": 1408,
        "height": 656,
        "content": "## Publish review comments\n\nResolve the GitLab diff position for each verified finding and post the final review comment.\n\nIf a valid inline position is available, create an inline diff discussion.\nOtherwise, post a reply comment in the trigger discussion."
      },
      "typeVersion": 1
    },
    {
      "id": "e877d584-a678-4c86-9645-dcb0872dd440",
      "name": "Sticky Note9",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3248,
        224
      ],
      "parameters": {
        "color": 7,
        "width": 656,
        "height": 608,
        "content": "## Post a summary reply\n\nAlways reply to the trigger discussion with either a completion message or a no-issues message.\nInline and fallback review comments are posted separately."
      },
      "typeVersion": 1
    },
    {
      "id": "5653a6fb-b1a1-4e70-bbf5-6bb8d709fa0d",
      "name": "Sticky Note10",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        800,
        -112
      ],
      "parameters": {
        "color": 7,
        "width": 592,
        "height": 576,
        "content": "## Merge reviewer outputs\n\nCombine findings from all reviewers, remove obvious duplicates, and keep the file and merge request context for final verification."
      },
      "typeVersion": 1
    },
    {
      "id": "17e3c9d5-0f05-4b66-adb5-7da2d903a8c1",
      "name": "Post Fallback Reply",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4416,
        -160
      ],
      "parameters": {
        "url": "={{ $('Workflow Configuration').first().json.gitlabBaseUrl }}/projects/{{ $json.projectId }}/merge_requests/{{ $json.mrIid }}/discussions/{{ $json.discussionId }}/notes",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "multipart-form-data",
        "authentication": "predefinedCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "body",
              "value": "={{ $json.fallbackBody }}"
            }
          ]
        },
        "nodeCredentialType": "gitlabApi"
      },
      "credentials": {
        "gitlabApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "8300ae3d-115d-44d3-8717-fa123959d1ba",
      "name": "Build Fallback Comment",
      "type": "n8n-nodes-base.code",
      "position": [
        4192,
        -160
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const iconMap = {\n  high: '\ud83d\udd34',\n  medium: '\ud83d\udfe1',\n  low: '\ud83d\udd35',\n};\n\nconst severity = ($json.final_severity || 'low').toLowerCase();\nconst icon = iconMap[severity] || '\ud83d\udd35';\n\nreturn {\n  ...$json,\n  fallbackBody: [\n    `${icon} General review comment (exact diff line could not be resolved)`,\n    '',\n    `**${$json.title}**`,\n    $json.comment,\n    '',\n    `File: ${$json.path || $json.newPath || $json.oldPath || 'unknown'}`\n  ].join('\\n')\n};"
      },
      "typeVersion": 2
    }
  ],
  "active": false,
  "settings": {
    "callerPolicy": "workflowsFromSameOwner",
    "timeSavedMode": "fixed",
    "availableInMCP": false,
    "executionOrder": "v1",
    "executionTimeout": 300,
    "timeSavedPerExecution": 5
  },
  "versionId": "9e8d2b52-6272-431e-86b5-f691e835270b",
  "connections": {
    "Analyze Bugs": {
      "main": [
        [
          {
            "node": "Merge Reviewer Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verifier Model": {
      "ai_languageModel": [
        [
          {
            "node": "Verify Findings",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Verify Findings": {
      "main": [
        [
          {
            "node": "Normalize Verified Findings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Combine Findings": {
      "main": [
        [
          {
            "node": "Verify Findings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Bug Reviewer Model": {
      "ai_languageModel": [
        [
          {
            "node": "Analyze Bugs",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Split Changed Files": {
      "main": [
        [
          {
            "node": "Filter Supported Diffs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Inline Comment": {
      "main": [
        [
          {
            "node": "Check Inline Position",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Findings Exist": {
      "main": [
        [
          {
            "node": "Build Summary Comment",
            "type": "main",
            "index": 0
          },
          {
            "node": "Split Verified Findings",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build No-Issues Comment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Summary Comment": {
      "main": [
        [
          {
            "node": "Post Summary Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Inline Position": {
      "main": [
        [
          {
            "node": "Post Inline Review Comment",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Fallback Comment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Analyze Security Risks": {
      "main": [
        [
          {
            "node": "Merge Reviewer Results",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Build Fallback Comment": {
      "main": [
        [
          {
            "node": "Post Fallback Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Supported Diffs": {
      "main": [
        [
          {
            "node": "Prepare Review Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Reviewer Results": {
      "main": [
        [
          {
            "node": "Combine Findings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Review Context": {
      "main": [
        [
          {
            "node": "Analyze Security Risks",
            "type": "main",
            "index": 0
          },
          {
            "node": "Analyze Bugs",
            "type": "main",
            "index": 0
          },
          {
            "node": "Analyze Maintainability Risks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Workflow Configuration": {
      "main": [
        [
          {
            "node": "Check Review Trigger Comment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build No-Issues Comment": {
      "main": [
        [
          {
            "node": "Post Summary Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Bug Review Output": {
      "ai_outputParser": [
        [
          {
            "node": "Analyze Bugs",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Security Reviewer Model": {
      "ai_languageModel": [
        [
          {
            "node": "Analyze Security Risks",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Split Verified Findings": {
      "main": [
        [
          {
            "node": "Filter Publishable Findings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GitLab Discussion Webhook": {
      "main": [
        [
          {
            "node": "Workflow Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Verification Output": {
      "ai_outputParser": [
        [
          {
            "node": "Verify Findings",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Post Inline Review Comment": {
      "main": [
        []
      ]
    },
    "Fetch Merge Request Changes": {
      "main": [
        [
          {
            "node": "Split Changed Files",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Publishable Findings": {
      "main": [
        [
          {
            "node": "Resolve GitLab Diff Position",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Verified Findings": {
      "main": [
        [
          {
            "node": "Check Findings Exist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Review Trigger Comment": {
      "main": [
        [
          {
            "node": "Fetch Merge Request Changes",
            "type": "main",
            "index": 0
          },
          {
            "node": "Post Review Started Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Security Review Output": {
      "ai_outputParser": [
        [
          {
            "node": "Analyze Security Risks",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Resolve GitLab Diff Position": {
      "main": [
        [
          {
            "node": "Build Inline Comment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Analyze Maintainability Risks": {
      "main": [
        [
          {
            "node": "Merge Reviewer Results",
            "type": "main",
            "index": 2
          }
        ]
      ]
    },
    "Maintainability Reviewer Model": {
      "ai_languageModel": [
        [
          {
            "node": "Analyze Maintainability Risks",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Parse Maintainability Review Output": {
      "ai_outputParser": [
        [
          {
            "node": "Analyze Maintainability Risks",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    }
  }
}