diff --git a/AGENTS.md b/AGENTS.md index 02e88a8..eee5ec5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -284,6 +284,7 @@ atl confluence page attachment --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 diff --git a/README.md b/README.md index d77265b..b9401b7 100644 --- a/README.md +++ b/README.md @@ -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 `***` | diff --git a/internal/api/jira.go b/internal/api/jira.go index b5d8944..60ab622 100644 --- a/internal/api/jira.go +++ b/internal/api/jira.go @@ -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. @@ -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]" diff --git a/internal/api/markdown.go b/internal/api/markdown.go index c707a01..1f86b12 100644 --- a/internal/api/markdown.go +++ b/internal/api/markdown.go @@ -1,6 +1,8 @@ package api import ( + "crypto/rand" + "fmt" "regexp" "strings" ) @@ -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 ___ @@ -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) @@ -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 @@ -313,7 +325,7 @@ func parseBulletList(lines []string, start int) (ADFContent, int) { break } - if !isBulletListItem(line) { + if !isBulletListItem(line) || isTaskListItem(line) { break } @@ -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) diff --git a/internal/api/markdown_tasklist_test.go b/internal/api/markdown_tasklist_test.go new file mode 100644 index 0000000..af355f3 --- /dev/null +++ b/internal/api/markdown_tasklist_test.go @@ -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) + } +}