fix: preserve Jira description formatting (#5762)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-18 21:30:23 -07:00 committed by GitHub
parent 4c5c2f4df3
commit fc17631af3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 297 additions and 63 deletions

View File

@ -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()
}

View File

@ -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: [

View File

@ -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)

View File

@ -659,6 +659,7 @@ export default function JiraIssueWorkspace({
{displayed.description?.trim() ? (
<CommentMarkdown
content={displayed.description}
variant="document"
className="text-[14px] leading-relaxed"
/>
) : (