{
  "id": "2E16VAHgDq5sG1kI",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "publicCI/CDassistant",
  "tags": [],
  "nodes": [
    {
      "id": "cf4db35c-a98b-444e-a765-3e43204206c7",
      "name": "Is job failed?",
      "type": "n8n-nodes-base.if",
      "position": [
        96,
        0
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "bbeeb61b-b294-4328-a9f7-001e2a0a1920",
              "operator": {
                "name": "filter.operator.equals",
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.body.object_attributes.status }}",
              "rightValue": "failed"
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "bf700581-5681-49ae-84d1-5e18894ba71e",
      "name": "Get Job Events",
      "type": "n8n-nodes-base.webhook",
      "position": [
        -112,
        0
      ],
      "parameters": {
        "path": "ab7b7111-31d5-4676-8b69-dfc364d9c4b7",
        "options": {},
        "httpMethod": "POST",
        "authentication": "headerAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "3796ec77-82aa-4360-84ff-97ab2e24149f",
      "name": "Extract event data",
      "type": "n8n-nodes-base.code",
      "position": [
        576,
        -128
      ],
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "\"\"\"\nExtract pipeline context and identify failed jobs from Pipeline Hook event.\n\"\"\"\n\nbody = _items[0][\"json\"][\"body\"]\nattrs = body.get(\"object_attributes\", {})\nproject = body.get(\"project\", {})\ncommit = body.get(\"commit\", {})\nuser = body.get(\"user\", {})\nmerge_request = body.get(\"merge_request\", {})\nbuilds = body.get(\"builds\", [])\n\n# --- Filter failed jobs (only those that actually broke the pipeline) ---\nfailed_jobs = []\nfor build in builds:\n    if build.get(\"status\") != \"failed\":\n        continue\n    if build.get(\"allow_failure\", False):\n        continue\n\n    runner = build.get(\"runner\", {})\n    environment = build.get(\"environment\", {})\n\n    failed_jobs.append({\n        \"job_id\": build.get(\"id\"),\n        \"job_name\": build.get(\"name\", \"unknown\"),\n        \"job_stage\": build.get(\"stage\", \"unknown\"),\n        \"failure_reason\": build.get(\"failure_reason\", \"unknown\"),\n        \"duration_sec\": round(build.get(\"duration\", 0), 1),\n        \"queued_duration_sec\": round(build.get(\"queued_duration\", 0), 1),\n        \"started_at\": build.get(\"started_at\", \"\"),\n        \"finished_at\": build.get(\"finished_at\", \"\"),\n        \"env_name\": environment.get(\"name\", \"\") if environment else \"\",\n        \"env_action\": environment.get(\"action\", \"\") if environment else \"\",\n        \"has_artifacts\": build.get(\"artifacts_file\", {}).get(\"filename\") is not None,\n    })\n\n# --- If no real failed jobs found, skip processing ---\nif not failed_jobs:\n    return []\n\n# --- Project & GitLab API base ---\nproject_url = project.get(\"web_url\", \"\")\ngitlab_base_url = \"/\".join(project_url.split(\"/\")[:3]) if project_url else \"https://gitlab.com\"\n\n# --- Merge Request info (if triggered by MR) ---\nmr_info = None\nif merge_request:\n    mr_info = {\n        \"mr_iid\": merge_request.get(\"iid\"),\n        \"mr_title\": merge_request.get(\"title\", \"\"),\n        \"source_branch\": merge_request.get(\"source_branch\", \"\"),\n        \"target_branch\": merge_request.get(\"target_branch\", \"\"),\n        \"mr_url\": merge_request.get(\"url\", \"\"),\n        \"mr_state\": merge_request.get(\"state\", \"\"),\n    }\n\n# --- Common pipeline context ---\npipeline_context = {\n    \"project_id\": project.get(\"id\"),\n    \"pipeline_id\": attrs.get(\"id\"),\n    \"pipeline_iid\": attrs.get(\"iid\"),\n    \"commit_sha\": attrs.get(\"sha\"),\n    \"gitlab_base_url\": gitlab_base_url,\n    \"pipeline_duration_sec\": attrs.get(\"duration\", 0),\n    \"pipeline_url\": attrs.get(\"url\", \"\"),\n    \"project_url\": project_url,\n    \"default_branch\": project.get(\"default_branch\", \"main\"),\n}\n\n# --- Output: one item per failed job ---\nresults = []\nfor job in failed_jobs:\n    results.append({\n        \"pipeline\": pipeline_context,\n        \"failed_job\": job,\n    })\n\nreturn results"
      },
      "typeVersion": 2
    },
    {
      "id": "a14e2e13-7a00-4434-a266-0cf7ba12b7d0",
      "name": "No Operation, do nothing",
      "type": "n8n-nodes-base.noOp",
      "position": [
        384,
        272
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "09f5ff62-4d6b-46b2-8731-a264747ec92f",
      "name": "GetJobLogs",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        736,
        -320
      ],
      "parameters": {
        "url": "={{ $json.pipeline.gitlab_base_url }}/api/v4/projects/{{ $json.pipeline.project_id }}/jobs/{{ $json.failed_job.job_id }}/trace",
        "options": {},
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "gitlabApi"
      },
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        },
        "gitlabApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "f32b5199-245f-4643-8d4f-5da5aed72c83",
      "name": "GetCIFile",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        864,
        64
      ],
      "parameters": {
        "url": "={{ $json.pipeline.gitlab_base_url }}/api/v4/projects/{{ $json.pipeline.project_id }}/repository/files/.gitlab-ci.yml/raw",
        "options": {},
        "sendQuery": true,
        "authentication": "predefinedCredentialType",
        "queryParameters": {
          "parameters": [
            {
              "name": "ref",
              "value": "={{ $json.pipeline.commit_sha }}"
            }
          ]
        },
        "nodeCredentialType": "gitlabApi"
      },
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        },
        "gitlabApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "81b7537e-89e4-4540-be63-f7730d1d8e41",
      "name": "Merge",
      "type": "n8n-nodes-base.merge",
      "position": [
        1136,
        -144
      ],
      "parameters": {
        "numberInputs": 3
      },
      "typeVersion": 3.2
    },
    {
      "id": "54f8d4ac-d84b-4eff-8b35-4fe418ab5173",
      "name": "AI Agent",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        1616,
        -128
      ],
      "parameters": {
        "text": "={{ JSON.stringify($json, null, 2) }}",
        "options": {
          "systemMessage": "You have a good understanding of CI/CD systems such as Gitlab CI, Github Actions, Jenkins, Teamcity.\nYour main task is to analyze unsuccessful jobs. You only look and analyze, without changing anything. You need to provide 1 to 3 probable causes and their solutions.\n\n## Input format\nYou will receive request in json format. \n```\n[{\n\t \"job_log\": \"Last 50 lines of failed job\"\n\t },\n\t {\"data\": \"Content .gitlab-ci.yml\"},\n\t {\"pipeline\": {}}, // information about project and pipeline\n     {\"failed_job\" {}}, // information about failed job\n},]\n```\n\n## Gitlab tools\n- Use for manipulation with projects, repositories, files, commits, merge requests\n- Gitlab root group name: {{ $('SetVars').first().json.gitlab_group }}\n\n## HTTP request tool\n- Use for web request\n\n## Grafana tools\n- Use it to get GitLab Runner resource consumption metrics.\n- Use this to get pod metrics and logs in case of a failed deployment\n- Prometheus datasource name: {{ $('SetVars').first().json.prometheus_name }}\n- Loki datasource name: {{ $('SetVars').first().json.loki_name }}\n\n## Critical Rules for Tool Usage\n- Don't make any changes to anything through mcp.\n\n\n## Important rules\n- Don't ask anything, analyze it yourself\n\n## Investigation suggestion\n- Always analyze job_log first\n\n\n## Output format\n### What happened\nDescription of what happened and what happened to what\n\n### Root cause\nNo more than two root cause suggestions\n\n### Troubleshooting tips\nStep-by-step description of actions for each root cause"
        },
        "promptType": "define"
      },
      "typeVersion": 3.1
    },
    {
      "id": "681813df-17a9-40d8-ba76-afc0cd8a12a4",
      "name": "OpenAI Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        1488,
        80
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5.3-codex",
          "cachedResultName": "gpt-5.3-codex"
        },
        "options": {},
        "builtInTools": {}
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "85c671a7-a531-411c-a957-b145d7c6d191",
      "name": "GetLastLogLines",
      "type": "n8n-nodes-base.code",
      "position": [
        896,
        -320
      ],
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "raw_log = _items[0][\"json\"].get(\"data\", \"\")\n\n# --- Strip ANSI escape sequences without regex ---\nclean = \"\"\ni = 0\nwhile i < len(raw_log):\n    if raw_log[i] == \"\\x1b\":\n        # Skip ESC[ ... <letter> sequences (CSI)\n        if i + 1 < len(raw_log) and raw_log[i + 1] == \"[\":\n            i += 2\n            while i < len(raw_log) and not raw_log[i].isalpha():\n                i += 1\n            i += 1  # skip the final letter\n            continue\n        # Skip ESC] ... ESC\\ sequences (OSC, used by GitLab hyperlinks)\n        elif i + 1 < len(raw_log) and raw_log[i + 1] == \"]\":\n            i += 2\n            while i < len(raw_log):\n                if raw_log[i] == \"\\x1b\" and i + 1 < len(raw_log) and raw_log[i + 1] == \"\\\\\":\n                    i += 2\n                    break\n                i += 1\n            continue\n        else:\n            i += 1\n            continue\n    clean += raw_log[i]\n    i += 1\n\n# --- Filter out section markers and empty lines ---\nlines = []\nfor line in clean.splitlines():\n    stripped = line.strip()\n    if not stripped:\n        continue\n    if stripped.startswith(\"section_start:\") or stripped.startswith(\"section_end:\"):\n        continue\n    lines.append(line)\n\n# --- Keep last 50 lines ---\nmax_lines = 50\ntotal = len(lines)\nif total > max_lines:\n    trimmed = lines[-max_lines:]\n    header = \"[... truncated \" + str(total - max_lines) + \" lines, showing last \" + str(max_lines) + \" ...]\\n\"\nelse:\n    trimmed = lines\n    header = \"\"\n\nreturn [{\n    \"job_log\": header + \"\\n\".join(trimmed),\n}]"
      },
      "typeVersion": 2
    },
    {
      "id": "283ccf3d-9369-42e6-97a6-8dcdb307b91c",
      "name": "HTTP Request",
      "type": "n8n-nodes-base.httpRequestTool",
      "position": [
        1808,
        320
      ],
      "parameters": {
        "url": "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('URL', ``, 'string') }}",
        "options": {}
      },
      "typeVersion": 4.4
    },
    {
      "id": "6f2657bd-7d73-4120-bc08-1f01b452cd1f",
      "name": "Gitlab MCP",
      "type": "@n8n/n8n-nodes-langchain.mcpClientTool",
      "position": [
        1520,
        320
      ],
      "parameters": {
        "include": "selected",
        "options": {},
        "endpointUrl": "http://gitlab-mcp:8091/mcp",
        "includeTools": [
          "search_repositories",
          "get_file_contents",
          "get_merge_request",
          "get_merge_request_diffs",
          "list_merge_request_versions",
          "get_merge_request_version",
          "get_branch_diffs",
          "list_labels",
          "get_label",
          "list_projects",
          "get_project",
          "get_namespace",
          "list_namespaces",
          "get_commit",
          "get_commit_diff",
          "list_commits",
          "get_repository_tree",
          "list_merge_requests",
          "list_group_projects",
          "list_events",
          "get_pipeline",
          "list_pipelines",
          "list_pipeline_jobs",
          "list_pipeline_trigger_jobs",
          "get_pipeline_job",
          "get_pipeline_job_output"
        ]
      },
      "typeVersion": 1.2
    },
    {
      "id": "6b7a434d-be8e-41a8-b7dc-0e13eb2269ef",
      "name": "Send a message",
      "type": "n8n-nodes-base.slack",
      "position": [
        2080,
        -128
      ],
      "parameters": {
        "text": "={{ $json.output }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "C0AJFB5PV5Y",
          "cachedResultName": "devops-ai-tests"
        },
        "otherOptions": {},
        "authentication": "oAuth2"
      },
      "credentials": {
        "slackOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.4
    },
    {
      "id": "212cd5f6-c612-44b9-907b-83fe750b2fdc",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        368,
        -368
      ],
      "parameters": {
        "color": 5,
        "width": 928,
        "height": 560,
        "content": "# Collect data\n* Job logs\n* Pipeline file"
      },
      "typeVersion": 1
    },
    {
      "id": "f82eca31-ed2b-4c2e-b926-dd57b8460acb",
      "name": "FormatData",
      "type": "n8n-nodes-base.code",
      "position": [
        1360,
        -128
      ],
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "result = {}\n\nfor item in _items:\n    data = item[\"json\"]\n    \n    # Item with job_log\n    if \"job_log\" in data:\n        result[\"job_log\"] = data[\"job_log\"]\n        result[\"job_log_total_lines\"] = data.get(\"job_log_total_lines\", 0)\n    \n    # Item with pipeline context and failed job info\n    if \"pipeline\" in data:\n        result[\"pipeline\"] = data[\"pipeline\"]\n        result[\"failed_job\"] = data[\"failed_job\"]\n    \n    # Item with CI config (raw YAML as string)\n    if \"data\" in data and \"pipeline\" not in data and \"job_log\" not in data:\n        result[\"ci_config\"] = data[\"data\"]\n    \n    # Item with commit diff (array of changed files)\n    if \"diff\" in data or (isinstance(data, list) and len(data) > 0 and \"diff\" in data[0]):\n        result[\"commit_diff\"] = data\n\nreturn [result]"
      },
      "typeVersion": 2
    },
    {
      "id": "431c2aec-c6ed-44a8-84c8-70b9e5f094fc",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1952,
        -208
      ],
      "parameters": {
        "width": 432,
        "height": 400,
        "content": "# Output Chain\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nPost generated report to Slack"
      },
      "typeVersion": 1
    },
    {
      "id": "9ac578ff-749c-416a-a887-15d82bb84940",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1328,
        -208
      ],
      "parameters": {
        "color": 6,
        "width": 592,
        "height": 400,
        "content": "# Error analysis"
      },
      "typeVersion": 1
    },
    {
      "id": "63e5c267-5ff5-4de1-8791-4c2168fb4e57",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1328,
        208
      ],
      "parameters": {
        "color": 4,
        "width": 592,
        "height": 416,
        "content": "# MCP servers\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nAdd correct URLs for remote MCPs\nUse following mcp:\n* [gitlab-mcp](https://github.com/zereight/gitlab-mcp)\n* [grafana-mcp](https://github.com/grafana/mcp-grafana)"
      },
      "typeVersion": 1
    },
    {
      "id": "fca3da23-d17b-4aa4-94b2-44fc256ca758",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -320,
        -736
      ],
      "parameters": {
        "color": 5,
        "width": 608,
        "height": 608,
        "content": "# Overview\nThis workflow helps automatically analyze the causes of build failures in Gitlab CI and propose solutions without involving DevOps engineers.\n## How it work\n1. Checks whether a job crashed.\n1. Gets logs of the crashed job and a description of the pipeline.\n1. The agent analyzes this data according to its system prompt.\n1. During the process, the agent can retrieve additional data from Gitlab and check the availability of endpoints.\n1. A report on the causes and solutions is sent to the selected Slack channel \n\n## How to use\n1. Generate webhook credentials and use it in Gitlab\n1. Add your own Rules and recommendations to system prompt\n1. Run MCP servers\n1. Choose Slack channel\n\n## Requirements:\n- OpenAI  or Anthropic API key\n- Slack API key\n- Webhook with X-Gitlab-Token \n- Gitlab Access key\n- Grafana Service Account token"
      },
      "typeVersion": 1
    },
    {
      "id": "b2460b44-19ef-4281-9699-889af3107091",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -176,
        -96
      ],
      "parameters": {
        "width": 448,
        "height": 288,
        "content": "# Input Chain\n\n\n\n\n\n\n\n\n\n\n\n\n\nReceving pipeline event from Gitlab"
      },
      "typeVersion": 1
    },
    {
      "id": "3dc3b6fe-eba2-4b91-908e-4f86ef753f60",
      "name": "MCP Grafana",
      "type": "@n8n/n8n-nodes-langchain.mcpClientTool",
      "position": [
        1664,
        320
      ],
      "parameters": {
        "include": "selected",
        "options": {},
        "endpointUrl": "http://mcp-grafana:8000/mcp",
        "includeTools": [
          "get_annotations",
          "get_annotation_tags",
          "get_dashboard_by_uid",
          "get_dashboard_panel_queries",
          "get_dashboard_property",
          "get_dashboard_summary",
          "get_datasource",
          "get_panel_image",
          "list_datasources",
          "list_loki_label_names",
          "list_loki_label_values",
          "list_prometheus_label_names",
          "list_prometheus_label_values",
          "list_prometheus_metric_metadata",
          "list_prometheus_metric_names",
          "query_loki_logs",
          "query_loki_patterns",
          "query_loki_stats",
          "query_prometheus",
          "query_prometheus_histogram",
          "search_dashboards",
          "search_folders"
        ]
      },
      "typeVersion": 1.2
    },
    {
      "id": "f9524ed0-d8ba-4982-a97d-934653899328",
      "name": "SetVars",
      "type": "n8n-nodes-base.set",
      "position": [
        400,
        -128
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "bc66cc19-c97c-4252-bb84-a0c81806565c",
              "name": "prometheus_name",
              "type": "string",
              "value": "dev"
            },
            {
              "id": "e9992949-2cc4-4341-873f-df6c88940027",
              "name": "loki_name",
              "type": "string",
              "value": "grafanacloud-logs"
            },
            {
              "id": "8fd89221-20b5-4013-b89c-bebfd2a509dc",
              "name": "grafana_group",
              "type": "string",
              "value": "TL"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "87124211-5d40-4cf8-a9c6-7b965ee202c4",
  "connections": {
    "Merge": {
      "main": [
        [
          {
            "node": "FormatData",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SetVars": {
      "main": [
        [
          {
            "node": "Extract event data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Agent": {
      "main": [
        [
          {
            "node": "Send a message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GetCIFile": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 2
          }
        ]
      ]
    },
    "FormatData": {
      "main": [
        [
          {
            "node": "AI Agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GetJobLogs": {
      "main": [
        [
          {
            "node": "GetLastLogLines",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gitlab MCP": {
      "ai_tool": [
        [
          {
            "node": "AI Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "MCP Grafana": {
      "ai_tool": [
        [
          {
            "node": "AI Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "HTTP Request": {
      "ai_tool": [
        [
          {
            "node": "AI Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Get Job Events": {
      "main": [
        [
          {
            "node": "Is job failed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is job failed?": {
      "main": [
        [
          {
            "node": "SetVars",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "No Operation, do nothing",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GetLastLogLines": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "AI Agent",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Extract event data": {
      "main": [
        [
          {
            "node": "GetJobLogs",
            "type": "main",
            "index": 0
          },
          {
            "node": "GetCIFile",
            "type": "main",
            "index": 0
          },
          {
            "node": "Merge",
            "type": "main",
            "index": 1
          }
        ]
      ]
    }
  }
}