Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ atl confluence page attachment <id> --upload a.pdf --upload b.png # Upload mult
The atl CLI accepts Markdown for descriptions AND comments, converting to Atlassian Document Format (ADF):

- Standard Markdown: headings, bold, italic, strikethrough, code, lists, links
- Task lists: `- [ ] open item`, `- [x] done item` — become real Jira action items (clickable checkboxes); their checked state round-trips through view/edit
- Blockquotes: `> text`
- Horizontal rules: `---` or `***` or `___`
- GFM tables: `| Header | Header |` with `|---|---|` separator
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ func main() {
| Code blocks | ` ``` ` with optional language |
| Links | `[text](url)` |
| Bullet lists | `- item` or `* item` |
| Task lists | `- [ ] open` or `- [x] done` |
| Numbered lists | `1. item` |
| Blockquotes | `> quote` |
| Horizontal rules | `---` or `***` |
Expand Down
40 changes: 40 additions & 0 deletions internal/api/jira.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,9 @@ type ADFAttrs struct {
Colspan int `json:"colspan,omitempty"`
Rowspan int `json:"rowspan,omitempty"`
Colwidth []int `json:"colwidth,omitempty"`
// Task list attributes (taskList/taskItem)
LocalID string `json:"localId,omitempty"`
State string `json:"state,omitempty"`
}

// ADFMark represents text marks in ADF.
Expand Down Expand Up @@ -1751,6 +1754,43 @@ func convertNode(c ADFContent) *adf.Node {
}
}

// Handle task lists - the library's Markdown translator does not know
// taskList/taskItem and would silently drop their structure, so render
// them as a bullet list with GFM checkbox markers. This also round-trips:
// MarkdownToADF parses "- [ ]"/"- [x]" back into taskList nodes.
if c.Type == "taskList" {
return &adf.Node{
NodeType: adf.NodeType("bulletList"),
Content: convertNodes(c.Content),
}
}
if c.Type == "taskItem" {
marker := "[ ] "
if c.Attrs != nil && c.Attrs.State == "DONE" {
marker = "[x] "
}
// taskItem holds inline content directly; wrap it in a paragraph so
// the listItem renders like a regular list entry. The marker is
// merged into the leading text node where possible because the
// translator drops trailing spaces of standalone text nodes.
inline := convertNodes(c.Content)
if len(inline) > 0 && inline[0].NodeType == adf.NodeType("text") && len(inline[0].Marks) == 0 {
inline[0].Text = marker + inline[0].Text
} else {
inline = append(
[]*adf.Node{{NodeType: adf.NodeType("text"), NodeValue: adf.NodeValue{Text: marker}}},
inline...,
)
}
return &adf.Node{
NodeType: adf.NodeType("listItem"),
Content: []*adf.Node{{
NodeType: adf.NodeType("paragraph"),
Content: inline,
}},
}
}

// Handle media nodes specially - convert to text with descriptive placeholder
if c.Type == "media" {
altText := "[Embedded image]"
Expand Down
98 changes: 96 additions & 2 deletions internal/api/markdown.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package api

import (
"crypto/rand"
"fmt"
"regexp"
"strings"
)
Expand All @@ -15,6 +17,7 @@ import (
// - Code blocks: ```language\ncode\n```
// - Links: [text](url)
// - Bullet lists: - item or * item
// - Task lists: - [ ] open item, - [x] done item
// - Numbered lists: 1. item
// - Blockquotes: > text
// - Horizontal rules: --- or *** or ___
Expand Down Expand Up @@ -119,6 +122,15 @@ func parseBlocks(lines []string) []ADFContent {
continue
}

// Task list (must be checked before bullet list, since "- [ ]" is
// also a valid bullet item prefix)
if isTaskListItem(line) {
block, consumed := parseTaskList(lines, i)
content = append(content, block)
i += consumed
continue
}

// Bullet list
if isBulletListItem(line) {
block, consumed := parseBulletList(lines, i)
Expand Down Expand Up @@ -299,7 +311,7 @@ func parseBulletList(lines []string, start int) (ADFContent, int) {
for j < len(lines) && strings.TrimSpace(lines[j]) == "" {
j++
}
if j >= len(lines) || !isBulletListItem(lines[j]) {
if j >= len(lines) || !isBulletListItem(lines[j]) || isTaskListItem(lines[j]) {
break
}
i = j
Expand All @@ -313,7 +325,7 @@ func parseBulletList(lines []string, start int) (ADFContent, int) {
break
}

if !isBulletListItem(line) {
if !isBulletListItem(line) || isTaskListItem(line) {
break
}

Expand Down Expand Up @@ -346,6 +358,88 @@ func parseBulletList(lines []string, start int) (ADFContent, int) {
}, i - start
}

// taskListItemPattern matches GFM task list items like "- [ ] text" or
// "* [x] text" and captures the state marker and the item text.
var taskListItemPattern = regexp.MustCompile(`^[-*+] \[([ xX])\] (.*)$`)

// isTaskListItem checks if a line is a task list item (- [ ] or - [x]).
func isTaskListItem(line string) bool {
return taskListItemPattern.MatchString(strings.TrimSpace(line))
}

// parseTaskList parses a GFM task list into an ADF taskList node. Task items
// carry their text as inline content directly (no paragraph wrapper), a
// TODO/DONE state derived from the [ ]/[x] marker, and a generated localId,
// which Jira requires on taskList and taskItem nodes.
func parseTaskList(lines []string, start int) (ADFContent, int) {
var items []ADFContent
i := start
baseIndent := countLeadingSpaces(lines[start])

for i < len(lines) {
line := lines[i]

// Empty line might end the list
if strings.TrimSpace(line) == "" {
// Check if next non-empty line continues the list
j := i + 1
for j < len(lines) && strings.TrimSpace(lines[j]) == "" {
j++
}
if j >= len(lines) || !isTaskListItem(lines[j]) {
break
}
i = j
continue
}

indent := countLeadingSpaces(line)

// If less indented than base, we're done
if indent < baseIndent && i > start {
break
}

matches := taskListItemPattern.FindStringSubmatch(strings.TrimSpace(line))
if matches == nil {
break
}

state := "TODO"
if matches[1] == "x" || matches[1] == "X" {
state = "DONE"
}

items = append(items, ADFContent{
Type: "taskItem",
Attrs: &ADFAttrs{LocalID: newLocalID(), State: state},
Content: parseInline(matches[2]),
})
i++
}

return ADFContent{
Type: "taskList",
Attrs: &ADFAttrs{LocalID: newLocalID()},
Content: items,
}, i - start
}

// newLocalID generates a random identifier for ADF nodes that require a
// localId attribute (taskList, taskItem).
func newLocalID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// crypto/rand never fails on supported platforms; fall back to a
// constant rather than propagating an error through the parser.
return "00000000-0000-0000-0000-000000000000"
}
// RFC 4122 version 4 layout, matching the ids the Jira editor generates.
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}

// isOrderedListItem checks if a line is an ordered list item.
func isOrderedListItem(line string) bool {
trimmed := strings.TrimSpace(line)
Expand Down
170 changes: 170 additions & 0 deletions internal/api/markdown_tasklist_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package api

import (
"strings"
"testing"
)

func TestMarkdownToADF_TaskList(t *testing.T) {
adf := MarkdownToADF("- [ ] Open item\n- [x] Done item\n- [X] Also done")

if len(adf.Content) != 1 {
t.Fatalf("expected 1 content block, got %d", len(adf.Content))
}

list := adf.Content[0]
if list.Type != "taskList" {
t.Fatalf("expected taskList, got %q", list.Type)
}
if list.Attrs == nil || list.Attrs.LocalID == "" {
t.Error("expected taskList to carry a localId")
}
if len(list.Content) != 3 {
t.Fatalf("expected 3 task items, got %d", len(list.Content))
}

wantStates := []string{"TODO", "DONE", "DONE"}
wantTexts := []string{"Open item", "Done item", "Also done"}
seenIDs := map[string]bool{}
for i, item := range list.Content {
if item.Type != "taskItem" {
t.Errorf("item %d: expected taskItem, got %q", i, item.Type)
}
if item.Attrs == nil {
t.Fatalf("item %d: expected attrs", i)
}
if item.Attrs.State != wantStates[i] {
t.Errorf("item %d: expected state %q, got %q", i, wantStates[i], item.Attrs.State)
}
if item.Attrs.LocalID == "" {
t.Errorf("item %d: expected a localId", i)
}
if seenIDs[item.Attrs.LocalID] {
t.Errorf("item %d: localId %q is not unique", i, item.Attrs.LocalID)
}
seenIDs[item.Attrs.LocalID] = true
if len(item.Content) != 1 || item.Content[0].Type != "text" {
t.Fatalf("item %d: expected inline text content, got %+v", i, item.Content)
}
if item.Content[0].Text != wantTexts[i] {
t.Errorf("item %d: expected text %q, got %q", i, wantTexts[i], item.Content[0].Text)
}
}
}

func TestMarkdownToADF_TaskListInlineFormatting(t *testing.T) {
adf := MarkdownToADF("- [ ] A1 – check **bold** and `code`")

list := adf.Content[0]
if list.Type != "taskList" {
t.Fatalf("expected taskList, got %q", list.Type)
}

item := list.Content[0]
if len(item.Content) < 3 {
t.Fatalf("expected inline nodes with marks, got %+v", item.Content)
}

var hasStrong, hasCode bool
for _, n := range item.Content {
for _, m := range n.Marks {
if m.Type == "strong" {
hasStrong = true
}
if m.Type == "code" {
hasCode = true
}
}
}
if !hasStrong || !hasCode {
t.Errorf("expected strong and code marks, got strong=%v code=%v", hasStrong, hasCode)
}
}

func TestMarkdownToADF_TaskListDoesNotSwallowBulletList(t *testing.T) {
adf := MarkdownToADF("- plain bullet\n- [ ] task item")

if len(adf.Content) != 2 {
t.Fatalf("expected 2 content blocks, got %d", len(adf.Content))
}
if adf.Content[0].Type != "bulletList" {
t.Errorf("expected first block bulletList, got %q", adf.Content[0].Type)
}
if adf.Content[1].Type != "taskList" {
t.Errorf("expected second block taskList, got %q", adf.Content[1].Type)
}
}

func TestMarkdownToADF_BulletListDoesNotSwallowTaskList(t *testing.T) {
adf := MarkdownToADF("- [ ] task item\n- plain bullet")

if len(adf.Content) != 2 {
t.Fatalf("expected 2 content blocks, got %d", len(adf.Content))
}
if adf.Content[0].Type != "taskList" {
t.Errorf("expected first block taskList, got %q", adf.Content[0].Type)
}
if adf.Content[1].Type != "bulletList" {
t.Errorf("expected second block bulletList, got %q", adf.Content[1].Type)
}
}

func TestADFToText_TaskList(t *testing.T) {
doc := &ADF{
Type: "doc",
Version: 1,
Content: []ADFContent{
{
Type: "taskList",
Attrs: &ADFAttrs{LocalID: "list-1"},
Content: []ADFContent{
{
Type: "taskItem",
Attrs: &ADFAttrs{LocalID: "item-1", State: "TODO"},
Content: []ADFContent{{Type: "text", Text: "Open item"}},
},
{
Type: "taskItem",
Attrs: &ADFAttrs{LocalID: "item-2", State: "DONE"},
Content: []ADFContent{{Type: "text", Text: "Done item"}},
},
},
},
},
}

text := ADFToText(doc)

if !strings.Contains(text, "[ ] Open item") {
t.Errorf("expected open checkbox marker in output, got %q", text)
}
if !strings.Contains(text, "[x] Done item") {
t.Errorf("expected done checkbox marker in output, got %q", text)
}
if strings.Contains(text, "Open itemDone item") {
t.Errorf("task items were flattened together: %q", text)
}
}

func TestTaskList_RoundTrip(t *testing.T) {
src := "- [ ] First\n- [x] Second"

text := ADFToText(MarkdownToADF(src))

if !strings.Contains(text, "[ ] First") || !strings.Contains(text, "[x] Second") {
t.Errorf("round-trip lost checkbox markers: %q", text)
}

// The rendered text must parse back into a task list with states intact.
again := MarkdownToADF(text)
if len(again.Content) != 1 || again.Content[0].Type != "taskList" {
t.Fatalf("re-parse: expected a single taskList, got %+v", again.Content)
}
items := again.Content[0].Content
if len(items) != 2 {
t.Fatalf("re-parse: expected 2 items, got %d", len(items))
}
if items[0].Attrs.State != "TODO" || items[1].Attrs.State != "DONE" {
t.Errorf("re-parse: states lost, got %q and %q", items[0].Attrs.State, items[1].Attrs.State)
}
}
Loading