fix: preserve Jira description formatting (#5762)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
4c5c2f4df3
commit
fc17631af3
|
|
@ -0,0 +1,192 @@
|
|||
type JiraAdfRecord = Record<string, unknown>
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -659,6 +659,7 @@ export default function JiraIssueWorkspace({
|
|||
{displayed.description?.trim() ? (
|
||||
<CommentMarkdown
|
||||
content={displayed.description}
|
||||
variant="document"
|
||||
className="text-[14px] leading-relaxed"
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
Loading…
Reference in New Issue