diff --git a/src/main/jira/adf-markdown.ts b/src/main/jira/adf-markdown.ts new file mode 100644 index 000000000..2c4eea30e --- /dev/null +++ b/src/main/jira/adf-markdown.ts @@ -0,0 +1,192 @@ +type JiraAdfRecord = Record + +type MarkdownBlock = { + kind: 'block' | 'list' + text: string +} + +function asRecord(value: unknown): JiraAdfRecord { + return value && typeof value === 'object' ? (value as JiraAdfRecord) : {} +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +function asString(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function textNode(text: string): JiraAdfRecord { + return text ? { type: 'text', text } : { type: 'hardBreak' } +} + +export function textToAdf(text: string): JiraAdfRecord { + const lines = text.split(/\r?\n/) + return { + type: 'doc', + version: 1, + content: lines.map((line) => ({ + type: 'paragraph', + content: line ? [textNode(line)] : [] + })) + } +} + +function positiveInteger(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback +} + +function headingLevel(value: unknown): number { + return Math.min(Math.max(positiveInteger(value, 1), 1), 6) +} + +function renderInline(node: unknown): string { + if (!node) { + return '' + } + if (typeof node === 'string') { + return node + } + if (Array.isArray(node)) { + return node.map(renderInline).join('') + } + if (typeof node !== 'object') { + return '' + } + + const record = node as JiraAdfRecord + if (typeof record.text === 'string') { + return record.text + } + if (record.type === 'hardBreak') { + return '\n' + } + + const attrs = asRecord(record.attrs) + const fallbackText = asString(attrs.text) || asString(attrs.shortName) || asString(attrs.url) + if (fallbackText) { + return fallbackText + } + + return renderInline(record.content) +} + +function joinBlocks(blocks: MarkdownBlock[]): string { + return blocks + .map((block) => block.text) + .filter((text) => text.length > 0) + .join('\n\n') +} + +function renderBlocks(content: unknown): MarkdownBlock[] { + return asArray(content) + .map(renderBlock) + .filter((block) => block.text.length > 0) +} + +function renderListItem(node: unknown, prefix: string): string { + const blocks = renderBlocks(asRecord(node).content) + if (blocks.length === 0) { + return prefix.trimEnd() + } + + const lines: string[] = [] + const continuationIndent = ' '.repeat(prefix.length) + blocks.forEach((block, blockIndex) => { + const blockLines = block.text.split('\n') + if (blockIndex === 0) { + lines.push(`${prefix}${blockLines[0] ?? ''}`.trimEnd()) + blockLines.slice(1).forEach((line) => { + lines.push(`${continuationIndent}${line}`.trimEnd()) + }) + return + } + + if (block.kind !== 'list') { + lines.push('') + } + blockLines.forEach((line) => { + lines.push(`${continuationIndent}${line}`.trimEnd()) + }) + }) + + return lines.join('\n') +} + +function renderList(record: JiraAdfRecord, ordered: boolean): string { + const start = ordered ? positiveInteger(asRecord(record.attrs).order, 1) : 1 + return asArray(record.content) + .map((item, index) => renderListItem(item, ordered ? `${start + index}. ` : '- ')) + .join('\n') +} + +function renderCodeBlock(record: JiraAdfRecord): MarkdownBlock { + const text = renderInline(record.content).replace(/\n$/, '') + return { kind: 'block', text: ['```', text, '```'].join('\n') } +} + +function renderBlockquote(record: JiraAdfRecord): MarkdownBlock { + const text = joinBlocks(renderBlocks(record.content)) + return { + kind: 'block', + text: text + .split('\n') + .map((line) => `> ${line}`.trimEnd()) + .join('\n') + } +} + +function renderBlock(node: unknown): MarkdownBlock { + if (typeof node === 'string') { + return { kind: 'block', text: node } + } + if (Array.isArray(node)) { + return { kind: 'block', text: joinBlocks(renderBlocks(node)) } + } + if (!node || typeof node !== 'object') { + return { kind: 'block', text: '' } + } + + const record = node as JiraAdfRecord + const type = asString(record.type) + if (type === 'doc') { + return { kind: 'block', text: joinBlocks(renderBlocks(record.content)) } + } + if (type === 'paragraph') { + return { kind: 'block', text: renderInline(record.content) } + } + if (type === 'heading') { + const prefix = '#'.repeat(headingLevel(asRecord(record.attrs).level)) + return { kind: 'block', text: `${prefix} ${renderInline(record.content).trim()}`.trim() } + } + if (type === 'bulletList') { + // Why: Orca renders Jira bodies as Markdown, so ADF list containers need + // concrete list markers instead of newline-only flattened text. + return { kind: 'list', text: renderList(record, false) } + } + if (type === 'orderedList') { + return { kind: 'list', text: renderList(record, true) } + } + if (type === 'listItem') { + return { kind: 'list', text: renderListItem(record, '- ') } + } + if (type === 'codeBlock') { + return renderCodeBlock(record) + } + if (type === 'blockquote') { + return renderBlockquote(record) + } + if (type === 'rule') { + return { kind: 'block', text: '---' } + } + + return { kind: 'block', text: joinBlocks(renderBlocks(record.content)) || renderInline(record) } +} + +export function adfToMarkdownText(value: unknown): string { + return renderBlock(value) + .text.replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index 7225a0e3e..befbbeed9 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -199,6 +199,107 @@ describe('Jira issue operations', () => { }) }) + it('maps Jira ADF descriptions into Markdown blocks and lists', async () => { + const { mapJiraIssue } = await import('./issues') + + const issue = mapJiraIssue(makeEntry().site, { + id: 'issue-33', + key: 'PM-33', + fields: { + summary: 'BE - Tests E2E/Cleanup', + description: { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'História' }, + { type: 'hardBreak' }, + { type: 'text', text: 'Coverage ownership' } + ] + }, + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'admin - JOAO' }] + } + ] + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'attachment batch - JOAO' }] + } + ] + } + ] + }, + { + type: 'orderedList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'API module' }] + } + ] + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'UI module' }] + } + ] + } + ] + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Done' }] + } + ] + }, + project: { id: '10000', key: 'PM', name: 'Project Management' }, + issuetype: { id: '10001', name: 'Task' }, + status: { + id: '1', + name: 'To Do', + statusCategory: { key: 'new', name: 'To Do' } + }, + labels: [], + created: '2026-06-18T00:00:00.000Z', + updated: '2026-06-18T00:00:00.000Z' + } + }) + + expect(issue.description).toBe( + [ + 'História', + 'Coverage ownership', + '', + '- admin - JOAO', + '- attachment batch - JOAO', + '', + '1. API module', + '2. UI module', + '', + 'Done' + ].join('\n') + ) + }) + it('maps comments from the Jira comments page key', async () => { jiraRequestMock.mockResolvedValueOnce({ comments: [ diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 490e488cf..60a7dc49f 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -29,6 +29,7 @@ import { release, type JiraClientForSite } from './client' +import { adfToMarkdownText, textToAdf } from './adf-markdown' const ISSUE_FIELDS = [ 'summary', @@ -264,67 +265,6 @@ function mapStatus(value: unknown): JiraStatus { } } -function textNode(text: string): JiraRecord { - return text ? { type: 'text', text } : { type: 'hardBreak' } -} - -function textToAdf(text: string): JiraRecord { - const lines = text.split(/\r?\n/) - return { - type: 'doc', - version: 1, - content: lines.map((line) => ({ - type: 'paragraph', - content: line ? [textNode(line)] : [] - })) - } -} - -function adfToPlainText(value: unknown): string { - const chunks: string[] = [] - - const walk = (node: unknown): void => { - if (!node) { - return - } - if (typeof node === 'string') { - chunks.push(node) - return - } - if (Array.isArray(node)) { - node.forEach(walk) - return - } - if (typeof node !== 'object') { - return - } - const record = node as JiraRecord - if (typeof record.text === 'string') { - chunks.push(record.text) - } - if (record.type === 'hardBreak') { - chunks.push('\n') - } - walk(record.content) - if ( - record.type === 'paragraph' || - record.type === 'heading' || - record.type === 'listItem' || - record.type === 'bulletList' || - record.type === 'orderedList' - ) { - chunks.push('\n') - } - } - - walk(value) - return chunks - .join('') - .replace(/[ \t]+\n/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim() -} - function issueUrl(site: JiraSite, key: string): string { return `${site.siteUrl}/browse/${encodeURIComponent(key)}` } @@ -338,7 +278,7 @@ export function mapJiraIssue(site: JiraSite, raw: JiraRecord): JiraIssue { siteId: site.id, siteName: site.displayName, title: asString(fields.summary, key || 'Untitled issue'), - description: adfToPlainText(fields.description), + description: adfToMarkdownText(fields.description), url: issueUrl(site, key), project: mapProject(fields.project, site), issueType: mapIssueType(fields.issuetype), @@ -592,7 +532,7 @@ export async function addIssueComment( function mapComment(raw: JiraRecord): JiraComment { return { id: asString(raw.id), - body: adfToPlainText(raw.body), + body: adfToMarkdownText(raw.body), createdAt: asString(raw.created, new Date().toISOString()), updatedAt: asString(raw.updated) || undefined, user: mapUser(raw.author) diff --git a/src/renderer/src/components/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx index 56ffbc802..3370e5613 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.tsx @@ -659,6 +659,7 @@ export default function JiraIssueWorkspace({ {displayed.description?.trim() ? ( ) : (