Skip to content

Conversation

@Jahteo
Copy link
Contributor

@Jahteo Jahteo commented Dec 2, 2025

Problem

Users were losing Shift+Enter line breaks (hard breaks) within bullet lists when updating Jira issues through our MCP tools. This happened because the codebase was doing lossy round-trip conversions: ADF → Markdown , which stripped out hardBreak nodes that don't cleanly map to Markdown, then converting that back -> ADF & uploading to Jira.

Solution

Refactored the write-next-story to work directly with native ADF (Atlassian Document Format) for all data manipulation, eliminating conversions entirely. Markdown is now only used one-way for AI prompt generation (never converted back).

Changes

  • write-next-story/core-logic.ts uses new parseShellStoriesFromAdf() & addCompletionMarkerToStory() for epic updates
  • figma-screen-setup.ts returns ADF structures as well as Markdown now. Renames some of it's return values for clarity.
  • Preserved all ADF node types including hardBreak, nested lists, marks, inline cards, mentions, etc.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors the codebase to work natively with ADF (Atlassian Document Format) instead of performing lossy Markdown round-trip conversions. The primary goal is to preserve hardBreak nodes (Shift+Enter line breaks) and other ADF formatting that gets lost when converting ADF → Markdown → ADF.

Key changes:

  • Renamed conversion functions to convertMarkdownToAdf_NewContentOnly() and convertAdfToMarkdown_AIPromptOnly() with clear usage constraints
  • Created comprehensive ADF manipulation utilities in adf-operations.ts
  • Implemented ADF-native shell story parser to replace Markdown-based parsing
  • Refactored business logic in write-next-story and write-shell-stories tools to use native ADF operations
  • Introduced AIPromptContext interface to enforce one-way Markdown conversion for AI prompts only

