AutomationFlowsAI & RAG › GitLab MR → Linear + Claude Release Notes

GitLab MR → Linear + Claude Release Notes

Original n8n title: Generate Gitlab Release Notes From Linear Issues with Claude Opus

ByRomain Jouhannet @rjouhann on n8n.io

Triggered by a GitLab MR webhook, this workflow automatically assists your team in writing customer-facing release notes by combining Linear issue data with Claude AI.

Event trigger★★★★☆ complexityAI-powered25 nodesHTTP RequestRSS Feed ReadGitlab TriggerAnthropic
AI & RAG Trigger: Event Nodes: 25 Complexity: ★★★★☆ AI nodes: yes Added:
GitLab MR → Linear + Claude Release Notes — n8n workflow card showing HTTP Request, RSS Feed Read, Gitlab Trigger integration

This workflow corresponds to n8n.io template #14520 — we link there as the canonical source.

This workflow follows the HTTP Request → RSS Feed Read recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "arv1gHOXJLJFgkjW",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Release Note Helper (Linear + GitLab + Claude AI)",
  "tags": [
    {
      "id": "NyHsy31Uw9Ue7FPB",
      "name": "gitlab",
      "createdAt": "2023-10-20T10:07:13.343Z",
      "updatedAt": "2023-10-20T10:07:13.343Z"
    },
    {
      "id": "6Ek7V8f4xbM9vWLj",
      "name": "linear",
      "createdAt": "2024-11-08T12:12:15.330Z",
      "updatedAt": "2024-11-08T12:12:15.330Z"
    },
    {
      "id": "C7P3CMZEIzF0SZIQ",
      "name": "claude",
      "createdAt": "2026-03-31T09:22:32.906Z",
      "updatedAt": "2026-03-31T09:22:32.906Z"
    }
  ],
  "nodes": [
    {
      "id": "2cf5b983-8aa1-4e1b-ac83-2bb03ab09379",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        976,
        -480
      ],
      "parameters": {
        "width": 480,
        "height": 816,
        "content": "## Release Note Helper (Linear + GitLab + Claude AI)\n\n### How it works\n\n1. Triggers on a GitLab merge request (MR) event to start the process.\n2. Checks existing MR conditions to determine if a release label is present.\n3. Retrieves and formats labels from a Linear RSS feed.\n4. Based on label presence, gathers Linear issues to generate summaries and suggestions.\n5. Applies AI to enhance MR summaries and updates the MR with suggestions and labels.\n6. Completes the MR process with additional notes if needed.\n\n### Setup steps\n\n- [ ] Configure GitLab credentials for API access.\n- [ ] Set up Linear API credentials for issue retrieval.\n- [ ] Ensure access to the AI model service for generating summaries.\n\n### Customization\n\nAdjust conditions and API endpoints for different GitLab projects or Linear workspaces."
      },
      "typeVersion": 1
    },
    {
      "id": "1eeda314-34c9-41d5-9f35-fcb28a6574b5",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1536,
        -176
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 320,
        "content": "## Trigger and initial condition\n\nStarts the workflow when a merge request is opened or updated in GitLab, then checks if the MR already contains release note inputs."
      },
      "typeVersion": 1
    },
    {
      "id": "f81f9aaf-5c9d-4ff8-bcf5-92e900d24f2e",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1984,
        -144
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 304,
        "content": "## Check and read RSS for labels\n\nEvaluates if a release label exists and reads relevant RSS feed to get initial labels."
      },
      "typeVersion": 1
    },
    {
      "id": "920f1765-e0c7-4a78-aa6f-faded7855986",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2432,
        -144
      ],
      "parameters": {
        "color": 7,
        "width": 864,
        "height": 304,
        "content": "## Retrieve and process Linear labels\n\nFetches labels from Linear, extracts specific versions, and sets parameters for further processing."
      },
      "typeVersion": 1
    },
    {
      "id": "8e8769a7-66b6-4d70-afee-c83f48cc94f1",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3328,
        -144
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 304,
        "content": "## Get Linear issues and manage response\n\nRetrieves Linear issues and decides the subsequent action, including generating summaries or adding notes if issues are absent."
      },
      "typeVersion": 1
    },
    {
      "id": "645b081d-a431-46f1-8928-fb91bcc4aedb",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3776,
        -480
      ],
      "parameters": {
        "color": 7,
        "width": 480,
        "height": 304,
        "content": "## Generate and add high level summary\n\nCreates a high-level summary and adds it to the GitLab MR."
      },
      "typeVersion": 1
    },
    {
      "id": "10f93d46-1e67-4e78-88c9-495bff1b12e6",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4000,
        -128
      ],
      "parameters": {
        "color": 7,
        "width": 992,
        "height": 464,
        "content": "## AI suggestions and issue management\n\nGenerates AI-driven suggestions and applies labels, finalizing MR modifications with notes if necessary."
      },
      "typeVersion": 1
    },
    {
      "id": "21b72b02-8876-4f41-b4c7-76bdde1cdd8b",
      "name": "Set Various Fields",
      "type": "n8n-nodes-base.set",
      "position": [
        2928,
        -16
      ],
      "parameters": {
        "values": {
          "string": [
            {
              "name": "versions_labels",
              "value": "=[\"{{ $json.versionRange }}\"]"
            },
            {
              "name": "custom_labels",
              "value": "[\"Customer request\", \"Release note public\"]"
            }
          ]
        },
        "options": {}
      },
      "typeVersion": 2
    },
    {
      "id": "4ac43eba-0e6f-4ebb-930c-647153070b55",
      "name": "Generate Summary with Links",
      "type": "n8n-nodes-base.code",
      "position": [
        3824,
        -352
      ],
      "parameters": {
        "jsCode": "const APPEND_LINKS = true;\n\n// --- Get label dates ---\nlet dateLabels = \"\";\ntry {\n    const formatLabels = $('Filter Labels Starting With v2')?.first()?.json;\n    const oldestDate = formatLabels?.oldestLabelDate;\n    const newestDate = formatLabels?.newestLabelDate;\n    dateLabels = (oldestDate && newestDate) ? `[${oldestDate} \u2192 ${newestDate}]` : \"\";\n} catch (error) {\n    dateLabels = \"\";\n}\n\n// --- Get version labels ---\nlet versionsLabels = [];\ntry {\n    const versionsLabelNodeOutput = $('Parse Linear Version Labels').first().json.versions_labels;\n    versionsLabels = typeof versionsLabelNodeOutput === 'string'\n        ? JSON.parse(versionsLabelNodeOutput)\n        : Array.isArray(versionsLabelNodeOutput)\n            ? versionsLabelNodeOutput\n            : [];\n} catch (error) {\n    versionsLabels = [];\n}\n\n// --- Get custom labels to include ---\nlet includeLabels = [];\ntry {\n    const includeLabelNodeOutput = $('Parse Linear Version Labels').first().json.custom_labels;\n    includeLabels = typeof includeLabelNodeOutput === 'string'\n        ? JSON.parse(includeLabelNodeOutput)\n        : Array.isArray(includeLabelNodeOutput)\n            ? includeLabelNodeOutput\n            : [];\n} catch (error) {\n    includeLabels = [];\n}\n\n// --- Initialize stats and grouping ---\nlet totalTickets = 0;\nlet ticketsWithZendesk = 0;\nlet ticketsWithCustomLabels = 0;\nconst issuesByTeam = {};\n\n// --- Slack link helper ---\nfunction extractFirstSlackLink(attachments) {\n    if (!attachments || !Array.isArray(attachments.nodes)) return null;\n    for (const att of attachments.nodes) {\n        if (att.url?.includes(\"your-company.slack.com/archives/\")) {\n            return att.url;\n        }\n    }\n    return null;\n}\n\n// --- Process each issue ---\nconst data = $input.first().json.data; // Get the original data\nconst items = data.issues.nodes;\nfor (const item of items) {\n    const issue = item;\n    totalTickets++;\n\n    const teamName = issue.team?.name || 'Unknown Team';\n    if (!issuesByTeam[teamName]) {\n        issuesByTeam[teamName] = [];\n    }\n    issuesByTeam[teamName].push(issue);\n\n    const zendeskAttachment = (issue.attachments?.nodes || []).find(att => att.url?.includes(\"your-company.zendesk.com\"));\n    if (zendeskAttachment) {\n        ticketsWithZendesk++;\n    }\n\n    const issueLabels = issue.labels?.nodes || [];\n    if (includeLabels.length > 0 && issueLabels.some(l => includeLabels.includes(l.name))) {\n        ticketsWithCustomLabels++;\n    }\n}\n\n// --- Build Markdown output ---\nlet fullMarkdownContent = `## \ud83d\udcca Ticket Summary\\n`;\nfullMarkdownContent += `List of customer-facing improvements and bug fixes.\\n`;\nfullMarkdownContent += `- Release labels: **${versionsLabels.join(', ')}** ${dateLabels}\\n`;\nfullMarkdownContent += `- Total tickets: **${totalTickets}**\\n`;\nfullMarkdownContent += `- Tickets with Zendesk: **${ticketsWithZendesk}**\\n`;\nfullMarkdownContent += `- Tickets with custom labels (${includeLabels.join(', ')}): **${ticketsWithCustomLabels}**\\n`;\n\nfullMarkdownContent += `\\n## \ud83d\uddc2\ufe0f Tickets\\n`;\n\nconst sortedTeams = Object.keys(issuesByTeam).sort();\nfor (const team of sortedTeams) {\n    const teamIssues = issuesByTeam[team];\n    teamIssues.sort((a, b) => (a.identifier && b.identifier) ? a.identifier.localeCompare(b.identifier) : 0);\n\n    fullMarkdownContent += `\\n### ${team}\\n`;\n\n    for (const issue of teamIssues) {\n        const labels = (issue.labels?.nodes || []).map(l => l.name).join(\", \");\n\n        const linearLink = APPEND_LINKS\n            ? `[${issue.identifier}](https://linear.app/YOUR_WORKSPACE/issue/${issue.identifier})`\n            : issue.identifier;\n\n        // --- Zendesk link (per issue) ---\n        let zendeskLink = \"\";\n        const zAttachment = (issue.attachments?.nodes || []).find(att => att.url?.includes(\"your-company.zendesk.com\"));\n        if (zAttachment) {\n            const ticketMatch = zAttachment.url.match(/tickets\\/(\\d+)/);\n            const ticketId = ticketMatch?.[1];\n            if (ticketId) {\n                zendeskLink = APPEND_LINKS\n                    ? `(Zendesk [#${ticketId}](${zAttachment.url}))`\n                    : `(Zendesk #${ticketId})`;\n            }\n        }\n\n        // --- Slack link ---\n        const slackLinkUrl = extractFirstSlackLink(issue.attachments);\n        const slackLink = slackLinkUrl\n            ? APPEND_LINKS\n                ? `(Slack [link](${slackLinkUrl}))`\n                : `(Slack link)`\n            : \"\";\n\n        fullMarkdownContent += `- [ ] ${linearLink} - ${issue.title} - ${labels} ${zendeskLink} ${slackLink}`.trim() + `\\n`;\n    }\n}\n\nreturn [{ json: { markdown: fullMarkdownContent } }];\n"
      },
      "executeOnce": true,
      "typeVersion": 2
    },
    {
      "id": "8e461938-7d33-4032-a94d-9cb446aabe9b",
      "name": "Post Summary to GitLab MR",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4112,
        -352
      ],
      "parameters": {
        "url": "=https://gitlab.example.com/api/v4/projects/YOUR_PROJECT_ID/merge_requests/{{ $('When GitLab MR Event Occurs').first().json.body.object_attributes.iid }}/discussions",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "form-urlencoded",
        "authentication": "predefinedCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "body",
              "value": "={{ $('Generate Summary with Links').item.json.markdown }}"
            }
          ]
        },
        "nodeCredentialType": "gitlabApi"
      },
      "typeVersion": 4.2
    },
    {
      "id": "48439997-26ab-4768-b0d9-c3bd82697bf6",
      "name": "Check Release Label",
      "type": "n8n-nodes-base.if",
      "position": [
        2032,
        -16
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "8f26658c-815b-4dd9-8aef-2a5898933c43",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.body.object_attributes.labels.some(label => label.title === \"rn-release-n8n\") }}",
              "rightValue": "rn-release-n8n"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "88428b83-0ee2-45af-92fa-efc356ef3855",
      "name": "Post Linear Issues to GraphQL",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        3376,
        -16
      ],
      "parameters": {
        "url": "https://api.linear.app/graphql",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"query\": \"query ($labels: [String!], $after: String) { issues(filter: { state: { type: { eq: \\\"completed\\\" }}, or: [ { attachments: { url: { contains: \\\"zendesk.com\\\" }}}, { attachments: { url: { contains: \\\"your-company.slack.com/archives/\\\" }}} {{ $('Parse Linear Version Labels').first().json.custom_labels_expanded.filter(label => label).map(label => `, { labels: { some: { name: { eq: \\\\\"${label}\\\\\" } } } }`).join('') }} ], labels: { some: { name: { in: $labels } } } }, first: 250, after: $after ) { nodes { id identifier title description state { name } createdAt labels { nodes { name } } project { id name progress } team { name } url assignee { name } attachments { nodes { url } } } pageInfo { hasNextPage endCursor } }}\",\n  \"variables\": {\n    \"labels\": {{ JSON.stringify($json.versions_labels_expanded) }}\n    {{ $json[\"endCursor\"] ? `,\"after\":\"${$json[\"endCursor\"]}\"` : \"\" }}\n  }\n}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "linearApi"
      },
      "typeVersion": 4.2
    },
    {
      "id": "a82e7680-3096-4233-8ff2-6dff73c7f9e7",
      "name": "Read RSS Feed",
      "type": "n8n-nodes-base.rssFeedRead",
      "position": [
        2256,
        -16
      ],
      "parameters": {
        "url": "https://YOUR_DOCS_SITE.com/releases/saas/rss.xml",
        "options": {}
      },
      "typeVersion": 1.1
    },
    {
      "id": "b8c15737-f189-4f31-a9dd-09566b6c2bc6",
      "name": "Filter Labels Starting With v2",
      "type": "n8n-nodes-base.code",
      "position": [
        2704,
        -16
      ],
      "parameters": {
        "jsCode": "// Extract and filter labels starting with \"v2\"\nconst nodes = items[0].json.data.issueLabels.nodes;\n\nconst versionLabels = nodes\n  .filter(node => node.name.startsWith('v'))\n  .map(node => ({\n    label: node.name,\n    createdAt: node.createdAt,\n    number: parseInt(node.name.split('.')[1])\n  }));\n\n// Sort by the numeric part\nversionLabels.sort((a, b) => a.number - b.number);\n\n// Get the first and last labels and their dates\nconst startLabel = versionLabels[0].label;\nconst endLabel = versionLabels[versionLabels.length - 1].label;\nconst oldestLabelDate = versionLabels[0].createdAt;\nconst newestLabelDate = versionLabels[versionLabels.length - 1].createdAt;\n\n// Format ISO date to \"YYYY-MM-DD\"\nconst formatDate = (isoString) => isoString.split('T')[0];\n\nconst formattedOldestLabelDate = formatDate(oldestLabelDate);\nconst formattedNewestLabelDate = formatDate(newestLabelDate);\n\n// Build final range string like \"v2.231-v2.233\"\nconst versionRange = `${startLabel}-${endLabel}`;\n\n// Return in proper structure\nreturn [\n  {\n    json: {\n      versionRange,\n      oldestLabelDate: formattedOldestLabelDate,\n      newestLabelDate: formattedNewestLabelDate,\n    }\n  }\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "8ac651c7-6395-424c-94b2-ed3110df0ac4",
      "name": "Update GitLab MR Label",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4848,
        80
      ],
      "parameters": {
        "url": "=https://gitlab.example.com/api/v4/projects/YOUR_PROJECT_ID/merge_requests/{{ $('When GitLab MR Event Occurs').first().json.body.object_attributes.iid }}",
        "method": "PUT",
        "options": {},
        "sendBody": true,
        "contentType": "form-urlencoded",
        "authentication": "predefinedCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "labels",
              "value": "=rn-done{{ $json.labels.slice(0, 4).filter(l => l && !['rn-release-n8n', 'rn-self-hosted-n8n'].includes(l)).map(l => ',' + l).join('') }}"
            }
          ]
        },
        "nodeCredentialType": "gitlabApi"
      },
      "typeVersion": 4.2
    },
    {
      "id": "15eb556c-8bb4-4df3-871a-b991bfd41eb2",
      "name": "Check MR for RN Inputs",
      "type": "n8n-nodes-base.if",
      "position": [
        1808,
        -16
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "8f26658c-815b-4dd9-8aef-2a5898933c43",
              "operator": {
                "type": "boolean",
                "operation": "false",
                "singleValue": true
              },
              "leftValue": "={{ $json.body.object_attributes.labels.some(label => label.title === \"rn-done\") }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "d9e88c8c-4e77-411a-8546-5ba33b330d43",
      "name": "Check Linear Issues Exist",
      "type": "n8n-nodes-base.if",
      "position": [
        3600,
        -16
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "524f5209-4d6e-4c8d-99ac-4a6912a0db7b",
              "operator": {
                "type": "number",
                "operation": "notEquals"
              },
              "leftValue": "={{ $json[\"data\"][\"issues\"][\"nodes\"].length }}",
              "rightValue": 0
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "93e177c7-5c89-448e-bba1-c3d1e3092bf3",
      "name": "Post No Issues Note to GitLab",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4624,
        176
      ],
      "parameters": {
        "url": "=https://gitlab.example.com/api/v4/projects/YOUR_PROJECT_ID/merge_requests/{{ $('When GitLab MR Event Occurs').first().json.body.object_attributes.iid }}/notes",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "form-urlencoded",
        "authentication": "predefinedCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "body",
              "value": "=There are no recent Linear issues with Zendesk tickets attached for ``{{ $('Parse Linear Version Labels').item.json.versionRange }}`` or with the following labels: ``{{ $('Parse Linear Version Labels').item.json.custom_labels }}``."
            }
          ]
        },
        "nodeCredentialType": "gitlabApi"
      },
      "typeVersion": 4.2
    },
    {
      "id": "43dee234-9f01-4304-a417-40233945fb2c",
      "name": "Generate Summary with Linear Details",
      "type": "n8n-nodes-base.code",
      "position": [
        3824,
        -16
      ],
      "parameters": {
        "jsCode": "const APPEND_LINKS = true;\n\n// --- Get label dates ---\nlet dateLabels = \"\";\ntry {\n    const formatLabels = $('Filter Labels Starting With v2')?.first()?.json;\n    const oldestDate = formatLabels?.oldestLabelDate;\n    const newestDate = formatLabels?.newestLabelDate;\n    dateLabels = (oldestDate && newestDate) ? `[${oldestDate} \u2192 ${newestDate}]` : \"\";\n} catch (error) {\n    dateLabels = \"\";\n}\n\n// --- Get version labels ---\nlet versionsLabels = [];\ntry {\n    const versionsLabelNodeOutput = $('Parse Linear Version Labels').first().json.versions_labels;\n    versionsLabels = typeof versionsLabelNodeOutput === 'string'\n        ? JSON.parse(versionsLabelNodeOutput)\n        : Array.isArray(versionsLabelNodeOutput)\n            ? versionsLabelNodeOutput\n            : [];\n} catch (error) {\n    versionsLabels = [];\n}\n\n// --- Get custom labels to include ---\nlet includeLabels = [];\ntry {\n    const includeLabelNodeOutput = $('Parse Linear Version Labels').first().json.custom_labels;\n    includeLabels = typeof includeLabelNodeOutput === 'string'\n        ? JSON.parse(includeLabelNodeOutput)\n        : Array.isArray(includeLabelNodeOutput)\n            ? includeLabelNodeOutput\n            : [];\n} catch (error) {\n    includeLabels = [];\n}\n\n// --- Initialize stats and grouping ---\nlet totalTickets = 0;\nlet ticketsWithZendesk = 0;\nlet ticketsWithCustomLabels = 0;\nconst issuesByTeam = {};\n\n// --- Slack link helper ---\nfunction extractFirstSlackLink(attachments) {\n    if (!attachments || !Array.isArray(attachments.nodes)) return null;\n    for (const att of attachments.nodes) {\n        if (att.url?.includes(\"your-company.slack.com/archives/\")) {\n            return att.url;\n        }\n    }\n    return null;\n}\n\n// --- Process each issue ---\nconst data = $input.first().json.data; // Get the original data\nconst items = data.issues.nodes;\nfor (const item of items) {\n    const issue = item;\n    totalTickets++;\n\n    const teamName = issue.team?.name || 'Unknown Team';\n    if (!issuesByTeam[teamName]) {\n        issuesByTeam[teamName] = [];\n    }\n    issuesByTeam[teamName].push(issue);\n\n    const zendeskAttachment = (issue.attachments?.nodes || []).find(att => att.url?.includes(\"your-company.zendesk.com\"));\n    if (zendeskAttachment) {\n        ticketsWithZendesk++;\n    }\n\n    const issueLabels = issue.labels?.nodes || [];\n    if (includeLabels.length > 0 && issueLabels.some(l => includeLabels.includes(l.name))) {\n        ticketsWithCustomLabels++;\n    }\n}\n\n// --- Build Markdown output ---\nlet fullMarkdownContent = `## \ud83d\udcca Ticket Summary\\n`;\nfullMarkdownContent += `List of customer-facing improvements and bug fixes.\\n`;\nfullMarkdownContent += `- Release labels: **${versionsLabels.join(', ')}** ${dateLabels}\\n`;\nfullMarkdownContent += `- Total tickets: **${totalTickets}**\\n`;\nfullMarkdownContent += `- Tickets with Zendesk: **${ticketsWithZendesk}**\\n`;\nfullMarkdownContent += `- Tickets with custom labels (${includeLabels.join(', ')}): **${ticketsWithCustomLabels}**\\n`;\n\nfullMarkdownContent += `\\n## \ud83d\uddc2\ufe0f Tickets\\n`;\n\nconst sortedTeams = Object.keys(issuesByTeam).sort();\nfor (const team of sortedTeams) {\n    const teamIssues = issuesByTeam[team];\n    teamIssues.sort((a, b) => (a.identifier && b.identifier) ? a.identifier.localeCompare(b.identifier) : 0);\n\n    fullMarkdownContent += `\\n### ${team}\\n`;\n\n    for (const issue of teamIssues) {\n        const labels = (issue.labels?.nodes || []).map(l => l.name).join(\", \");\n\n        const linearLink = APPEND_LINKS\n            ? `[${issue.identifier}](https://linear.app/YOUR_WORKSPACE/issue/${issue.identifier})`\n            : issue.identifier;\n\n        // --- Zendesk link (per issue) ---\n        let zendeskLink = \"\";\n        const zAttachment = (issue.attachments?.nodes || []).find(att => att.url?.includes(\"your-company.zendesk.com\"));\n        if (zAttachment) {\n            const ticketMatch = zAttachment.url.match(/tickets\\/(\\d+)/);\n            const ticketId = ticketMatch?.[1];\n            if (ticketId) {\n                zendeskLink = APPEND_LINKS\n                    ? `(Zendesk [#${ticketId}](${zAttachment.url}))`\n                    : `(Zendesk #${ticketId})`;\n            }\n        }\n\n        // --- Slack link ---\n        const slackLinkUrl = extractFirstSlackLink(issue.attachments);\n        const slackLink = slackLinkUrl\n            ? APPEND_LINKS\n                ? `(Slack [link](${slackLinkUrl}))`\n                : `(Slack link)`\n            : \"\";\n\n        fullMarkdownContent += `- ${linearLink} - ${issue.title} - ${labels} ${zendeskLink} ${slackLink}`.trim() + `\\n`;\n        fullMarkdownContent += `<description>${issue.description}</description>\\n\\n`;\n    }\n}\n\nreturn [{ json: { markdown: fullMarkdownContent } }];\n"
      },
      "executeOnce": true,
      "typeVersion": 2
    },
    {
      "id": "62ba83df-7062-4e52-9ef7-58494ff1e129",
      "name": "Post AI Suggestions to GitLab",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4400,
        -16
      ],
      "parameters": {
        "url": "=https://gitlab.example.com/api/v4/projects/YOUR_PROJECT_ID/merge_requests/{{ $('When GitLab MR Event Occurs').first().json.body.object_attributes.iid }}/discussions/{{ $('Post Summary to GitLab MR').first().json.id}}/notes",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "form-urlencoded",
        "authentication": "predefinedCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "body",
              "value": "=## \ud83e\udd16 Suggestion from Claude AI \u2728 to be verified:\n\n{{ $json.content[0].text }}\n\n\n\n\n"
            }
          ]
        },
        "nodeCredentialType": "gitlabApi"
      },
      "typeVersion": 4.2
    },
    {
      "id": "9f924b06-9a51-41c2-a0e4-ccc7a7039d9e",
      "name": "When GitLab MR Event Occurs",
      "type": "n8n-nodes-base.gitlabTrigger",
      "position": [
        1584,
        -16
      ],
      "parameters": {
        "owner": "your-org",
        "events": [
          "merge_requests"
        ],
        "repository": "your-docs-repo"
      },
      "typeVersion": 1
    },
    {
      "id": "07e3e3b1-1b2a-4a23-a767-959de9c8fbfe",
      "name": "Claude AI Message",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "position": [
        4048,
        -16
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "claude-opus-4-6",
          "cachedResultName": "claude-opus-4-6"
        },
        "options": {},
        "messages": {
          "values": [
            {
              "content": "=You are a technical writer generating concise, customer-facing release notes.\n\n<data>\n{{ $json.markdown }}\n</data>\n\n<task>\nFor each issue in the data above, write a single-line release note entry suitable for a public changelog.\n</task>\n\n<formatting_instructions>\n- Group entries into two sections:\n  - `### Enhancements` \u2014 new features, improvements, or UX changes\n  - `### Fixes` \u2014 bugs, regressions, or reliability issues\n- Each entry must:\n  - Start with `- [ ] `\n  - Begin with the **Feature Area** in bold (e.g. `**API**:`, `**Dashboard**:`)\n  - Be written in clear, customer-friendly language \u2014 no internal jargon\n  - End with the linked Linear issue ID: `**[ISSUE-123](https://linear.app/YOUR_WORKSPACE/issue/ISSUE-123)**`\n- Group multiple entries under the same Feature Area using nested bullets\n- Do not include emojis\n- Use a professional tone appropriate for an external changelog\n</formatting_instructions>\n\n<example>\n### Enhancements\n- [ ] **API**: Added support for filtering by key/value pairs, improving search flexibility. **[ISSUE-101](https://linear.app/YOUR_WORKSPACE/issue/ISSUE-101)**\n- [ ] **Dashboard**:\n  - [ ] Introduced a new summary view for faster navigation. **[ISSUE-102](https://linear.app/YOUR_WORKSPACE/issue/ISSUE-102)**\n  - [ ] Export to CSV now includes all custom fields. **[ISSUE-103](https://linear.app/YOUR_WORKSPACE/issue/ISSUE-103)**\n\n### Fixes\n- [ ] **Notifications**: Fixed an issue where alerts were sent to deactivated users. **[ISSUE-104](https://linear.app/YOUR_WORKSPACE/issue/ISSUE-104)**\n</example>\n\n<notes>\nUse the issue title, description, and labels from the data to craft accurate summaries.\nIf the feature area is unclear, infer it from the issue's team or labels.\nIf the title is vague, use the description to understand the user impact.\nKeep each entry concise and focused on what changed for the user.\n</notes>"
            }
          ]
        }
      },
      "typeVersion": 1
    },
    {
      "id": "67c47700-2691-4c95-a9fc-c6b452c9ba3c",
      "name": "Fetch GitLab MR Labels",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4624,
        -16
      ],
      "parameters": {
        "url": "=https://gitlab.example.com/api/v4/projects/YOUR_PROJECT_ID/merge_requests/{{ $('When GitLab MR Event Occurs').first().json.body.object_attributes.iid }}",
        "options": {},
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "gitlabApi"
      },
      "typeVersion": 4.2
    },
    {
      "id": "5c1e84ba-7f8d-4964-b0f3-26d974f33b5d",
      "name": "Post Linear Labels to GraphQL",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2480,
        -16
      ],
      "parameters": {
        "url": "https://api.linear.app/graphql",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"query\": \"query ($afterDate: DateTimeOrDuration!, $beforeDate: DateTimeOrDuration!) { issueLabels(filter: { createdAt: { gt: $afterDate, lt: $beforeDate }, name: { startsWith: \\\"v2\\\" } }) { nodes { name, createdAt } } }\",\n  \"variables\": {\n    \"afterDate\": \"{{ $json.isoDate }}\",\n    \"beforeDate\": \"{{ new Date().toISOString().split('T')[0] + 'T23:59:59.999Z' }}\"\n  }\n}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "linearApi"
      },
      "executeOnce": true,
      "typeVersion": 4.2
    },
    {
      "id": "e4f65727-ef31-452f-ab49-efbacc42a977",
      "name": "Parse Linear Version Labels",
      "type": "n8n-nodes-base.code",
      "position": [
        3152,
        -16
      ],
      "parameters": {
        "jsCode": "// Parse version_labels\nconst rawVersionLabels = $json['versions_labels'];\nconst versionLabels = typeof rawVersionLabels === 'string' ? JSON.parse(rawVersionLabels) : rawVersionLabels;\n\n// Parse custom_labels\nconst rawCustomLabels = $json['custom_labels'];\nconst customLabels = typeof rawCustomLabels === 'string' ? JSON.parse(rawCustomLabels) : rawCustomLabels;\n\nconst versions_labels_expanded = [];\nconst custom_labels_expanded = []; // NEW: Array to hold expanded custom labels\n\n// Helper function for expanding version ranges\nfunction expandVersionRange(label, targetArray) {\n    const match = /^v2\\.(\\d+)-v2\\.(\\d+)$/.exec(label);\n    if (match) {\n        const start = parseInt(match[1], 10);\n        const end = parseInt(match[2], 10);\n        for (let i = start; i <= end; i++) {\n            targetArray.push(`v2.${i}`);\n        }\n    } else {\n        targetArray.push(label); // If not a range, push the label as is\n    }\n}\n\n// Expand versions_labels\nfor (const label of versionLabels) {\n    expandVersionRange(label, versions_labels_expanded);\n}\n\n// NEW: Expand custom_labels\nfor (const label of customLabels) {\n    expandVersionRange(label, custom_labels_expanded);\n}\n\n// Return both expanded arrays in the output JSON\nreturn [{ json: {\n    ...$json, // Keep original input data\n    versions_labels_expanded, // Expanded version labels\n    custom_labels_expanded    // NEW: Expanded custom labels\n} }];"
      },
      "typeVersion": 2
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "4c3f15c7-589c-40bf-859f-ccf4a5e22c62",
  "connections": {
    "Read RSS Feed": {
      "main": [
        [
          {
            "node": "Post Linear Labels to GraphQL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude AI Message": {
      "main": [
        [
          {
            "node": "Post AI Suggestions to GitLab",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Various Fields": {
      "main": [
        [
          {
            "node": "Parse Linear Version Labels",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Release Label": {
      "main": [
        [
          {
            "node": "Read RSS Feed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check MR for RN Inputs": {
      "main": [
        [
          {
            "node": "Check Release Label",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch GitLab MR Labels": {
      "main": [
        [
          {
            "node": "Update GitLab MR Label",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Linear Issues Exist": {
      "main": [
        [
          {
            "node": "Generate Summary with Links",
            "type": "main",
            "index": 0
          },
          {
            "node": "Generate Summary with Linear Details",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post No Issues Note to GitLab",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Summary with Links": {
      "main": [
        [
          {
            "node": "Post Summary to GitLab MR",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Linear Version Labels": {
      "main": [
        [
          {
            "node": "Post Linear Issues to GraphQL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When GitLab MR Event Occurs": {
      "main": [
        [
          {
            "node": "Check MR for RN Inputs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post AI Suggestions to GitLab": {
      "main": [
        [
          {
            "node": "Fetch GitLab MR Labels",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post Linear Issues to GraphQL": {
      "main": [
        [
          {
            "node": "Check Linear Issues Exist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post Linear Labels to GraphQL": {
      "main": [
        [
          {
            "node": "Filter Labels Starting With v2",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post No Issues Note to GitLab": {
      "main": [
        [
          {
            "node": "Update GitLab MR Label",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Labels Starting With v2": {
      "main": [
        [
          {
            "node": "Set Various Fields",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Summary with Linear Details": {
      "main": [
        [
          {
            "node": "Claude AI Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Triggered by a GitLab MR webhook, this workflow automatically assists your team in writing customer-facing release notes by combining Linear issue data with Claude AI.

Source: https://n8n.io/workflows/14520/ — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

This workflow helps you repurpose your YouTube videos across multiple social media platforms with zero manual effort. It’s designed for creators, businesses, and marketers who want to maximize reach w

HTTP Request, RSS Feed Read, Discord +4
AI & RAG

This n8n workflow automatically generates a comprehensive dataset of 50 AI search prompts tailored to a specific company.

Anthropic, HTTP Request, Form Trigger
AI & RAG

The competitive edge, delivered. This Customer Intelligence Engine simultaneously analyzes the web, Reddit, and X/Twitter to generate a professional, actionable executive briefing.

Reddit, N8N Nodes Serpapi, HTTP Request +4
AI & RAG

Your Cold Email is Now Researched. This pipeline finds specific bottlenecks on prospect websites and instantly crafts an irresistible pitch

Gmail, Google Sheets, HTTP Request +2
AI & RAG

Automatically turn top performing Instagram reels into 7 new ready to use content scripts. This workflow scrapes high performing posts from a chosen Instagram profile, downloads and transcribes the re

OpenAI, HTTP Request, @Apify/N8N Nodes Apify +2