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 →
{
"name": "Markdown \u2192 Jira Epic + Stories (Create or Update)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "markdown-to-jira",
"responseMode": "responseNode",
"options": {}
},
"id": "j1000000-0000-0000-0000-000000000001",
"name": "Webhook Trigger",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
240,
300
]
},
{
"parameters": {
"jsCode": "// \u2500\u2500 Resolve user input: URL \u2192 accountId, email \u2192 mark for search, raw \u2192 passthrough \u2500\u2500\nfunction resolveUserInput(val) {\n if (!val) return { accountId: '', needsSearch: false, searchQuery: '' };\n val = val.trim();\n // Atlassian profile URL: extract and decode the ID after /people/\n const peopleMatch = val.match(/\\/people\\/([^?/]+)/);\n if (peopleMatch) return { accountId: decodeURIComponent(peopleMatch[1]), needsSearch: false, searchQuery: '' };\n // Email: needs API search\n if (val.includes('@')) return { accountId: '', needsSearch: true, searchQuery: val };\n // Raw accountId\n return { accountId: val, needsSearch: false, searchQuery: '' };\n}\n\nconst body = $input.first().json.body;\nconst assigneeInput = resolveUserInput(body.assignee || '');\nconst reporterInput = resolveUserInput(body.reporter || '');\n\n// Build search queries for any emails that need resolving\nconst searches = [];\nif (assigneeInput.needsSearch) searches.push({ field: 'assignee', query: assigneeInput.searchQuery });\nif (reporterInput.needsSearch) searches.push({ field: 'reporter', query: reporterInput.searchQuery });\n\nreturn [{ json: {\n body,\n assigneeId: assigneeInput.accountId,\n reporterId: reporterInput.accountId,\n searches,\n needsSearch: searches.length > 0\n} }];"
},
"id": "j1000000-0000-0000-0000-000000000020",
"name": "Extract User IDs",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
460,
300
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "needs-search",
"leftValue": "={{ $json.needsSearch }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
}
},
"id": "j1000000-0000-0000-0000-000000000021",
"name": "Needs User Search?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
680,
300
]
},
{
"parameters": {
"method": "GET",
"url": "={{ $env.JIRA_BASE_URL }}/rest/api/3/user/search?query={{ encodeURIComponent($json.searches[0].query) }}&maxResults=1",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"options": {
"timeout": 15000
}
},
"id": "j1000000-0000-0000-0000-000000000022",
"name": "Search Users",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
900,
200
],
"credentials": {
"httpBasicAuth": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "const prev = $('Extract User IDs').first().json;\nconst searchResults = $input.first().json;\nlet assigneeId = prev.assigneeId;\nlet reporterId = prev.reporterId;\n\n// Map search results back to the right field\nconst searches = prev.searches || [];\nconst results = Array.isArray(searchResults) ? searchResults : [searchResults];\nfor (const s of searches) {\n const found = results.find(r => r.emailAddress === s.query || r.displayName === s.query);\n const resolvedId = (results.length > 0 && results[0].accountId) ? results[0].accountId : '';\n if (s.field === 'assignee' && !assigneeId) assigneeId = found?.accountId || resolvedId;\n if (s.field === 'reporter' && !reporterId) reporterId = found?.accountId || resolvedId;\n}\n// If two different emails, second one needs separate search \u2014 for simplicity use first result\n// (Full implementation would do two searches)\nif (searches.length > 1 && !reporterId) {\n reporterId = assigneeId; // fallback: same user if only one search was done\n}\n\nreturn [{ json: { body: prev.body, assigneeId, reporterId } }];"
},
"id": "j1000000-0000-0000-0000-000000000023",
"name": "Map Search Results",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1120,
200
]
},
{
"parameters": {
"jsCode": "const prev = $('Extract User IDs').first().json;\nreturn [{ json: { body: prev.body, assigneeId: prev.assigneeId, reporterId: prev.reporterId } }];"
},
"id": "j1000000-0000-0000-0000-000000000024",
"name": "Skip Search",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
900,
420
]
},
{
"parameters": {
"jsCode": "// \u2500\u2500 Markdown-to-ADF converter \u2500\u2500\nfunction mdToAdf(md) {\n const lines = md.replace(/\\r/g, '').split('\\n');\n const nodes = [];\n let i = 0;\n function inlineToAdf(text) {\n const parts = [];\n const regex = /(\\*\\*(.+?)\\*\\*|\\*(.+?)\\*|`([^`]+)`|\\[([^\\]]+)\\]\\(([^)]+)\\))/g;\n let last = 0; let m;\n while ((m = regex.exec(text)) !== null) {\n if (m.index > last) parts.push({ type: 'text', text: text.slice(last, m.index) });\n if (m[2]) parts.push({ type: 'text', text: m[2], marks: [{ type: 'strong' }] });\n else if (m[3]) parts.push({ type: 'text', text: m[3], marks: [{ type: 'em' }] });\n else if (m[4]) parts.push({ type: 'text', text: m[4], marks: [{ type: 'code' }] });\n else if (m[5] && m[6]) parts.push({ type: 'text', text: m[5], marks: [{ type: 'link', attrs: { href: m[6] } }] });\n last = m.index + m[0].length;\n }\n if (last < text.length) parts.push({ type: 'text', text: text.slice(last) });\n if (parts.length === 0 && text) parts.push({ type: 'text', text });\n return parts;\n }\n while (i < lines.length) {\n const line = lines[i];\n if (line.startsWith('```')) {\n const lang = line.slice(3).trim() || null; const codeLines = []; i++;\n while (i < lines.length && !lines[i].startsWith('```')) { codeLines.push(lines[i]); i++; } i++;\n const cb = { type: 'codeBlock', content: [{ type: 'text', text: codeLines.join('\\n') }] };\n if (lang) cb.attrs = { language: lang }; nodes.push(cb); continue;\n }\n const hm = line.match(/^(#{1,6}) (.+)$/);\n if (hm) { nodes.push({ type: 'heading', attrs: { level: hm[1].length }, content: inlineToAdf(hm[2]) }); i++; continue; }\n if (line.match(/^---+$/)) { nodes.push({ type: 'rule' }); i++; continue; }\n if (line.startsWith('> ')) { const ql = []; while (i < lines.length && lines[i].startsWith('> ')) { ql.push(lines[i].slice(2)); i++; } nodes.push({ type: 'blockquote', content: [{ type: 'paragraph', content: inlineToAdf(ql.join(' ')) }] }); continue; }\n if (line.match(/^- \\[([ xX])\\] /)) { let uid = 0; const items = []; while (i < lines.length && lines[i].match(/^- \\[([ xX])\\] /)) { const cm = lines[i].match(/^- \\[([ xX])\\] (.+)/); const state = (cm[1] === 'x' || cm[1] === 'X') ? 'DONE' : 'TODO'; items.push({ type: 'taskItem', attrs: { localId: 'task-' + (uid++), state }, content: inlineToAdf(cm[2]) }); i++; } nodes.push({ type: 'taskList', attrs: { localId: 'tl-' + uid }, content: items }); continue; }\n if (line.match(/^[-*] /)) { const items = []; while (i < lines.length && lines[i].match(/^[-*] /) && !lines[i].match(/^- \\[([ xX])\\] /)) { items.push({ type: 'listItem', content: [{ type: 'paragraph', content: inlineToAdf(lines[i].replace(/^[-*] /, '')) }] }); i++; } nodes.push({ type: 'bulletList', content: items }); continue; }\n if (line.match(/^\\d+[.)]/)) { const items = []; while (i < lines.length && lines[i].match(/^\\d+[.)]/)) { items.push({ type: 'listItem', content: [{ type: 'paragraph', content: inlineToAdf(lines[i].replace(/^\\d+[.)] ?/, '')) }] }); i++; } nodes.push({ type: 'orderedList', content: items }); continue; }\n if (line.match(/^\\|.+\\|$/) && i + 1 < lines.length && lines[i+1].match(/^\\|[-| :]+\\|$/)) {\n const hc = line.split('|').filter(c => c.trim()).map(c => ({ type: 'tableHeader', attrs: {}, content: [{ type: 'paragraph', content: inlineToAdf(c.trim()) }] }));\n const rows = [{ type: 'tableRow', content: hc }]; i += 2;\n while (i < lines.length && lines[i].match(/^\\|.+\\|$/)) { rows.push({ type: 'tableRow', content: lines[i].split('|').filter(c => c.trim()).map(c => ({ type: 'tableCell', attrs: {}, content: [{ type: 'paragraph', content: inlineToAdf(c.trim()) }] })) }); i++; }\n nodes.push({ type: 'table', attrs: { isNumberColumnEnabled: false, layout: 'default' }, content: rows }); continue;\n }\n if (!line.trim()) { i++; continue; }\n nodes.push({ type: 'paragraph', content: inlineToAdf(line) }); i++;\n }\n return { type: 'doc', version: 1, content: nodes.length > 0 ? nodes : [{ type: 'paragraph', content: [{ type: 'text', text: ' ' }] }] };\n}\n\nfunction parseMarkdown(md) {\n const lines = md.replace(/\\r/g, '').split('\\n');\n const titleLine = lines.find(l => l.startsWith('# '));\n const title = titleLine ? titleLine.replace(/^# /, '') : 'Untitled';\n const titleIdx = lines.indexOf(titleLine);\n const knownSections = ['Acceptance Criteria', 'Priority', 'Estimate', 'Component', 'Labels'];\n const bodyLines = [];\n for (let j = (titleIdx >= 0 ? titleIdx + 1 : 0); j < lines.length; j++) {\n if (lines[j].startsWith('## ') && knownSections.some(s => lines[j] === '## ' + s)) break;\n bodyLines.push(lines[j]);\n }\n while (bodyLines.length && !bodyLines[0].trim()) bodyLines.shift();\n while (bodyLines.length && !bodyLines[bodyLines.length - 1].trim()) bodyLines.pop();\n const bodyMarkdown = bodyLines.join('\\n');\n function getSection(heading) { const start = lines.findIndex(l => l === '## ' + heading); if (start === -1) return []; const out = []; for (let j = start + 1; j < lines.length; j++) { if (lines[j].startsWith('## ')) break; if (lines[j].trim()) out.push(lines[j]); } return out; }\n function getField(heading) { const s = getSection(heading); return s.length ? s[0].trim() : ''; }\n const criteria = getSection('Acceptance Criteria').filter(l => l.startsWith('- ')).map(l => l.replace(/^- /, ''));\n const priority = getField('Priority') || ($env.JIRA_DEFAULT_PRIORITY || 'Medium');\n const component = getField('Component');\n const labelsStr = getField('Labels');\n const labels = ['n8n-pipeline-generated'];\n if (labelsStr) labels.push(...labelsStr.split(',').map(l => l.trim()).filter(Boolean));\n return { title, bodyMarkdown, criteria, priority, component, labels };\n}\n\nfunction buildJiraFields(parsed, issueType, parentKey, formComponent, assignee, reporter) {\n let fullMd = parsed.bodyMarkdown || '';\n if (parsed.criteria.length > 0) { if (fullMd) fullMd += '\\n\\n'; fullMd += '## Acceptance Criteria\\n'; for (const c of parsed.criteria) fullMd += '- ' + c + '\\n'; }\n const fields = { summary: parsed.title, description: mdToAdf(fullMd), priority: { name: parsed.priority }, labels: parsed.labels };\n const comp = parsed.component || formComponent || ($env.JIRA_DEFAULT_COMPONENT || '');\n if (comp) fields.components = [{ name: comp }];\n if (assignee) fields.assignee = { accountId: assignee };\n if (reporter) fields.reporter = { accountId: reporter };\n if (parentKey) fields.parent = { key: parentKey };\n return fields;\n}\n\nfunction extractKey(input) {\n if (!input) return '';\n const browseMatch = input.match(/\\/browse\\/([A-Z]+-\\d+)/);\n if (browseMatch) return browseMatch[1];\n if (input.match(/^[A-Z]+-\\d+$/)) return input;\n return input.trim();\n}\n\nconst prev = $input.first().json;\nconst body = prev.body;\nconst assigneeId = prev.assigneeId || '';\nconst reporterId = prev.reporterId || '';\nconst epicMarkdown = body.epicMarkdown || '';\nconst taskMarkdowns = body.taskMarkdowns || [];\nconst epicKey = extractKey(body.epicKey || '');\nconst taskKeys = (body.taskKeys || []).map(k => extractKey(k));\nconst taskAssignees = body.taskAssignees || [];\nconst formComponent = body.component || '';\n\nconst epicParsed = parseMarkdown(epicMarkdown);\nconst epicIssueType = body.issueType || ($env.JIRA_EPIC_ISSUE_TYPE || 'Epic');\nconst explicitParentKey = body.parentKey || '';\n\nlet epicApiBody, epicMethod, epicUrl;\nif (epicKey) {\n epicMethod = 'PUT';\n epicUrl = $env.JIRA_BASE_URL + '/rest/api/3/issue/' + epicKey;\n epicApiBody = { fields: buildJiraFields(epicParsed, epicIssueType, explicitParentKey || null, formComponent, assigneeId, reporterId) };\n} else {\n epicMethod = 'POST';\n epicUrl = $env.JIRA_BASE_URL + '/rest/api/3/issue';\n const fields = buildJiraFields(epicParsed, epicIssueType, explicitParentKey || null, formComponent, assigneeId, reporterId);\n fields.project = { key: $env.JIRA_PROJECT_KEY };\n fields.issuetype = { name: epicIssueType };\n epicApiBody = { fields };\n}\n\nreturn [{ json: { epicApiBody, epicMethod, epicUrl, epicKey, epicTitle: epicParsed.title, taskMarkdowns, taskKeys, taskAssignees, isUpdate: !!epicKey, formComponent, assigneeId, reporterId } }];"
},
"id": "j1000000-0000-0000-0000-000000000002",
"name": "Parse Epic Markdown",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1340,
300
]
},
{
"parameters": {
"method": "={{ $json.epicMethod }}",
"url": "={{ $json.epicUrl }}",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.epicApiBody) }}",
"options": {
"timeout": 30000,
"response": {
"response": {
"responseFormat": "text"
}
}
}
},
"id": "j1000000-0000-0000-0000-000000000003",
"name": "Create or Update Epic",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1560,
300
],
"credentials": {
"httpBasicAuth": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "// \u2500\u2500 Same helpers (duplicated for n8n Code node isolation) \u2500\u2500\nfunction mdToAdf(md) {\n const lines = md.replace(/\\r/g, '').split('\\n'); const nodes = []; let i = 0;\n function inlineToAdf(text) { const parts = []; const regex = /(\\*\\*(.+?)\\*\\*|\\*(.+?)\\*|`([^`]+)`|\\[([^\\]]+)\\]\\(([^)]+)\\))/g; let last = 0; let m; while ((m = regex.exec(text)) !== null) { if (m.index > last) parts.push({ type: 'text', text: text.slice(last, m.index) }); if (m[2]) parts.push({ type: 'text', text: m[2], marks: [{ type: 'strong' }] }); else if (m[3]) parts.push({ type: 'text', text: m[3], marks: [{ type: 'em' }] }); else if (m[4]) parts.push({ type: 'text', text: m[4], marks: [{ type: 'code' }] }); else if (m[5] && m[6]) parts.push({ type: 'text', text: m[5], marks: [{ type: 'link', attrs: { href: m[6] } }] }); last = m.index + m[0].length; } if (last < text.length) parts.push({ type: 'text', text: text.slice(last) }); if (parts.length === 0 && text) parts.push({ type: 'text', text }); return parts; }\n while (i < lines.length) { const line = lines[i]; if (line.startsWith('```')) { const lang = line.slice(3).trim() || null; const cl = []; i++; while (i < lines.length && !lines[i].startsWith('```')) { cl.push(lines[i]); i++; } i++; const cb = { type: 'codeBlock', content: [{ type: 'text', text: cl.join('\\n') }] }; if (lang) cb.attrs = { language: lang }; nodes.push(cb); continue; } const hm = line.match(/^(#{1,6}) (.+)$/); if (hm) { nodes.push({ type: 'heading', attrs: { level: hm[1].length }, content: inlineToAdf(hm[2]) }); i++; continue; } if (line.match(/^---+$/)) { nodes.push({ type: 'rule' }); i++; continue; } if (line.startsWith('> ')) { const ql = []; while (i < lines.length && lines[i].startsWith('> ')) { ql.push(lines[i].slice(2)); i++; } nodes.push({ type: 'blockquote', content: [{ type: 'paragraph', content: inlineToAdf(ql.join(' ')) }] }); continue; } if (line.match(/^- \\[([ xX])\\] /)) { let uid = 0; const items = []; while (i < lines.length && lines[i].match(/^- \\[([ xX])\\] /)) { const cm = lines[i].match(/^- \\[([ xX])\\] (.+)/); const state = (cm[1] === 'x' || cm[1] === 'X') ? 'DONE' : 'TODO'; items.push({ type: 'taskItem', attrs: { localId: 'task-' + (uid++), state }, content: inlineToAdf(cm[2]) }); i++; } nodes.push({ type: 'taskList', attrs: { localId: 'tl-' + uid }, content: items }); continue; }\n if (line.match(/^[-*] /)) { const items = []; while (i < lines.length && lines[i].match(/^[-*] /) && !lines[i].match(/^- \\[([ xX])\\] /)) { items.push({ type: 'listItem', content: [{ type: 'paragraph', content: inlineToAdf(lines[i].replace(/^[-*] /, '')) }] }); i++; } nodes.push({ type: 'bulletList', content: items }); continue; } if (line.match(/^\\d+[.)]/)) { const items = []; while (i < lines.length && lines[i].match(/^\\d+[.)]/)) { items.push({ type: 'listItem', content: [{ type: 'paragraph', content: inlineToAdf(lines[i].replace(/^\\d+[.)] ?/, '')) }] }); i++; } nodes.push({ type: 'orderedList', content: items }); continue; }\n if (line.match(/^\\|.+\\|$/) && i + 1 < lines.length && lines[i+1].match(/^\\|[-| :]+\\|$/)) {\n const hc = line.split('|').filter(c => c.trim()).map(c => ({ type: 'tableHeader', attrs: {}, content: [{ type: 'paragraph', content: inlineToAdf(c.trim()) }] }));\n const rows = [{ type: 'tableRow', content: hc }]; i += 2;\n while (i < lines.length && lines[i].match(/^\\|.+\\|$/)) { rows.push({ type: 'tableRow', content: lines[i].split('|').filter(c => c.trim()).map(c => ({ type: 'tableCell', attrs: {}, content: [{ type: 'paragraph', content: inlineToAdf(c.trim()) }] })) }); i++; }\n nodes.push({ type: 'table', attrs: { isNumberColumnEnabled: false, layout: 'default' }, content: rows }); continue;\n }\n if (!line.trim()) { i++; continue; } nodes.push({ type: 'paragraph', content: inlineToAdf(line) }); i++; }\n return { type: 'doc', version: 1, content: nodes.length > 0 ? nodes : [{ type: 'paragraph', content: [{ type: 'text', text: ' ' }] }] };\n}\nfunction parseMarkdown(md) { const lines = md.split('\\n'); const titleLine = lines.find(l => l.startsWith('# ')); const title = titleLine ? titleLine.replace(/^# /, '') : 'Untitled'; const titleIdx = lines.indexOf(titleLine); const knownSections = ['Acceptance Criteria', 'Priority', 'Estimate', 'Component', 'Labels']; const bodyLines = []; for (let j = (titleIdx >= 0 ? titleIdx + 1 : 0); j < lines.length; j++) { if (lines[j].startsWith('## ') && knownSections.some(s => lines[j] === '## ' + s)) break; bodyLines.push(lines[j]); } while (bodyLines.length && !bodyLines[0].trim()) bodyLines.shift(); while (bodyLines.length && !bodyLines[bodyLines.length - 1].trim()) bodyLines.pop(); const bodyMarkdown = bodyLines.join('\\n'); function getSection(heading) { const start = lines.findIndex(l => l === '## ' + heading); if (start === -1) return []; const out = []; for (let j = start + 1; j < lines.length; j++) { if (lines[j].startsWith('## ')) break; if (lines[j].trim()) out.push(lines[j]); } return out; } function getField(heading) { const s = getSection(heading); return s.length ? s[0].trim() : ''; } const criteria = getSection('Acceptance Criteria').filter(l => l.startsWith('- ')).map(l => l.replace(/^- /, '')); const priority = getField('Priority') || ($env.JIRA_DEFAULT_PRIORITY || 'Medium'); const component = getField('Component'); const labelsStr = getField('Labels'); const labels = ['n8n-pipeline-generated']; if (labelsStr) labels.push(...labelsStr.split(',').map(l => l.trim()).filter(Boolean)); return { title, bodyMarkdown, criteria, priority, component, labels }; }\nfunction buildJiraFields(parsed, issueType, parentKey, formComponent, assignee, reporter) { let fullMd = parsed.bodyMarkdown || ''; if (parsed.criteria.length > 0) { if (fullMd) fullMd += '\\n\\n'; fullMd += '## Acceptance Criteria\\n'; for (const c of parsed.criteria) fullMd += '- ' + c + '\\n'; } const fields = { summary: parsed.title, description: mdToAdf(fullMd), priority: { name: parsed.priority }, labels: parsed.labels }; const comp = parsed.component || formComponent || ($env.JIRA_DEFAULT_COMPONENT || ''); if (comp) fields.components = [{ name: comp }]; if (assignee) fields.assignee = { accountId: assignee }; if (reporter) fields.reporter = { accountId: reporter }; if (parentKey) fields.parent = { key: parentKey }; return fields; }\n\nconst rawResponse = $input.first().json;\nlet epicResponse = {};\ntry { const txt = rawResponse.data || rawResponse.body || (typeof rawResponse === 'string' ? rawResponse : ''); if (txt && typeof txt === 'string' && txt.trim()) epicResponse = JSON.parse(txt); else if (rawResponse.key) epicResponse = rawResponse; } catch(e) { epicResponse = rawResponse; }\nconst prev = $('Parse Epic Markdown').first().json;\nconst epicKey = prev.epicKey || epicResponse.key;\nconst epicTitle = prev.epicTitle;\nconst taskMarkdowns = prev.taskMarkdowns || [];\nconst taskKeys = prev.taskKeys || [];\nconst taskAssignees = prev.taskAssignees || [];\nconst formComponent = prev.formComponent || '';\nconst assigneeId = prev.assigneeId || '';\nconst reporterId = prev.reporterId || '';\nconst storyType = $env.JIRA_STORY_ISSUE_TYPE || 'Story';\nconst jiraBase = $env.JIRA_BASE_URL;\n\nif (taskMarkdowns.length === 0) return [{ json: { epicKey, epicTitle, noTasks: true } }];\n\nreturn taskMarkdowns.map((md, idx) => {\n const parsed = parseMarkdown(md);\n const existingKey = taskKeys[idx] || '';\n // Per-task assignee: resolve URL if needed, otherwise use form default\n let taskAssignee = taskAssignees[idx] || '';\n if (taskAssignee) {\n const pm = taskAssignee.match(/\\/people\\/([^?/]+)/);\n if (pm) taskAssignee = decodeURIComponent(pm[1]);\n else if (taskAssignee.includes('@')) taskAssignee = ''; // can't resolve per-task emails in this node\n }\n if (!taskAssignee) taskAssignee = assigneeId;\n let method, url, apiBody;\n if (existingKey) {\n method = 'PUT'; url = jiraBase + '/rest/api/3/issue/' + existingKey;\n apiBody = { fields: buildJiraFields(parsed, storyType, epicKey, formComponent, taskAssignee, reporterId) };\n } else {\n method = 'POST'; url = jiraBase + '/rest/api/3/issue';\n const fields = buildJiraFields(parsed, storyType, epicKey, formComponent, taskAssignee, reporterId);\n fields.project = { key: $env.JIRA_PROJECT_KEY }; fields.issuetype = { name: storyType };\n apiBody = { fields };\n }\n return { json: { apiBody, method, url, taskTitle: parsed.title, epicKey, epicTitle, existingKey } };\n});"
},
"id": "j1000000-0000-0000-0000-000000000004",
"name": "Prepare Tasks",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1780,
300
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "has-tasks",
"leftValue": "={{ $json.noTasks }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "notEquals"
}
}
],
"combinator": "and"
}
},
"id": "j1000000-0000-0000-0000-000000000005",
"name": "Has Tasks?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
2000,
300
]
},
{
"parameters": {
"method": "={{ $json.method }}",
"url": "={{ $json.url }}",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.apiBody) }}",
"options": {
"timeout": 30000,
"response": {
"response": {
"responseFormat": "text"
}
}
}
},
"id": "j1000000-0000-0000-0000-000000000006",
"name": "Create or Update Task",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2220,
200
],
"credentials": {
"httpBasicAuth": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "const prev = $('Prepare Tasks').first().json;\nconst epicKey = prev.epicKey;\nconst epicTitle = prev.epicTitle;\nconst jiraBase = $env.JIRA_BASE_URL;\nconst allTasks = $('Prepare Tasks').all();\nconst allResults = $input.all();\nconst tasks = allTasks.map((t, i) => {\n const raw = allResults[i]?.json || {};\n let result = {};\n try { const txt = raw.data || raw.body || (typeof raw === 'string' ? raw : ''); if (txt && typeof txt === 'string' && txt.trim()) result = JSON.parse(txt); else if (raw.key) result = raw; } catch(e) { result = raw; }\n const key = result.key || t.json.existingKey;\n return { key, title: t.json.taskTitle, url: jiraBase + '/browse/' + key, action: t.json.existingKey ? 'updated' : 'created' };\n});\nreturn [{ json: { success: true, epic: { key: epicKey, title: epicTitle, url: jiraBase + '/browse/' + epicKey, action: $('Parse Epic Markdown').first().json.isUpdate ? 'updated' : 'created' }, tasks, taskCount: tasks.length } }];"
},
"id": "j1000000-0000-0000-0000-000000000007",
"name": "Aggregate Results",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2440,
200
]
},
{
"parameters": {
"jsCode": "const epicKey = $input.first().json.epicKey;\nconst epicTitle = $input.first().json.epicTitle;\nconst jiraBase = $env.JIRA_BASE_URL;\nconst isUpdate = $('Parse Epic Markdown').first().json.isUpdate;\nreturn [{ json: { success: true, epic: { key: epicKey, title: epicTitle, url: jiraBase + '/browse/' + epicKey, action: isUpdate ? 'updated' : 'created' }, tasks: [], taskCount: 0 } }];"
},
"id": "j1000000-0000-0000-0000-000000000008",
"name": "Epic Only Result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2220,
420
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify($json) }}"
},
"id": "j1000000-0000-0000-0000-000000000009",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2660,
300
]
}
],
"connections": {
"Webhook Trigger": {
"main": [
[
{
"node": "Extract User IDs",
"type": "main",
"index": 0
}
]
]
},
"Extract User IDs": {
"main": [
[
{
"node": "Needs User Search?",
"type": "main",
"index": 0
}
]
]
},
"Needs User Search?": {
"main": [
[
{
"node": "Search Users",
"type": "main",
"index": 0
}
],
[
{
"node": "Skip Search",
"type": "main",
"index": 0
}
]
]
},
"Search Users": {
"main": [
[
{
"node": "Map Search Results",
"type": "main",
"index": 0
}
]
]
},
"Map Search Results": {
"main": [
[
{
"node": "Parse Epic Markdown",
"type": "main",
"index": 0
}
]
]
},
"Skip Search": {
"main": [
[
{
"node": "Parse Epic Markdown",
"type": "main",
"index": 0
}
]
]
},
"Parse Epic Markdown": {
"main": [
[
{
"node": "Create or Update Epic",
"type": "main",
"index": 0
}
]
]
},
"Create or Update Epic": {
"main": [
[
{
"node": "Prepare Tasks",
"type": "main",
"index": 0
}
]
]
},
"Prepare Tasks": {
"main": [
[
{
"node": "Has Tasks?",
"type": "main",
"index": 0
}
]
]
},
"Has Tasks?": {
"main": [
[
{
"node": "Create or Update Task",
"type": "main",
"index": 0
}
],
[
{
"node": "Epic Only Result",
"type": "main",
"index": 0
}
]
]
},
"Create or Update Task": {
"main": [
[
{
"node": "Aggregate Results",
"type": "main",
"index": 0
}
]
]
},
"Aggregate Results": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
},
"Epic Only Result": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"tags": [
{
"name": "ai-pipeline"
},
{
"name": "jira"
},
{
"name": "markdown"
}
]
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
httpBasicAuth
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Markdown → Jira Epic + Stories (Create or Update). Uses httpRequest. Webhook trigger; 14 nodes.
Source: https://github.com/openmindednewby/ai-confluence-pipeline/blob/master/workflows/markdown-to-jira-pipeline.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This n8n template provides enterprise-level version control for your workflows using GitHub integration. Stop losing hours to broken workflows and manual exports – get proper commit history, visual di
This flow creates dummy files for every item added in your *Arrs (Radarr/Sonarr) with the tag .
eek-Go v2 (Batch-Then-Review). Uses httpRequest. Webhook trigger; 75 nodes.
This workflow receives webhook requests from a content calendar and uses the X API v2 to publish text posts, threads, image/video posts, and polls, as well as delete existing posts and run a credentia
This workflow acts as a central API gateway for all technical indicator agents in the Binance Spot Market Quant AI system. It listens for incoming webhook requests and dynamically routes them to the c