Benefits: Preserves all Jira formatting (hardBreaks, nested lists, marks, etc.), reduces AI token costs by 3-4x, prevents data corruption, and improves overall system reliability.

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
specs/adf-native-processing-refactor.md Comprehensive implementation plan and design decisions for the ADF native refactor
server/providers/atlassian/markdown-converter.ts Renamed conversion functions with usage warnings in JSDoc
server/providers/atlassian/adf-operations.ts New pure ADF manipulation utilities (extract, remove, append, replace sections)
server/providers/combined/tools/shared/shell-story-adf-parser.ts New ADF-based parser replacing Markdown parser, preserves all node types
server/providers/combined/tools/shared/ai-prompt-context.ts Centralized AI prompt context conversion with _AIPromptOnly naming
server/providers/combined/tools/writing-shell-stories/figma-screen-setup.ts Updated interface to return ADF fields instead of Markdown
server/providers/combined/tools/writing-shell-stories/core-logic.ts Refactored to use ADF operations and AIPromptContext
server/providers/combined/tools/write-next-story/core-logic.ts Refactored to use ADF operations for epic updates
server/providers/combined/tools/write-next-story/prompt-story-generation.ts Updated to build prompts from parsed fields instead of rawContent
server/providers/combined/tools/shared/screen-analysis-pipeline.ts Updated to use prepareAIPromptContext()
server/providers/atlassian/test-fixtures/adf/*.json Test fixtures for ADF structures with hardBreaks, shell stories
server/providers/combined/tools/shared/shell-story-adf-parser.test.ts Comprehensive test suite for ADF parser (733 lines)
server/providers/atlassian/adf-operations.test.ts Comprehensive test suite for ADF operations (501 lines)
server/providers/atlassian/markdown-converter.test.ts Updated function names in existing tests
Various tool files Updated import statements to use renamed conversion functions

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Comment on lines 416 to 419
epicSansShellStoriesMarkdown,
epicSansShellStoriesAdf,
epicDescriptionAdf: description,
shellStoriesAdf,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

epicContext, epicMarkdown & contentWithoutShellStories needed more explicit names & to clarify if they were markdown or adf. I think another file is misusing epicContent as if it was epicSansScopeAnalysis.

Nitpick:

  • personally I'd use epicSansShellStoriesMD instead of epicSansShellStoriesMarkdown (& epicSansShellStoriesADF), but I kept with repo conventions for now
  • I also think epicContent should be renamed across the entire repo, but for now only renamed it in select files

Comment on lines 416 to 419
epicSansShellStoriesMarkdown,
epicSansShellStoriesAdf,
epicDescriptionAdf: description,
shellStoriesAdf,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes made here:
Image

  • rename epicContent & contentWithoutShellStories for clarity
  • provide epicDescriptionAdf instead of epicMarkdown, so write-next-story can update adf directly & not have data loss
  • provide shellStoriesAdf so write-next-story doesn't have to duplicate the extraction. (shellStoriesAdf is already provided when contentWithoutShellStories are is extracted from the adfDoc)

*/
export function parseShellStories(shellStoriesContent: string): ParsedShellStory[] {
const stories: ParsedShellStory[] = [];
export function parseShellStories(shellStoriesContent: string): ParsedShellStoryMarkdown[] {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@justinbmeyer
parseShellStories & ParsedShellStoryMarkdown are only used in write-next-story-old.ts.
Can we delete that file and delete these?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes

yamlContent,
epicContext,
contentWithoutSection,
epicSansShellStoriesMarkdown: epicContext,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use epicWithoutShellStoriesMarkdown

Without is more comman than Sans outside design contexts

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do. updated in next commit.

That's what I initially had anyways, but then I over-optimized trying to make it shorter. I was also on the fence over it bc Sans isn't as quickly comprehended, didn't know that it was used in design contexts.

* "https://bitovi.atlassian.net/browse/PROJ-123"
* );
*/
export function addCompletionMarkerToStory(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be addCompletionMarkerToShellStory. It's probably good to distinguish between "stories" and "shell stories".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Updated in next commit.

// Convert entire listItem to markdown for AI prompts (preserves original formatting)
const rawContent = convertAdfNodesToMarkdown([listItem]);

return {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is any of this actually needed?

We just needed to know if shell stories exist, I don't believe we needed any of this sort of information.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These were all added for parity with the original function, but you're right that some aren't needed.
I'm removing

  • timestamps
  • included
  • lowPriority
  • excluded
  • questions
    and adding comments to the rest to point out where they're used

…logic

- Deleted `write-next-story-old.ts`
- Renamed variables and properties
- removed unneeded returns from `parseShellStoryFromListItem`

// Validate shellStoriesAdf data structure
if (!Array.isArray(setupResult.shellStoriesAdf)) {
throw new Error(`Epic ${setupResult.epicKey} has invalid shellStoriesAdf data (expected array, got ${typeof setupResult.shellStoriesAdf})`);
Copy link
Member

@justinbmeyer justinbmeyer Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users can see these errors we throw. I'm not sure this would be a good error for them to understand. Under what circumstances could this ever happen?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unneeded & removed. I think this was a holdover from some other refactoring I reverted.

const storyBlocks = shellStoriesContent.split(/\n- /);
// Find the story's listItem
for (const node of newSection) {
if (node.type === 'bulletList' && node.content) {
Copy link
Member

@justinbmeyer justinbmeyer Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there some way to clean up this?

I wonder if there's some sort of JSON-query type library we could bring in that would let us parse this more simply ...

const para = itemContent.find(node => node.type === 'paragraph') ?? null;
if (!para?.content) return null;

for (const textNode of para.content) {
if (textNode.type === 'text' && hasMarkType(textNode, 'code')) {
const match = textNode.text?.match(/^st\d+$/);
if (match) return match[0];
}
}

.find( [
 {type: 'bulletList'}, 
 {type: 'paragraph', index: 0}, 
 {type: 'text'}, {text: /^st\d+$/}] 
)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for each main shell story list item
  check if it has the same id
  if it has the same id
     for each paragraph in the list item
        for nodes in the paragraph
           IF storyId, skip (but mark we found the id)
           IF title, turn node into a link
           IF separator, mark as found
           IF description
               check for `em` for date
               IF no date, add date at the end
        

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findInSequence(p, [{type: 'text', marks: ['cide']}, {type: 'text'}]

forEachWithContent(newSection, {type: 'bulletList'}, (bulletedList) => {
  const id = extractStoryId(listItem.content);
   if (id === storyId) {
     forEachWithContent(listItem.content, {type: 'paragraph'}, (para) => {

        const {title, id, description, timestamp } = findTitleParts(para.content)

        const titleNode = findShellStoryTitleNodeFromParagraph(para.content);
        if(title)  addLinkToNode( title, {url} )
        if(!timestamp){
            para.content.push({
                        type: 'text',
                        text: ' '
                      });
                      para.content.push({
                        type: 'text',
                        text: `(${new Date().toISOString()})`,
                        marks: [{ type: 'em' }]
                      });
        } else{ 
           timestamp.text = `(${new Date().toISOString()})`,
        }
     })
  }
})

function findTitleParts(){
  
}



if (id !== storyId) return;
storyFound = true;
forEachWithContent(listItem.content!, { type: 'paragraph' }, (paragraph) => {
if (!paragraph.content) return;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forEachWithContent already ensures paragraph.content (hence the "WithContent" part of the helper). This check isn't needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed. Added the type-narrowing in forEachWithContent that was missing

}

// Find title parts from a paragraph content: title nodes between ID and separator
function findTitleParts(content: ADFNode[]): { titleNodes: ADFNode[] } {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why doesn't this look more like: const {title, id, description, timestamp } = findTitleParts(para.content) as suggested in the comment?

This way it could be used also by extractStoryId and we can do an "upsert" more or less on the timestamp?

Also, part of the goal was to separate parsing and mutation. appendOrUpdateTimestamp is doing both still now.

This would but

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why doesn't this look more like: const {title, id, description, timestamp } = findTitleParts(para.content) as suggested in the comment?

updated. id & description aren't needed here though, so I left them out. findTitleParts became extractTitleParts & does return storyId & descriptionString, but those are only used in other functions

This way it could be used also by extractStoryId ...

extractStoryId needs to do a depth search, so I kept them separate.

part of the goal was to separate parsing and mutation. appendOrUpdateTimestamp is doing both still now.

resolved by doing them inline like the initial example instead of splitting appendOrUpdateTimestamp

Copy link
Member

@justinbmeyer justinbmeyer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my comments

forEachWithContent(itemContent, { type: 'bulletList' }, (bulletList) => {
forEachWithContent(bulletList.content, { type: 'listItem' }, (listItem) => {
forEachWithContent(listItem.content, { type: 'paragraph' }, (paragraph) => {
const content = paragraph.content ?? [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't forEachWithContent mean paragraph.content will always exist and have one element?

forEachWithContent(itemContent, { type: 'bulletList' }, (bulletList) => {
forEachWithContent(bulletList.content, { type: 'listItem' }, (listItem) => {
forEachWithContent(listItem.content, { type: 'paragraph' }, (paragraph) => {
const content = paragraph.content ?? [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comments as above

@justinbmeyer justinbmeyer merged commit 231899a into main Dec 8, 2025
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants