{
  "name": "GitLab MR Code Review",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 1
            }
          ]
        }
      },
      "id": "schedule",
      "name": "Every Minute",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        416,
        304
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "=https://__GITLAB_HOST__/api/v4/projects/{{ encodeURIComponent('__GITLAB_PROJECT__') }}/merge_requests",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "state",
              "value": "opened"
            },
            {
              "name": "per_page",
              "value": "20"
            },
            {
              "name": "order_by",
              "value": "updated_at"
            },
            {
              "name": "sort",
              "value": "desc"
            }
          ]
        },
        "options": {}
      },
      "id": "get-mrs",
      "name": "Get Open MRs",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        608,
        304
      ],
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const CONFIG = { GITLAB_HOST: '__GITLAB_HOST__', GITLAB_PROJECT: '__GITLAB_PROJECT__' };\nconst mrs = $input.all().map(i => i.json);\n\nconst toReview = mrs.filter(mr => {\n  const labels = mr.labels || [];\n  const hasInProgress = labels.includes('ai-review::in-progress');\n  const reviewedSha = labels.find(l => l.startsWith('ai-review::sha:'))?.replace('ai-review::sha:', '');\n  return !hasInProgress && reviewedSha !== mr.sha;\n});\n\nif (toReview.length === 0) return [];\n\nconst mr = toReview[0];\nreturn [{\n  json: {\n    mr_iid: mr.iid,\n    mr_title: mr.title,\n    mr_url: mr.web_url,\n    source_branch: mr.source_branch,\n    target_branch: mr.target_branch,\n    author: mr.author?.username,\n    project_path: CONFIG.GITLAB_PROJECT,\n    gitlab_host: CONFIG.GITLAB_HOST,\n    sha: mr.sha,\n    reviewers: (mr.reviewers || []).map(r => r.username),\n    labels: mr.labels || []\n  }\n}];"
      },
      "id": "filter-mrs",
      "name": "Filter",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        784,
        304
      ]
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "=https://{{ $json.gitlab_host }}/api/v4/projects/{{ encodeURIComponent($json.project_path) }}/merge_requests/{{ $json.mr_iid }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ add_labels: 'ai-review::in-progress', remove_labels: $json.labels.filter(l => l.startsWith('ai-review::')).join(',') || undefined }) }}",
        "options": {}
      },
      "id": "label-in-progress",
      "name": "In Progress",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        960,
        304
      ],
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "method": "GET",
        "url": "=https://{{ $('Filter').first().json.gitlab_host }}/api/v4/projects/{{ encodeURIComponent($('Filter').first().json.project_path) }}/merge_requests/{{ $('Filter').first().json.mr_iid }}/discussions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {}
      },
      "id": "get-discussions",
      "name": "Get Discussions",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1152,
        304
      ],
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const AI_MARKER = 'AI Code Review';\nconst mr = $('Filter').first().json;\nconst discussions = $input.all().map(i => i.json);\n\nconst threads = [];\nfor (const d of discussions) {\n  const notes = d.notes || [];\n  if (notes.length === 0) continue;\n  \n  const isAI = n => (n.body || '').split('\\n')[0].includes(AI_MARKER);\n  const isUser = n => !n.system && !isAI(n);\n  \n  if (notes.some(isAI) && !notes.some(isUser)) continue;\n  \n  const thread = { notes: [], position: null };\n  for (const note of notes) {\n    if (note.system) continue;\n    thread.notes.push({ author: note.author?.username, body: (note.body || '').slice(0, 500), isAI: isAI(note) });\n    if (note.type === 'DiffNote' && note.position && !thread.position) {\n      thread.position = { file: note.position.new_path || note.position.old_path, line: note.position.new_line || note.position.old_line };\n    }\n  }\n  if (thread.notes.length > 0) threads.push(thread);\n}\n\nreturn { json: { ...mr, threads } };"
      },
      "id": "process-notes",
      "name": "Process Notes",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1328,
        304
      ]
    },
    {
      "parameters": {
        "jsCode": "const mr = $input.first().json;\nconst threads = mr.threads || [];\n\nlet prompt = `Review MR !${mr.mr_iid}: ${mr.mr_title}\\n\\n**Branch**: \\`${mr.source_branch}\\` \u2192 \\`${mr.target_branch}\\`\\n**Author**: ${mr.author}\\n**URL**: ${mr.mr_url}`;\n\nif (threads.length > 0) {\n  prompt += '\\n\\n## Discussion Threads';\n  for (const thread of threads) {\n    prompt += thread.position ? `\\n\\n### \\`${thread.position.file}:${thread.position.line}\\`` : '\\n\\n### General Discussion';\n    for (const n of thread.notes) {\n      prompt += `\\n- ${n.isAI ? '\ud83e\udd16 AI' : `@${n.author}`}: \"${n.body}\"`;\n    }\n  }\n}\n\nreturn { json: { ...mr, formatted_message: prompt } };"
      },
      "id": "build-prompt",
      "name": "Build Prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1504,
        304
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.N8N_API_URL }}/v1/projects/system/chat",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ user_message: $json.formatted_message, agent: 'MR Reviewer', source: 'gitlab', requester: 'gitlab-mr-workflow', metadata: { mr_iid: $json.mr_iid, mr_url: $json.mr_url, author: $json.author, source_branch: $json.source_branch, target_branch: $json.target_branch, workflow_execution_id: $executionId } }) }}",
        "options": {
          "timeout": 660000
        }
      },
      "id": "execute-claude",
      "name": "Execute",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1680,
        304
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "success",
              "leftValue": "={{ $json.status }}",
              "rightValue": "completed",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "check-result",
      "name": "Success?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1856,
        304
      ]
    },
    {
      "parameters": {
        "jsCode": "const mr = $('Build Prompt').item.json;\nconst result = $('Execute').item.json;\nconst output = result.structured_output || {};\n\nconst review = {\n  verdict: output.verdict || 'comment',\n  gitlab_comment: output.gitlab_comment || '## \ud83d\udd0d AI Code Review\\n\\nReview completed.',\n  summary: output.summary || '\ub9ac\ubdf0 \uc644\ub8cc',\n  points: output.points || []\n};\n\nreturn { json: { mr, review, reviewers: mr.reviewers || [] } };"
      },
      "id": "parse-review",
      "name": "Parse Review",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2032,
        208
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://{{ $json.mr.gitlab_host }}/api/v4/projects/{{ encodeURIComponent($json.mr.project_path) }}/merge_requests/{{ $json.mr.mr_iid }}/notes",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ body: $json.review.gitlab_comment }) }}",
        "options": {}
      },
      "id": "post-gitlab-comment",
      "name": "Post GitLab Comment",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2208,
        208
      ],
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const data = $('Parse Review').item.json;\nconst mr = data.mr;\nconst review = data.review;\nconst reviewers = data.reviewers || [];\nconst dashboardUrl = $env.N8N_DASHBOARD_URL;\n\n// Verdict emoji\nconst verdictEmoji = { approve: ':white_check_mark:', request_changes: ':warning:', comment: ':speech_balloon:' };\nconst emoji = verdictEmoji[review.verdict] || ':speech_balloon:';\n\n// Build message parts\nconst header = `${emoji} <${mr.mr_url}|!${mr.mr_iid}> ${mr.mr_title}`;\nconst summary = review.summary;\nconst points = (review.points || []).map(p => `\u2022 ${p}`).join('\\n');\n\n// Reviewer mentions\nconst mentions = [];\nfor (const username of reviewers) {\n  if (!username) continue;\n  try {\n    const res = await this.helpers.httpRequest({ method: 'GET', url: `${dashboardUrl}/api/plugins/slack/users`, qs: { q: username, limit: 1 } });\n    if (res.users?.[0]) mentions.push(`<@${res.users[0].id}>`);\n  } catch (e) {}\n}\n\n// Assemble final message\nlet message = header + '\\n' + summary;\nif (points) message += '\\n' + points;\nif (mentions.length > 0) message += '\\n' + mentions.join(' ');\n\nreturn { json: { message, mr, review } };"
      },
      "id": "build-message",
      "name": "Build Message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2384,
        208
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "__MR_REVIEW_CHANNEL__"
        },
        "text": "={{ $json.message }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "post-slack",
      "name": "Post Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        2576,
        208
      ],
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "=https://{{ $('Build Prompt').item.json.gitlab_host }}/api/v4/projects/{{ encodeURIComponent($('Build Prompt').item.json.project_path) }}/merge_requests/{{ $('Build Prompt').item.json.mr_iid }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ add_labels: 'ai-review::done,ai-review::sha:' + $('Build Prompt').item.json.sha, remove_labels: 'ai-review::in-progress' }) }}",
        "options": {}
      },
      "id": "label-done",
      "name": "Done",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2752,
        208
      ],
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.N8N_API_URL }}/v1/workflows/stats",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ workflow: 'gitlab-mr-review', execution_id: $('Execute').item.json.id, status: 'success', duration_ms: $('Execute').item.json.duration_ms, metadata: { mr_iid: $('Build Prompt').item.json.mr_iid, mr_title: $('Build Prompt').item.json.mr_title } }) }}",
        "options": {
          "timeout": 5000
        }
      },
      "id": "record-stats-ok",
      "name": "Stats OK",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2928,
        208
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "=https://{{ $('Build Prompt').item.json.gitlab_host }}/api/v4/projects/{{ encodeURIComponent($('Build Prompt').item.json.project_path) }}/merge_requests/{{ $('Build Prompt').item.json.mr_iid }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ add_labels: 'ai-review::failed', remove_labels: 'ai-review::in-progress' }) }}",
        "options": {}
      },
      "id": "label-failed",
      "name": "Failed",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2032,
        400
      ],
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "__MR_REVIEW_CHANNEL__"
        },
        "text": "={{ `:x: *MR Review Failed*\\n<${$('Build Prompt').item.json.mr_url}|!${$('Build Prompt').item.json.mr_iid}> ${$('Build Prompt').item.json.mr_title}\\n${$('Success?').item.json.status === 'timeout' ? ':hourglass: Timeout' : ':warning: ' + ($('Success?').item.json.error?.message || 'Unknown error')}` }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "notify-failure",
      "name": "Notify Failure",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        2208,
        400
      ],
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.N8N_API_URL }}/v1/workflows/stats",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ workflow: 'gitlab-mr-review', execution_id: $('Execute').item.json.id, status: $('Success?').item.json.status === 'timeout' ? 'timeout' : 'error', duration_ms: $('Execute').item.json.duration_ms, metadata: { mr_iid: $('Build Prompt').item.json.mr_iid, error: $('Success?').item.json.error?.message } }) }}",
        "options": {
          "timeout": 5000
        }
      },
      "id": "record-stats-err",
      "name": "Stats Err",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2384,
        400
      ],
      "onError": "continueRegularOutput"
    }
  ],
  "connections": {
    "Every Minute": {
      "main": [
        [
          {
            "node": "Get Open MRs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Open MRs": {
      "main": [
        [
          {
            "node": "Filter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter": {
      "main": [
        [
          {
            "node": "In Progress",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "In Progress": {
      "main": [
        [
          {
            "node": "Get Discussions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Discussions": {
      "main": [
        [
          {
            "node": "Process Notes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Process Notes": {
      "main": [
        [
          {
            "node": "Build Prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Prompt": {
      "main": [
        [
          {
            "node": "Execute",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Execute": {
      "main": [
        [
          {
            "node": "Success?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Success?": {
      "main": [
        [
          {
            "node": "Parse Review",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Failed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Review": {
      "main": [
        [
          {
            "node": "Post GitLab Comment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post GitLab Comment": {
      "main": [
        [
          {
            "node": "Build Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Message": {
      "main": [
        [
          {
            "node": "Post Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post Slack": {
      "main": [
        [
          {
            "node": "Done",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Done": {
      "main": [
        [
          {
            "node": "Stats OK",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Failed": {
      "main": [
        [
          {
            "node": "Notify Failure",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Notify Failure": {
      "main": [
        [
          {
            "node": "Stats Err",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "callerPolicy": "workflowsFromSameOwner",
    "availableInMCP": false
  }
}