{
  "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"
    }
  ]
}