diff --git a/go.mod b/go.mod index 44b227dc44..552ed2e49b 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/knights-analytics/hugot v0.7.7 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + github.com/yuin/goldmark v1.8.5 go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 go.opentelemetry.io/otel/sdk v1.44.0 diff --git a/go.sum b/go.sum index 06a75f899d..0d877422cd 100644 --- a/go.sum +++ b/go.sum @@ -161,6 +161,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yalue/onnxruntime_go v1.32.0 h1:O4pPw3IT+46CRrfuT0lcHWkczVoFvtfq6kMAO/iIVKc= github.com/yalue/onnxruntime_go v1.32.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4= +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= diff --git a/internal/forge/jira/adf.go b/internal/forge/jira/adf.go new file mode 100644 index 0000000000..eeec3d10ee --- /dev/null +++ b/internal/forge/jira/adf.go @@ -0,0 +1,1057 @@ +package jira + +import ( + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// MarkdownToADF parses source as CommonMark and returns an Atlassian +// Document Format (ADF) "doc" node, since Jira's comment/description +// fields don't accept markdown directly. It supports the block/inline +// vocabulary ADFToPlainText (and the read-side walker it mirrors in +// internal/jirapoll) already recognizes: paragraphs, headings, +// bullet/ordered lists, blockquotes, fenced/indented code blocks, +// thematic breaks, and the strong/em/code/link/hardBreak inline marks. +// Block-level constructs outside that vocabulary (raw HTML, tables, ...) +// fall back to a plain-text paragraph of their raw source rather than +// vanishing outright — ADF has no equivalent node for most of them, but +// dropping the content entirely would silently lose whatever a caller +// wrote (e.g. this repo's own
convention for collapsing output). +// +// Returns an error, rather than a partial or empty result, if source is +// over maxMarkdownParseBytes or if it converts to no ADF content at all +// (e.g. markdown that's nothing but a chain of nested blockquotes deeper +// than maxADFWriteDepth): callers write the result straight to Jira, and +// silently posting a truncated or visibly empty comment is worse than +// failing the write. +func MarkdownToADF(source string) (map[string]any, error) { + src := []byte(source) + if len(src) > maxMarkdownParseBytes { + return nil, fmt.Errorf("markdown body is %d bytes, over the %d byte limit", len(src), maxMarkdownParseBytes) + } + doc := goldmark.DefaultParser().Parse(text.NewReader(src)) + content := adfBlockContent(doc, src, 0, false) + if len(content) == 0 { + return nil, fmt.Errorf("markdown body converted to no ADF content") + } + return map[string]any{ + "version": 1, + "type": "doc", + "content": content, + }, nil +} + +// maxADFWriteDepth caps how deep adfBlockContent/convertBlockNode/ +// walkInline will recurse into parsed markdown, mirroring maxADFDepth's +// rationale on the read side below. MarkdownToADF's callers don't feed it +// attacker-controlled input today, but it ships as a public API +// (jira.MarkdownToADF, LiveClient.CreateComment/UpdateComment) that a +// future caller could feed external content into (e.g. quoting an issue +// or comment body) — the same unbounded-recursion risk applies once that +// happens. +const maxADFWriteDepth = 50 + +// maxMarkdownParseBytes caps the input size MarkdownToADF will parse. +// maxADFWriteDepth above only bounds the post-parse AST walk; Parse() +// itself is the actual bottleneck for adversarial input — benchmarking +// showed it costs ~O(N^2) on deeply nested blockquotes (~3.2s to parse +// 80,000 nesting levels, i.e. 160KB), independent of and unreached by the +// walk-depth cap. Rejecting oversized input outright keeps worst-case +// parse time to roughly a few hundred milliseconds even for +// pathologically nested input, while comfortably fitting any +// realistically hand-written comment or issue description; MarkdownToADF +// documents the limit for callers that may feed it larger, +// machine-generated bodies. +const maxMarkdownParseBytes = 32 * 1024 + +// adfBlockContent converts each block-level child of parent into zero or +// more ADF block nodes (a child can expand to several siblings, e.g. a +// flattened nested blockquote, or to none, e.g. a dropped thematic break). +// depth is the nesting depth of parent's children; past maxADFWriteDepth, +// remaining content is dropped rather than descended into. restricted +// indicates parent is itself a blockquote or listItem, whose ADF schema +// only accepts a narrower set of block types (paragraph, list, codeBlock) +// than convertBlockNode emits by default — see convertBlockNode. +func adfBlockContent(parent ast.Node, source []byte, depth int, restricted bool) []any { + content := []any{} + if depth > maxADFWriteDepth { + return content + } + for c := parent.FirstChild(); c != nil; c = c.NextSibling() { + content = append(content, convertBlockNode(c, source, depth, restricted)...) + } + return content +} + +// convertBlockNode converts a single goldmark block node to zero or more +// ADF nodes: normally one, but zero for a dropped or empty node, or +// several when a nested blockquote is flattened into its parent's +// content. depth is n's own nesting depth. restricted indicates n is a +// direct child of a blockquote or listItem: ADF restricts both to +// paragraph/bulletList/orderedList/codeBlock/media content, so heading, +// thematic break, and nested blockquote — all valid at the top level or +// inside a list item's ordinary flow — must be degraded rather than +// emitted as-is, or Jira Cloud rejects the whole write with a 400. +func convertBlockNode(n ast.Node, source []byte, depth int, restricted bool) []any { + switch v := n.(type) { + case *ast.Paragraph: + content := adfInlineContent(n, source, depth, nil) + if len(content) == 0 { + return nil + } + return oneNode(map[string]any{"type": "paragraph", "content": content}) + case *ast.TextBlock: + // Tight list items wrap their text in a TextBlock rather than a + // Paragraph; ADF's listItem schema still expects a paragraph child. + content := adfInlineContent(n, source, depth, nil) + if len(content) == 0 { + return nil + } + return oneNode(map[string]any{"type": "paragraph", "content": content}) + case *ast.Heading: + if restricted { + // Degrade to a bold paragraph: ADF's blockquote/listItem + // schema has no heading node. + marks := []any{map[string]any{"type": "strong"}} + return oneNode(map[string]any{"type": "paragraph", "content": adfInlineContent(n, source, depth, marks)}) + } + return oneNode(map[string]any{ + "type": "heading", + "attrs": map[string]any{"level": v.Level}, + "content": adfInlineContent(n, source, depth, nil), + }) + case *ast.ThematicBreak: + if restricted { + // ADF's blockquote/listItem schema has no rule node; there's + // no reasonable degradation, so it's dropped. + return nil + } + return oneNode(map[string]any{"type": "rule"}) + case *ast.Blockquote: + children := adfBlockContent(n, source, depth+1, true) + if restricted { + // ADF's blockquote schema doesn't allow a nested blockquote; + // flatten by splicing this one's children directly into the + // parent's content instead of wrapping them. + return children + } + return containerNode("blockquote", children, nil) + case *ast.CodeBlock: + return oneNode(codeBlockNode(v.Lines().Value(source), "")) + case *ast.FencedCodeBlock: + return oneNode(codeBlockNode(v.Lines().Value(source), string(v.Language(source)))) + case *ast.List: + listType := "bulletList" + var attrs map[string]any + if v.IsOrdered() { + listType = "orderedList" + if v.Start != 1 { + attrs = map[string]any{"order": v.Start} + } + } + // A List's own children are always ListItems, which are valid in + // any context, so restricted doesn't propagate here — only into + // each ListItem's own content below. + return containerNode(listType, adfBlockContent(n, source, depth+1, false), attrs) + case *ast.ListItem: + return containerNode("listItem", adfBlockContent(n, source, depth+1, true), nil) + default: + // Node types without ADF-specific handling (e.g. HTMLBlock) fall + // back to a plain-text paragraph of their raw source, mirroring + // walkInline's own default case, so at least the readable content + // isn't lost outright. + if node := fallbackTextNode(n, source); node != nil { + return oneNode(node) + } + return nil + } +} + +// oneNode wraps a single ADF node for convertBlockNode's []any return +// type. +func oneNode(node map[string]any) []any { + return []any{node} +} + +// containerNode builds an ADF container node (blockquote, bulletList, +// orderedList, listItem) of the given type, or returns no nodes at all if +// content is empty: ADF requires these types to have at least one child +// (minItems: 1), and an empty one — e.g. from a maxADFWriteDepth cutoff, +// or a listItem whose only child was dropped — would make Jira reject the +// entire write with a 400. Dropping the container is preferable to +// inserting a placeholder, since it doesn't fabricate visible content the +// original markdown never had. +func containerNode(nodeType string, content []any, attrs map[string]any) []any { + if len(content) == 0 { + return nil + } + node := map[string]any{"type": nodeType, "content": content} + if attrs != nil { + node["attrs"] = attrs + } + return oneNode(node) +} + +// fallbackTextNode renders a block node's raw source lines as a plain-text +// paragraph, for block kinds convertBlockNode has no dedicated handling +// for. Returns nil if the node exposes no raw lines or they're blank. +func fallbackTextNode(n ast.Node, source []byte) map[string]any { + lines, ok := n.(interface{ Lines() *text.Segments }) + if !ok { + return nil + } + raw := strings.TrimSpace(string(lines.Lines().Value(source))) + if raw == "" { + return nil + } + return map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": raw}}} +} + +// codeBlockNode builds an ADF codeBlock node. lang is set as the +// "language" attr only when non-empty (indented code blocks have none). +// The text child is omitted entirely for an empty block: ADF requires +// text nodes to be non-empty, but codeBlock content itself is optional, so +// a bare {"type":"codeBlock"} is the valid way to represent one. +func codeBlockNode(text []byte, lang string) map[string]any { + node := map[string]any{"type": "codeBlock"} + if trimmed := strings.TrimRight(string(text), "\n"); trimmed != "" { + node["content"] = []any{map[string]any{"type": "text", "text": trimmed}} + } + if lang != "" { + node["attrs"] = map[string]any{"language": lang} + } + return node +} + +// adfInlineContent converts the inline children of a block node (paragraph +// or heading) into a flat sequence of ADF text/hardBreak nodes. depth is +// the block node's own nesting depth, threaded through to walkInline since +// inline marks (nested emphasis/links/code spans) recurse too. marks seeds +// the mark set every emitted text node starts with — nil normally, or e.g. +// a "strong" mark when a heading is being degraded to a bold paragraph +// inside a blockquote/listItem. +func adfInlineContent(parent ast.Node, source []byte, depth int, marks []any) []any { + content := []any{} + walkInline(parent, source, marks, &content, depth) + return content +} + +// walkInline recursively walks inline nodes, accumulating the marks +// (strong/em/code/link) implied by enclosing nodes and emitting a text (or +// hardBreak) ADF node for each leaf. Past maxADFWriteDepth, remaining +// content is dropped rather than descended into. +func walkInline(parent ast.Node, source []byte, marks []any, out *[]any, depth int) { + if depth > maxADFWriteDepth { + return + } + for c := parent.FirstChild(); c != nil; c = c.NextSibling() { + switch v := c.(type) { + case *ast.Text: + appendADFText(out, textValue(v, source), marks) + if v.HardLineBreak() { + *out = append(*out, map[string]any{"type": "hardBreak"}) + } else if v.SoftLineBreak() { + appendADFText(out, " ", marks) + } + case *ast.String: + appendADFText(out, string(v.Value), marks) + case *ast.CodeSpan: + // ADF's code mark can only combine with link, per Atlassian's + // mark-combination rules; drop any inherited strong/em (or + // other) marks rather than emitting a schema-invalid + // [strong, code] pair for input like "**`--flag`**". + walkInline(v, source, withMark(onlyLinkMarks(marks), map[string]any{"type": "code"}), out, depth+1) + case *ast.Emphasis: + markType := "em" + if v.Level >= 2 { + markType = "strong" + } + walkInline(v, source, withMark(marks, map[string]any{"type": markType}), out, depth+1) + case *ast.Link: + dest := string(v.Destination) + linkMarks := marks + if isSafeHref(dest) { + attrs := map[string]any{"href": dest} + if len(v.Title) > 0 { + attrs["title"] = string(v.Title) + } + linkMarks = withMark(marks, map[string]any{"type": "link", "attrs": attrs}) + } + walkInline(v, source, linkMarks, out, depth+1) + case *ast.AutoLink: + dest := string(v.URL(source)) + linkMarks := marks + if isSafeHref(dest) { + linkMarks = withMark(marks, map[string]any{ + "type": "link", "attrs": map[string]any{"href": dest}, + }) + } + appendADFText(out, string(v.Label(source)), linkMarks) + case *ast.Image: + // ADF has no plain image node outside "media", which needs + // an uploaded attachment id rather than a bare URL — so the + // destination, the load-bearing part of an image, is + // preserved as a link on the alt text instead of being + // dropped like the generic default case would (which walks + // an Image's children as plain text and never looks at its + // Destination). + dest := string(v.Destination) + linkMarks := marks + if isSafeHref(dest) { + linkMarks = withMark(marks, map[string]any{ + "type": "link", "attrs": map[string]any{"href": dest}, + }) + } + walkInline(v, source, linkMarks, out, depth+1) + default: + // Node types without ADF-specific handling (e.g. Image, + // RawHTML) fall through to walking their children as plain + // text, so at least the readable content isn't lost. + walkInline(c, source, marks, out, depth+1) + } + } +} + +// isSafeHref reports whether dest is safe to emit as an ADF link's href. +// A missing scheme with no host (relative paths, "#fragment" anchors) is +// allowed, as are http/https/mailto; anything else — javascript:, data:, +// vbscript:, file:, protocol-relative "//host", ... — is rejected, +// mirroring the scheme allowlisting ValidateBaseURL already applies to +// Jira base URLs elsewhere in this package. dest that fails to parse as a +// URL at all is rejected too. +func isSafeHref(dest string) bool { + if dest == "" { + return true + } + u, err := url.Parse(dest) + if err != nil { + return false + } + switch strings.ToLower(u.Scheme) { + case "": + return u.Host == "" + case "http", "https", "mailto": + return true + default: + return false + } +} + +// textValue returns the plain-text value of an *ast.Text node, resolving +// backslash escapes and HTML entity/numeric character references the same +// way goldmark's own HTML renderer does (via v.Value, which is raw source +// bytes and does neither). Raw text — inside a code span or code block — +// is returned unresolved, since goldmark's renderer leaves that content +// verbatim too: "\*not em\*" outside code becomes "*not em*", but +// "`\*x\*`" keeps its backslashes. +func textValue(v *ast.Text, source []byte) string { + value := v.Value(source) + if v.IsRaw() { + return string(value) + } + return string(resolveTextEscapes(value)) +} + +// resolveTextEscapes resolves backslash-escaped punctuation and HTML +// entity/numeric character references in a single left-to-right pass, the +// same way goldmark's HTML renderer (defaultWriter.Write) does. A single +// pass is required: resolving punctuation escapes and entity references as +// two separate passes — the way util.UnescapePunctuations followed by +// util.ResolveEntityNames does — would unescape "\©" to "©" +// first and then misread that as the entity reference "©", even +// though CommonMark's backslash-escape rule says the escaped "&" should +// stay literal and shield the following text from entity parsing. +func resolveTextEscapes(source []byte) []byte { + var out []byte + escaped := false + limit := len(source) + var ok bool + n := 0 + for i := 0; i < limit; i++ { + c := source[i] + if escaped { + if util.IsPunct(c) { + out = append(out, source[n:i-1]...) + n = i + escaped = false + continue + } + escaped = false + } + if c == '&' { + pos := i + next := i + 1 + if next < limit && source[next] == '#' { + nnext := next + 1 + if nnext < limit { + nc := source[nnext] + if nc == 'x' || nc == 'X' { + start := nnext + 1 + i, ok = util.ReadWhile(source, [2]int{start, limit}, util.IsHexDecimal) + if ok && i < limit && source[i] == ';' { + v, _ := strconv.ParseUint(util.BytesToReadOnlyString(source[start:i]), 16, 32) + out = append(out, source[n:pos]...) + n = i + 1 + out = utf8.AppendRune(out, util.ToValidRune(rune(v))) + continue + } + } else if nc >= '0' && nc <= '9' { + start := nnext + i, ok = util.ReadWhile(source, [2]int{start, limit}, util.IsNumeric) + if ok && i < limit && i-start < 8 && source[i] == ';' { + v, _ := strconv.ParseUint(util.BytesToReadOnlyString(source[start:i]), 10, 32) + out = append(out, source[n:pos]...) + n = i + 1 + out = utf8.AppendRune(out, util.ToValidRune(rune(v))) + continue + } + } + } + } else { + start := next + i, ok = util.ReadWhile(source, [2]int{start, limit}, util.IsAlphaNumeric) + if ok && i < limit && source[i] == ';' { + name := util.BytesToReadOnlyString(source[start:i]) + if entity, ok2 := util.LookUpHTML5EntityByName(name); ok2 { + out = append(out, source[n:pos]...) + n = i + 1 + out = append(out, entity.Characters...) + continue + } + } + } + i = next - 1 + } + if c == '\\' { + escaped = true + } + } + out = append(out, source[n:]...) + return out +} + +// withMark returns a new marks slice with mark appended, without mutating +// the caller's slice (siblings must not see marks added while walking a +// previous sibling's subtree). If marks already contains a mark of the +// same type — e.g. nested same-delimiter emphasis like +// "*outer _inner_ text*", which produces two nested *ast.Emphasis nodes +// with the same "em" markType — marks is returned unchanged rather than +// growing a duplicate: a text node with two identical marks is never +// meaningful, and ADF's validator behavior for it is unconfirmed. +// +// A same-type "link" mark is the one exception: unlike em/strong, two +// link marks can carry different hrefs — goldmark parses an autolink +// nested inside a Markdown link (e.g. "[a b](outer-url)") as +// a Link wrapping an AutoLink, even though CommonMark itself refuses +// nested links — so the existing link mark is replaced in place by the +// new (inner) one rather than treated as a redundant duplicate, matching +// CommonMark's inner-most-wins resolution for nested constructs. +func withMark(marks []any, mark map[string]any) []any { + markType, _ := mark["type"].(string) + for i, m := range marks { + existing, ok := m.(map[string]any) + if !ok || existing["type"] != markType { + continue + } + if markType != "link" { + return marks + } + next := make([]any, len(marks)) + copy(next, marks) + next[i] = mark + return next + } + next := make([]any, len(marks), len(marks)+1) + copy(next, marks) + return append(next, mark) +} + +// onlyLinkMarks filters marks down to just the "link" mark (if present), +// for use before appending a "code" mark: ADF documents that code can only +// be combined with link, not with strong/em or other marks. +func onlyLinkMarks(marks []any) []any { + var kept []any + for _, m := range marks { + if mark, ok := m.(map[string]any); ok && mark["type"] == "link" { + kept = append(kept, m) + } + } + return kept +} + +// appendADFText appends a "text" ADF node, or does nothing for empty text +// (e.g. the zero-length segment goldmark can produce around a hard break). +func appendADFText(out *[]any, value string, marks []any) { + if value == "" { + return + } + node := map[string]any{"type": "text", "text": value} + if len(marks) > 0 { + node["marks"] = marks + } + *out = append(*out, node) +} + +// maxADFDepth caps how deep walkADFNode will recurse into an issue or +// comment body. Real Jira-UI-authored ADF documents are shallow (a +// handful of levels for nested lists at most); a body is +// attacker-controlled by any Jira user who can comment on or edit an +// issue tracker.Client reads, so without a cap, deeply nested JSON could +// exhaust the goroutine stack. Mirrors jirapoll's identical cap and logic +// (see extractADFText/walkADFNode in internal/jirapoll/discover.go) — the +// duplication is intentional for now, to avoid refactoring jirapoll's +// private helpers as part of this package's feature work; consolidating +// onto this exported ADFToPlainText is a reasonable follow-up once the +// Jira tracker integration stabilizes. +const maxADFDepth = 50 + +// ADFToPlainText extracts plain text from a Jira issue or comment body. +// body is either a plain string or an ADF document (map[string]any), +// matching the two shapes Jira's REST API returns for description/body +// fields; any other type (including nil) yields "". +func ADFToPlainText(body any) string { + switch v := body.(type) { + case string: + return v + case map[string]any: + var sb strings.Builder + walkADFNode(v, &sb, 0) + return sb.String() + default: + return "" + } +} + +// walkADFNode recursively walks ADF nodes, extracting text, up to +// maxADFDepth levels deep. +func walkADFNode(node map[string]any, sb *strings.Builder, depth int) { + if depth > maxADFDepth { + return + } + + // A hardBreak (Shift+Enter in the Jira editor) carries no text or + // content; without emitting a newline here, words the author placed on + // separate visual lines within one paragraph would fuse together. + if nodeType, _ := node["type"].(string); nodeType == "hardBreak" { + sb.WriteString("\n") + return + } + + if text, ok := node["text"].(string); ok { + sb.WriteString(text) + } + + nodeType, _ := node["type"].(string) + + content, ok := node["content"].([]any) + if !ok { + return + } + + for i, child := range content { + childMap, ok := child.(map[string]any) + if !ok { + continue + } + walkADFNode(childMap, sb, depth+1) + + // Add newline after paragraph/heading blocks (except the last one). + childType, _ := childMap["type"].(string) + if i < len(content)-1 && isBlockType(childType) && isBlockType(nodeType) { + sb.WriteString("\n") + } + } +} + +// isBlockType returns true for ADF block-level types that should be +// separated by newlines. +func isBlockType(nodeType string) bool { + switch nodeType { + case "doc", "paragraph", "heading", "blockquote", "codeBlock", + "bulletList", "orderedList", "listItem", "panel", "rule": + return true + default: + return false + } +} + +// ADFToMarkdown converts a Jira issue or comment body to CommonMark +// Markdown, the reverse of MarkdownToADF. body is either a plain string or +// an ADF document (map[string]any), matching the two shapes Jira's REST +// API returns for description/body fields; any other type (including nil) +// yields "". +// +// Unlike ADFToPlainText, this preserves formatting (bold/italic/code/ +// links, headings, lists, blockquotes, code blocks) rather than +// discarding it: tracker.Body is documented as Markdown-formatted text, +// so a Jira-backed tracker.Client returning plain text there would +// silently drop content GitHub- and GitLab-backed implementations +// preserve. +func ADFToMarkdown(body any) string { + switch v := body.(type) { + case string: + return v + case map[string]any: + // A real Jira description/comment body is always a "doc" node + // whose own content is a list of sibling blocks. Some callers + // (and this package's own tests, mirroring ADFToPlainText's) + // instead pass a single block node — a bare paragraph or list — + // directly; render that as one block rather than misreading its + // inline/item content as if it were a list of sibling blocks. + if nodeType, _ := v["type"].(string); isBlockType(nodeType) && nodeType != "doc" { + return adfMarkdownBlock(v, 0) + } + return strings.Join(adfMarkdownBlocks(v, 0), "\n\n") + default: + return "" + } +} + +// adfMarkdownBlocks renders each block-level child of node into its own +// Markdown string, mirroring walkADFNode's traversal but block-aware: +// callers join siblings with a blank line rather than a single newline, +// since that's what separates Markdown blocks (a single newline reads +// back as one paragraph). depth mirrors walkADFNode's cap against +// attacker-controlled ADF bodies. +func adfMarkdownBlocks(node map[string]any, depth int) []string { + if depth > maxADFDepth { + return nil + } + content, ok := node["content"].([]any) + if !ok { + return nil + } + var blocks []string + for _, c := range content { + childMap, ok := c.(map[string]any) + if !ok { + continue + } + if block := adfMarkdownBlock(childMap, depth+1); block != "" { + blocks = append(blocks, block) + } + } + return blocks +} + +// adfMarkdownBlock renders a single ADF block-level node as Markdown. +// Unrecognized types (e.g. "panel", "table", "expand", "taskList") fall +// back to recursing into their block-level children (e.g. a panel's +// paragraphs, a table's rows) so content isn't silently dropped; if that +// yields nothing, e.g. a taskItem whose own children are inline text +// nodes rather than blocks, it falls back further to rendering any +// direct inline text content flat, mirroring MarkdownToADF's own +// fallback-to-plain-text convention. +func adfMarkdownBlock(node map[string]any, depth int) string { + nodeType, _ := node["type"].(string) + switch nodeType { + case "paragraph": + return adfMarkdownInline(node) + case "heading": + level := 1 + if attrs, ok := node["attrs"].(map[string]any); ok { + if l, ok := attrs["level"].(int); ok { + level = l + } else if l, ok := attrs["level"].(float64); ok { + level = int(l) + } + } + // The ADF schema restricts heading level to 1-6, but a + // malformed or malicious body could carry anything; + // strings.Repeat panics on a negative count, and 0 or >6 + // wouldn't round-trip as a heading anyway. + level = clampInt(level, 1, 6) + return strings.Repeat("#", level) + " " + adfMarkdownInline(node) + case "codeBlock": + lang := "" + if attrs, ok := node["attrs"].(map[string]any); ok { + if l, ok := attrs["language"].(string); ok { + lang = sanitizeCodeLanguage(l) + } + } + text := adfCodeBlockText(node) + fence := strings.Repeat("`", codeFenceLength(text)) + if text == "" { + return fence + lang + "\n" + fence + } + return fence + lang + "\n" + text + "\n" + fence + case "rule": + return "---" + case "blockquote": + lines := strings.Split(strings.Join(adfMarkdownBlocks(node, depth), "\n\n"), "\n") + for i, line := range lines { + lines[i] = "> " + line + } + return strings.Join(lines, "\n") + case "bulletList": + return adfMarkdownList(node, depth, func(int) string { return "- " }) + case "orderedList": + start := 1 + if attrs, ok := node["attrs"].(map[string]any); ok { + if o, ok := attrs["order"].(int); ok { + start = o + } else if o, ok := attrs["order"].(float64); ok { + start = int(o) + } + } + // CommonMark ordered-list start numbers are bounded to + // [0, 999999999]; clamp so a malformed order attr can't + // produce a marker a Markdown parser would reject. + start = clampInt(start, 0, 999999999) + return adfMarkdownList(node, depth, func(i int) string { return fmt.Sprintf("%d. ", start+i) }) + default: + if blocks := adfMarkdownBlocks(node, depth); len(blocks) > 0 { + return strings.Join(blocks, "\n\n") + } + return adfMarkdownInline(node) + } +} + +// codeFenceLength returns the number of backticks to use for a codeBlock's +// Markdown fence: at least 3, and always more than the longest run of +// consecutive backticks in content, so a quoted fenced code block inside +// content can't be mistaken for this block's own closing fence — per +// CommonMark, a closing fence needs only be *at least as many* backticks +// as the opening one. +func codeFenceLength(content string) int { + longest, current := 0, 0 + for _, r := range content { + if r == '`' { + current++ + if current > longest { + longest = current + } + } else { + current = 0 + } + } + return max(longest+1, 3) +} + +// codeLanguagePattern matches the conservative set of characters legal in +// Markdown fence-line language identifiers (word characters plus a few +// symbols common in real language names like "c++" and "c#"). +var codeLanguagePattern = regexp.MustCompile(`^[A-Za-z0-9+#._-]+$`) + +// sanitizeCodeLanguage returns lang unchanged if it matches +// codeLanguagePattern, or "" otherwise. ADF's codeBlock attrs.language is +// an unconstrained string spliced directly into the fence line; a value +// containing a newline and its own fence could inject arbitrary block +// structure into the rendered Markdown. +func sanitizeCodeLanguage(lang string) string { + if codeLanguagePattern.MatchString(lang) { + return lang + } + return "" +} + +// adfCodeBlockText concatenates a codeBlock node's text children verbatim +// (no marks are valid inside an ADF codeBlock). +func adfCodeBlockText(node map[string]any) string { + var sb strings.Builder + for _, c := range asADFNodes(node["content"]) { + if text, ok := c["text"].(string); ok { + sb.WriteString(text) + } + } + return sb.String() +} + +// adfMarkdownList renders a bulletList or orderedList's items, each +// prefixed by marker(i) for its zero-based index. A listItem's own block +// content is rendered with adfMarkdownBlocks and indented so continuation +// lines (a second paragraph, or a nested list) stay part of the same item. +func adfMarkdownList(node map[string]any, depth int, marker func(i int) string) string { + items := asADFNodes(node["content"]) + lines := make([]string, 0, len(items)) + for i, item := range items { + body := strings.Join(adfMarkdownBlocks(item, depth+1), "\n\n") + prefix := marker(i) + indented := strings.ReplaceAll(body, "\n", "\n"+strings.Repeat(" ", len(prefix))) + lines = append(lines, prefix+indented) + } + return strings.Join(lines, "\n") +} + +// clampInt returns n bounded to [min, max]. +func clampInt(n, min, max int) int { + if n < min { + return min + } + if n > max { + return max + } + return n +} + +// asADFNodes type-asserts an ADF "content" field into a slice of child +// node maps, skipping (rather than failing on) any element that isn't a +// map[string]any. +func asADFNodes(content any) []map[string]any { + items, ok := content.([]any) + if !ok { + return nil + } + nodes := make([]map[string]any, 0, len(items)) + for _, c := range items { + if m, ok := c.(map[string]any); ok { + nodes = append(nodes, m) + } + } + return nodes +} + +// adfMarkdownInline renders a block node's inline children (text/hardBreak/ +// mention/emoji/status/date/inlineCard) as a single line of Markdown, +// applying each node's marks. +// +// Adjacent children carrying identical mark sets are coalesced into one +// run before their marks are applied, rather than wrapped independently. +// Real Jira-Cloud-authored ADF commonly splits a single bold run across +// several same-marked text nodes, and walkInline's own soft-break case +// (a " " text node inheriting its neighbors' marks) produces the same +// shape on the write side: wrapping "hello " and "world" independently +// as "**hello **" + "**world**" leaves "**hello ****world**", which +// MarkdownToADF re-parses with the middle "****" as literal asterisks +// rather than one continuous bold run. +func adfMarkdownInline(node map[string]any) string { + var sb strings.Builder + // atLineStart tracks whether the next emitted content starts a fresh + // rendered Markdown line: true for the block's first child, and + // again right after a hardBreak. Unmarked text landing here needs + // escaping if it happens to begin with block-level syntax (#, -, >, + // +, "1."), or MarkdownToADF would reparse it as a different node + // type — see applyADFMarks/escapeMDLineStarts. + atLineStart := true + + // pending buffers a run of consecutive children with identical marks + // so their text can be escaped and wrapped once, rather than once + // per ADF node. + var pending struct { + text string + marks []map[string]any + atLineStart bool + open bool + } + flush := func() { + if !pending.open { + return + } + sb.WriteString(applyADFMarks(pending.text, pending.marks, pending.atLineStart)) + pending.open = false + } + appendRun := func(text string, marks []map[string]any) { + if pending.open && reflect.DeepEqual(pending.marks, marks) { + pending.text += text + return + } + flush() + pending.text, pending.marks, pending.atLineStart, pending.open = text, marks, atLineStart, true + } + + for _, c := range asADFNodes(node["content"]) { + childType, _ := c["type"].(string) + switch childType { + case "hardBreak": + // A backslash before the line ending, rather than the more + // common trailing double-space: trailing whitespace is + // silently stripped by enough editors, git diffs, and web + // forms that a hard break built from it wouldn't reliably + // survive a copy/paste round trip. + flush() + sb.WriteString("\\\n") + atLineStart = true + continue + case "mention", "status": + appendRun(atomAttrText(c, "text"), asADFNodes(c["marks"])) + atLineStart = false + continue + case "emoji": + text := atomAttrText(c, "text") + if text == "" { + text = atomAttrText(c, "shortName") + } + appendRun(text, asADFNodes(c["marks"])) + atLineStart = false + continue + case "date": + appendRun(atomAttrText(c, "timestamp"), asADFNodes(c["marks"])) + atLineStart = false + continue + case "inlineCard": + appendRun(atomAttrText(c, "url"), asADFNodes(c["marks"])) + atLineStart = false + continue + } + text, _ := c["text"].(string) + appendRun(text, asADFNodes(c["marks"])) + atLineStart = false + } + flush() + return sb.String() +} + +// atomAttrText reads a string-valued attrs field from an inline atom node +// (mention, emoji, status, date, inlineCard) — these carry their visible +// text in attrs rather than a top-level "text" field. Returns "" if attrs +// or the named field is missing or not a string. +func atomAttrText(node map[string]any, field string) string { + attrs, ok := node["attrs"].(map[string]any) + if !ok { + return "" + } + s, _ := attrs[field].(string) + return s +} + +// applyADFMarks wraps text in the Markdown syntax for each of marks, in +// reverse order: MarkdownToADF's walkInline builds a text node's marks +// outer-to-inner (each enclosing mark is appended after the ones already +// inherited from its parent — see its Emphasis/Link cases), so marks[0] is +// the outermost mark and must be applied last to reproduce the original +// nesting. Text is escaped for Markdown-significant characters unless a +// code mark is present (code spans are verbatim in Markdown). +// +// atLineStart additionally escapes leading block-level syntax (#, -, >, +// +, "1.") when text is otherwise unmarked and lands at the start of a +// rendered line — marked text doesn't need this, since a Markdown parser +// sees the mark's own opening delimiter, not text's first character, at +// that position. +func applyADFMarks(text string, marks []map[string]any, atLineStart bool) string { + if !hasCodeMark(marks) { + text = escapeMDText(text) + if len(marks) == 0 { + text = escapeMDLineStarts(text, atLineStart) + } + } + for i := len(marks) - 1; i >= 0; i-- { + mark := marks[i] + switch mark["type"] { + case "strong": + text = wrapFlanking(text, "**") + case "em": + text = wrapFlanking(text, "*") + case "code": + text = "`" + text + "`" + case "link": + href := "" + if attrs, ok := mark["attrs"].(map[string]any); ok { + href, _ = attrs["href"].(string) + } + text = "[" + text + "](" + href + ")" + } + } + return text +} + +// wrapFlanking wraps text in an emphasis delimiter (CommonMark's "**" or +// "*"), placing the delimiters around only text's non-whitespace core and +// leaving any leading or trailing whitespace outside them. CommonMark +// only lets a delimiter open or close emphasis when it isn't immediately +// followed (to open) or preceded (to close) by whitespace — wrapping +// "hello " directly as "**hello **" leaves the closing "**" unable to +// close, so MarkdownToADF would read it back as literal asterisks rather +// than bold. If text is entirely whitespace (or empty), there's no +// visible content to mark up, so it's returned unwrapped. +func wrapFlanking(text, delim string) string { + leftTrimmed := strings.TrimLeftFunc(text, unicode.IsSpace) + lead := text[:len(text)-len(leftTrimmed)] + core := strings.TrimRightFunc(leftTrimmed, unicode.IsSpace) + if core == "" { + return text + } + trail := leftTrimmed[len(core):] + return lead + delim + core + delim + trail +} + +// mdEscaper escapes characters that have syntactic meaning in CommonMark +// so that literal content from Jira-native ADF (not from a MarkdownToADF +// round-trip) renders verbatim rather than triggering formatting. +// Backslash is escaped first to avoid double-escaping. +var mdEscaper = strings.NewReplacer( + `\`, `\\`, + `*`, `\*`, + `_`, `\_`, + "`", "\\`", + `[`, `\[`, + `]`, `\]`, + `&`, `\&`, +) + +// escapeMDText escapes markdown-significant characters in text. +func escapeMDText(text string) string { + return mdEscaper.Replace(text) +} + +// escapeMDBlockChars are the characters that only carry block-level +// meaning (ATX heading, blockquote, bullet list) when they're the first +// character on a Markdown source line. Unlike mdEscaper's characters, +// escaping these everywhere would be needlessly noisy for ordinary body +// text ("well-known", "C#"), so escapeMDLineStarts only escapes them at +// an actual line start. +const escapeMDBlockChars = "#->+" + +// escapeMDLineStarts backslash-escapes a leading block-syntax character +// — ATX heading '#', blockquote '>', bullet list '-'/'+', or an ordered +// list marker like "1." or "1)" — wherever it falls at the start of a +// rendered Markdown line: at the very start of text if atLineStart, and +// after any newline embedded directly within text. Without this, e.g. a +// Jira-native paragraph whose text happens to start with "# " would +// reparse as a heading on a round trip through MarkdownToADF. +func escapeMDLineStarts(text string, atLineStart bool) string { + var sb strings.Builder + lineStart := atLineStart + for i := 0; i < len(text); { + c := text[i] + if lineStart { + if strings.IndexByte(escapeMDBlockChars, c) >= 0 { + sb.WriteByte('\\') + sb.WriteByte(c) + i++ + lineStart = false + continue + } + if c >= '0' && c <= '9' { + j := i + for j < len(text) && text[j] >= '0' && text[j] <= '9' { + j++ + } + if j < len(text) && (text[j] == '.' || text[j] == ')') { + sb.WriteString(text[i:j]) + sb.WriteByte('\\') + sb.WriteByte(text[j]) + i = j + 1 + lineStart = false + continue + } + } + } + sb.WriteByte(c) + lineStart = c == '\n' + i++ + } + return sb.String() +} + +// hasCodeMark reports whether marks contains a "code" mark. Text inside a +// code span is rendered verbatim in Markdown, so escaping would insert +// visible backslashes. +func hasCodeMark(marks []map[string]any) bool { + for _, m := range marks { + if m["type"] == "code" { + return true + } + } + return false +} diff --git a/internal/forge/jira/adf_test.go b/internal/forge/jira/adf_test.go new file mode 100644 index 0000000000..6454c105e1 --- /dev/null +++ b/internal/forge/jira/adf_test.go @@ -0,0 +1,1834 @@ +package jira + +import ( + "fmt" + "strings" + "testing" + "time" +) + +// asMap is a small helper to keep test assertions terse when reaching into +// nested ADF map[string]any structures. +func asMap(t *testing.T, v any) map[string]any { + t.Helper() + m, ok := v.(map[string]any) + if !ok { + t.Fatalf("expected map[string]any, got %T (%v)", v, v) + } + return m +} + +func asSlice(t *testing.T, v any) []any { + t.Helper() + s, ok := v.([]any) + if !ok { + t.Fatalf("expected []any, got %T (%v)", v, v) + } + return s +} + +// mustADF calls MarkdownToADF and fails the test if it returns an error, +// for the vast majority of test cases that expect src to convert +// successfully. +func mustADF(t *testing.T, src string) map[string]any { + t.Helper() + doc, err := MarkdownToADF(src) + if err != nil { + t.Fatalf("MarkdownToADF(%q) returned unexpected error: %v", src, err) + } + return doc +} + +// --------------------------------------------------------------------------- +// MarkdownToADF +// --------------------------------------------------------------------------- + +func TestMarkdownToADF_PlainParagraph(t *testing.T) { + doc := mustADF(t, "hello world") + + if doc["type"] != "doc" { + t.Errorf("doc type = %v, want %q", doc["type"], "doc") + } + if doc["version"] != 1 { + t.Errorf("doc version = %v, want 1", doc["version"]) + } + + content := asSlice(t, doc["content"]) + if len(content) != 1 { + t.Fatalf("doc content len = %d, want 1", len(content)) + } + para := asMap(t, content[0]) + if para["type"] != "paragraph" { + t.Errorf("block type = %v, want %q", para["type"], "paragraph") + } + paraContent := asSlice(t, para["content"]) + if len(paraContent) != 1 { + t.Fatalf("paragraph content len = %d, want 1", len(paraContent)) + } + text := asMap(t, paraContent[0]) + if text["type"] != "text" || text["text"] != "hello world" { + t.Errorf("text node = %+v, want type=text text=%q", text, "hello world") + } +} + +func TestMarkdownToADF_ResolvesBackslashEscapesAndEntities(t *testing.T) { + doc := mustADF(t, `\*not em\* and © and &`) + + para := asMap(t, asSlice(t, doc["content"])[0]) + nodes := asSlice(t, para["content"]) + + var got strings.Builder + for _, n := range nodes { + node := asMap(t, n) + got.WriteString(fmt.Sprint(node["text"])) + } + want := "*not em* and \u00a9 and &" + if got.String() != want { + t.Errorf("text = %q, want %q (escapes/entities should be resolved, as goldmark's HTML renderer does)", got.String(), want) + } +} + +func TestMarkdownToADF_CodeSpanKeepsBackslashesLiteral(t *testing.T) { + doc := mustADF(t, "`\\*literal\\*`") + + para := asMap(t, asSlice(t, doc["content"])[0]) + nodes := asSlice(t, para["content"]) + text := asMap(t, nodes[0]) + want := `\*literal\*` + if text["text"] != want { + t.Errorf("code span text = %v, want %q (raw/code text must not be unescaped)", text["text"], want) + } +} + +func TestMarkdownToADF_MultipleParagraphs(t *testing.T) { + doc := mustADF(t, "first\n\nsecond") + + content := asSlice(t, doc["content"]) + if len(content) != 2 { + t.Fatalf("doc content len = %d, want 2", len(content)) + } + for i, want := range []string{"first", "second"} { + para := asMap(t, content[i]) + text := asMap(t, asSlice(t, para["content"])[0]) + if text["text"] != want { + t.Errorf("paragraph %d text = %v, want %q", i, text["text"], want) + } + } +} + +func TestMarkdownToADF_HeadingLevels(t *testing.T) { + for level := 1; level <= 6; level++ { + src := strings.Repeat("#", level) + " Heading" + doc := mustADF(t, src) + content := asSlice(t, doc["content"]) + if len(content) != 1 { + t.Fatalf("level %d: doc content len = %d, want 1", level, len(content)) + } + heading := asMap(t, content[0]) + if heading["type"] != "heading" { + t.Fatalf("level %d: block type = %v, want %q", level, heading["type"], "heading") + } + attrs := asMap(t, heading["attrs"]) + gotLevel, ok := attrs["level"].(int) + if !ok || gotLevel != level { + t.Errorf("level %d: attrs.level = %v, want %d", level, attrs["level"], level) + } + } +} + +func TestMarkdownToADF_BoldItalicInlineCode(t *testing.T) { + doc := mustADF(t, "**bold** and *italic* and `code`") + + para := asMap(t, asSlice(t, doc["content"])[0]) + nodes := asSlice(t, para["content"]) + + var foundStrong, foundEm, foundCode bool + for _, n := range nodes { + node := asMap(t, n) + marks, ok := node["marks"].([]any) + if !ok { + continue + } + for _, m := range marks { + mark := asMap(t, m) + switch mark["type"] { + case "strong": + if node["text"] != "bold" { + t.Errorf("strong text = %v, want %q", node["text"], "bold") + } + foundStrong = true + case "em": + if node["text"] != "italic" { + t.Errorf("em text = %v, want %q", node["text"], "italic") + } + foundEm = true + case "code": + if node["text"] != "code" { + t.Errorf("code text = %v, want %q", node["text"], "code") + } + foundCode = true + } + } + } + if !foundStrong { + t.Error("expected a text node with a strong mark") + } + if !foundEm { + t.Error("expected a text node with an em mark") + } + if !foundCode { + t.Error("expected a text node with a code mark") + } +} + +func TestMarkdownToADF_BoldInlineCodeDoesNotCombineMarks(t *testing.T) { + doc := mustADF(t, "**`--flag`**") + + para := asMap(t, asSlice(t, doc["content"])[0]) + nodes := asSlice(t, para["content"]) + + var found bool + for _, n := range nodes { + node := asMap(t, n) + if node["text"] != "--flag" { + continue + } + found = true + marks := asSlice(t, node["marks"]) + var sawCode, sawStrong bool + for _, m := range marks { + switch asMap(t, m)["type"] { + case "code": + sawCode = true + case "strong", "em": + sawStrong = true + } + } + if !sawCode { + t.Errorf("marks = %v, want a code mark", marks) + } + if sawStrong { + t.Errorf("marks = %v, want code combined only with link, not strong/em", marks) + } + } + if !found { + t.Fatalf("expected a text node with value %q", "--flag") + } +} + +func TestMarkdownToADF_NestedSameTypeEmphasisDoesNotDuplicateMarks(t *testing.T) { + for _, src := range []string{"*outer _inner_ text*", "**outer __inner__ text**"} { + doc := mustADF(t, src) + + para := asMap(t, asSlice(t, doc["content"])[0]) + nodes := asSlice(t, para["content"]) + + var found bool + for _, n := range nodes { + node := asMap(t, n) + if node["text"] != "inner" { + continue + } + found = true + marks := asSlice(t, node["marks"]) + if len(marks) != 1 { + t.Errorf("%q: marks on %q = %v, want exactly one mark", src, "inner", marks) + } + } + if !found { + t.Fatalf("%q: expected a text node with value %q", src, "inner") + } + } +} + +func TestMarkdownToADF_FencedCodeBlockWithLanguage(t *testing.T) { + doc := mustADF(t, "```go\nfmt.Println(\"hi\")\n```") + + content := asSlice(t, doc["content"]) + if len(content) != 1 { + t.Fatalf("doc content len = %d, want 1", len(content)) + } + block := asMap(t, content[0]) + if block["type"] != "codeBlock" { + t.Fatalf("block type = %v, want %q", block["type"], "codeBlock") + } + attrs := asMap(t, block["attrs"]) + if attrs["language"] != "go" { + t.Errorf("attrs.language = %v, want %q", attrs["language"], "go") + } + codeContent := asSlice(t, block["content"]) + text := asMap(t, codeContent[0]) + if !strings.Contains(fmt.Sprint(text["text"]), "fmt.Println") { + t.Errorf("code text = %v, want it to contain %q", text["text"], "fmt.Println") + } +} + +func TestMarkdownToADF_EmptyFencedCodeBlock(t *testing.T) { + doc := mustADF(t, "```\n```") + + content := asSlice(t, doc["content"]) + if len(content) != 1 { + t.Fatalf("doc content len = %d, want 1", len(content)) + } + block := asMap(t, content[0]) + if block["type"] != "codeBlock" { + t.Fatalf("block type = %v, want %q", block["type"], "codeBlock") + } + codeContent, _ := block["content"].([]any) + for _, c := range codeContent { + text := asMap(t, c) + if text["text"] == "" { + t.Errorf("codeBlock content = %v, want no zero-length text node (ADF forbids text.text minLength 0)", codeContent) + } + } +} + +func TestMarkdownToADF_BulletList(t *testing.T) { + doc := mustADF(t, "- one\n- two\n") + + content := asSlice(t, doc["content"]) + list := asMap(t, content[0]) + if list["type"] != "bulletList" { + t.Fatalf("block type = %v, want %q", list["type"], "bulletList") + } + items := asSlice(t, list["content"]) + if len(items) != 2 { + t.Fatalf("bulletList content len = %d, want 2", len(items)) + } + for i, want := range []string{"one", "two"} { + item := asMap(t, items[i]) + if item["type"] != "listItem" { + t.Errorf("item %d type = %v, want %q", i, item["type"], "listItem") + } + itemPara := asMap(t, asSlice(t, item["content"])[0]) + text := asMap(t, asSlice(t, itemPara["content"])[0]) + if text["text"] != want { + t.Errorf("item %d text = %v, want %q", i, text["text"], want) + } + } +} + +func TestMarkdownToADF_OrderedListNonOneStart(t *testing.T) { + doc := mustADF(t, "5. five\n6. six\n") + + content := asSlice(t, doc["content"]) + list := asMap(t, content[0]) + if list["type"] != "orderedList" { + t.Fatalf("block type = %v, want %q", list["type"], "orderedList") + } + attrs := asMap(t, list["attrs"]) + if attrs["order"] != 5 { + t.Errorf("attrs.order = %v, want 5", attrs["order"]) + } + items := asSlice(t, list["content"]) + if len(items) != 2 { + t.Fatalf("orderedList content len = %d, want 2", len(items)) + } +} + +func TestMarkdownToADF_Blockquote(t *testing.T) { + doc := mustADF(t, "> quoted text") + + content := asSlice(t, doc["content"]) + bq := asMap(t, content[0]) + if bq["type"] != "blockquote" { + t.Fatalf("block type = %v, want %q", bq["type"], "blockquote") + } + para := asMap(t, asSlice(t, bq["content"])[0]) + text := asMap(t, asSlice(t, para["content"])[0]) + if text["text"] != "quoted text" { + t.Errorf("blockquote text = %v, want %q", text["text"], "quoted text") + } +} + +func TestMarkdownToADF_Link(t *testing.T) { + doc := mustADF(t, "see [the docs](https://example.com/docs)") + + para := asMap(t, asSlice(t, doc["content"])[0]) + nodes := asSlice(t, para["content"]) + + var found bool + for _, n := range nodes { + node := asMap(t, n) + marks, ok := node["marks"].([]any) + if !ok { + continue + } + for _, m := range marks { + mark := asMap(t, m) + if mark["type"] != "link" { + continue + } + attrs := asMap(t, mark["attrs"]) + if attrs["href"] != "https://example.com/docs" { + t.Errorf("link href = %v, want %q", attrs["href"], "https://example.com/docs") + } + if node["text"] != "the docs" { + t.Errorf("link text = %v, want %q", node["text"], "the docs") + } + found = true + } + } + if !found { + t.Error("expected a text node with a link mark") + } +} + +func TestMarkdownToADF_LinkMarkDoesNotLeakToFollowingText(t *testing.T) { + doc := mustADF(t, "before [link](https://example.com) after") + + para := asMap(t, asSlice(t, doc["content"])[0]) + nodes := asSlice(t, para["content"]) + + var sawTrailingText bool + for _, n := range nodes { + node := asMap(t, n) + text, _ := node["text"].(string) + if !strings.Contains(text, "after") { + continue + } + sawTrailingText = true + if marks, ok := node["marks"].([]any); ok && len(marks) > 0 { + t.Errorf("text node %q has marks %v, want none (link mark must not leak to a sibling)", text, marks) + } + } + if !sawTrailingText { + t.Fatal("expected a text node containing \"after\"") + } +} + +func TestMarkdownToADF_NestedAutolinkKeepsInnerHref(t *testing.T) { + // goldmark parses "[a ](outer)" as an AutoLink nested inside a + // Link — deviating from CommonMark, which refuses nested links, but + // reachable input nonetheless. withMark's type-only dedup on "link" + // must not let the outer link's href silently overwrite the inner + // autolink's own href. + doc := mustADF(t, "[a b](https://outer.example)") + + para := asMap(t, asSlice(t, doc["content"])[0]) + var innerHref string + var found bool + for _, n := range asSlice(t, para["content"]) { + node := asMap(t, n) + if node["text"] != "https://inner.example" { + continue + } + found = true + marks := asSlice(t, node["marks"]) + for _, m := range marks { + mark := asMap(t, m) + if mark["type"] != "link" { + continue + } + attrs := asMap(t, mark["attrs"]) + innerHref = fmt.Sprint(attrs["href"]) + } + } + if !found { + t.Fatalf("expected a text node with value %q", "https://inner.example") + } + if innerHref != "https://inner.example" { + t.Errorf("inner autolink href = %q, want %q (the outer link's href leaked in)", innerHref, "https://inner.example") + } +} + +func TestMarkdownToADF_ImagePreservesDestinationAsLink(t *testing.T) { + // ADF has no plain image node outside "media" (which requires an + // uploaded attachment id, not a bare URL), so the load-bearing + // destination is preserved as a link on the alt text — the closest + // faithful degradation available. + doc := mustADF(t, "see ![diagram](https://img.example/d.png) here") + + href, found := linkMarkHref(t, doc) + if !found { + t.Fatalf("MarkdownToADF(image) produced no link mark; want the image destination preserved as a link") + } + if href != "https://img.example/d.png" { + t.Errorf("image link href = %q, want %q", href, "https://img.example/d.png") + } + + para := asMap(t, asSlice(t, doc["content"])[0]) + var sawAlt bool + for _, n := range asSlice(t, para["content"]) { + if asMap(t, n)["text"] == "diagram" { + sawAlt = true + } + } + if !sawAlt { + t.Errorf("MarkdownToADF(image) = %+v, want alt text %q preserved", doc, "diagram") + } +} + +func TestMarkdownToADF_ImageRejectsDangerousScheme(t *testing.T) { + doc := mustADF(t, "see ![diagram](javascript:alert(1)) here") + if href, found := linkMarkHref(t, doc); found { + t.Errorf("MarkdownToADF(image with javascript: scheme) produced a link mark with href %q; want it dropped", href) + } +} + +func TestMarkdownToADF_UnknownBlockFallsBackToPlainText(t *testing.T) { + // convertBlockNode's default case previously returned nil for any + // block-level node outside its supported vocabulary (e.g. a raw HTML + // block), silently dropping the content with no trace. This repo's + // own convention (internal/sticky, postcomment.go, postreview.go) of + // wrapping output in
......
+ // HTML blocks would vanish entirely if posted to Jira. Mirror + // walkInline's own default case, which falls back to plain text + // rather than losing readable content. + doc := mustADF(t, "before\n\n
x\ny\n
\n\nafter") + + content := asSlice(t, doc["content"]) + var sawFallbackText bool + for _, c := range content { + block := asMap(t, c) + if block["type"] != "paragraph" { + continue + } + for _, n := range asSlice(t, block["content"]) { + node := asMap(t, n) + text, _ := node["text"].(string) + if strings.Contains(text, "
") { + sawFallbackText = true + } + } + } + if !sawFallbackText { + t.Errorf("MarkdownToADF(html block) = %+v, want the raw HTML block content preserved as fallback text somewhere, not silently dropped", doc) + } +} + +// linkMarkHref returns the href of the first "link" mark found among +// para's inline content, and whether one was found at all. +func linkMarkHref(t *testing.T, doc map[string]any) (href string, found bool) { + t.Helper() + para := asMap(t, asSlice(t, doc["content"])[0]) + for _, n := range asSlice(t, para["content"]) { + node := asMap(t, n) + marks, ok := node["marks"].([]any) + if !ok { + continue + } + for _, m := range marks { + mark := asMap(t, m) + if mark["type"] != "link" { + continue + } + attrs := asMap(t, mark["attrs"]) + return fmt.Sprint(attrs["href"]), true + } + } + return "", false +} + +func TestMarkdownToADF_LinkRejectsDangerousSchemes(t *testing.T) { + for _, src := range []string{ + "[click me](javascript:alert(1))", + "[click me](data:text/html,)", + "[click me](vbscript:msgbox(1))", + } { + doc := mustADF(t, src) + if _, found := linkMarkHref(t, doc); found { + t.Errorf("MarkdownToADF(%q) produced a link mark; want the dangerous-scheme href dropped", src) + } + } +} + +func TestMarkdownToADF_LinkAllowsSafeSchemes(t *testing.T) { + for _, tc := range []struct{ src, want string }{ + {"[docs](https://example.com/docs)", "https://example.com/docs"}, + {"[docs](http://example.com/docs)", "http://example.com/docs"}, + {"[me](mailto:me@example.com)", "mailto:me@example.com"}, + {"[section](#section)", "#section"}, + {"[rel](/path/to/page)", "/path/to/page"}, + } { + doc := mustADF(t, tc.src) + href, found := linkMarkHref(t, doc) + if !found { + t.Errorf("MarkdownToADF(%q): expected a link mark, got none", tc.src) + continue + } + if href != tc.want { + t.Errorf("MarkdownToADF(%q) href = %q, want %q", tc.src, href, tc.want) + } + } +} + +func TestMarkdownToADF_LinkRejectsProtocolRelativeHost(t *testing.T) { + doc := mustADF(t, "[click me](//evil.example)") + if href, found := linkMarkHref(t, doc); found { + t.Errorf("MarkdownToADF(protocol-relative link) produced a link mark with href %q; want it dropped", href) + } +} + +func TestMarkdownToADF_AutoLinkRejectsDangerousScheme(t *testing.T) { + // goldmark's autolink extension isn't enabled by default, so this + // exercises the CommonMark <...> autolink form instead. + doc := mustADF(t, "") + if _, found := linkMarkHref(t, doc); found { + t.Errorf("MarkdownToADF(autolink with javascript: scheme) produced a link mark; want it dropped") + } +} + +// assertNoEmptyContainers recursively checks that no blockquote, +// bulletList, orderedList, or listItem node in the ADF tree has empty +// content, which violates ADF's minItems: 1 for those node types. +func assertNoEmptyContainers(t *testing.T, node any) { + t.Helper() + m, ok := node.(map[string]any) + if !ok { + return + } + nodeType, _ := m["type"].(string) + content, hasContent := m["content"].([]any) + switch nodeType { + case "blockquote", "bulletList", "orderedList", "listItem": + if hasContent && len(content) == 0 { + t.Errorf("%s node has content: [], which violates ADF minItems: 1: %+v", nodeType, m) + } + } + for _, c := range content { + assertNoEmptyContainers(t, c) + } +} + +func TestMarkdownToADF_HeadingInsideBlockquoteDegradesToBoldParagraph(t *testing.T) { + doc := mustADF(t, "> # title") + + bq := asMap(t, asSlice(t, doc["content"])[0]) + if bq["type"] != "blockquote" { + t.Fatalf("block type = %v, want %q", bq["type"], "blockquote") + } + children := asSlice(t, bq["content"]) + if len(children) != 1 { + t.Fatalf("blockquote content len = %d, want 1", len(children)) + } + para := asMap(t, children[0]) + if para["type"] != "paragraph" { + t.Errorf("heading-in-blockquote degraded type = %v, want %q (ADF blockquote schema doesn't allow heading children)", para["type"], "paragraph") + } + text := asMap(t, asSlice(t, para["content"])[0]) + if text["text"] != "title" { + t.Errorf("degraded heading text = %v, want %q", text["text"], "title") + } + marks := asSlice(t, text["marks"]) + var sawStrong bool + for _, mk := range marks { + if asMap(t, mk)["type"] == "strong" { + sawStrong = true + } + } + if !sawStrong { + t.Errorf("degraded heading marks = %v, want a strong mark", marks) + } + assertNoEmptyContainers(t, doc) +} + +func TestMarkdownToADF_NestedBlockquoteFlattens(t *testing.T) { + doc := mustADF(t, "> > inner") + + outer := asMap(t, asSlice(t, doc["content"])[0]) + if outer["type"] != "blockquote" { + t.Fatalf("block type = %v, want %q", outer["type"], "blockquote") + } + for _, c := range asSlice(t, outer["content"]) { + if asMap(t, c)["type"] == "blockquote" { + t.Fatalf("outer blockquote content = %+v, want the nested blockquote flattened away (ADF blockquote schema doesn't allow blockquote children)", outer["content"]) + } + } + assertNoEmptyContainers(t, doc) +} + +func TestMarkdownToADF_HeadingInsideListItemDegradesToBoldParagraph(t *testing.T) { + doc := mustADF(t, "- # h") + + list := asMap(t, asSlice(t, doc["content"])[0]) + item := asMap(t, asSlice(t, list["content"])[0]) + para := asMap(t, asSlice(t, item["content"])[0]) + if para["type"] != "paragraph" { + t.Errorf("heading-in-listItem degraded type = %v, want %q (ADF listItem schema doesn't allow heading children)", para["type"], "paragraph") + } + assertNoEmptyContainers(t, doc) +} + +func TestMarkdownToADF_BlockquoteInsideListItemFlattens(t *testing.T) { + doc := mustADF(t, "- > q") + + list := asMap(t, asSlice(t, doc["content"])[0]) + item := asMap(t, asSlice(t, list["content"])[0]) + for _, c := range asSlice(t, item["content"]) { + if asMap(t, c)["type"] == "blockquote" { + t.Fatalf("listItem content = %+v, want the blockquote flattened away (ADF listItem schema doesn't allow blockquote children)", item["content"]) + } + } + assertNoEmptyContainers(t, doc) +} + +func TestMarkdownToADF_ThematicBreakInsideBlockquoteDropped(t *testing.T) { + doc := mustADF(t, "> above\n>\n> ---\n>\n> below") + + bq := asMap(t, asSlice(t, doc["content"])[0]) + if bq["type"] != "blockquote" { + t.Fatalf("block type = %v, want %q", bq["type"], "blockquote") + } + for _, c := range asSlice(t, bq["content"]) { + if asMap(t, c)["type"] == "rule" { + t.Errorf("blockquote content = %+v, want the rule dropped (ADF blockquote schema doesn't allow a rule child)", bq["content"]) + } + } + assertNoEmptyContainers(t, doc) +} + +func TestMarkdownToADF_DeepNestingProducesNoEmptyContainers(t *testing.T) { + // The maxADFWriteDepth cutoff truncates a deeply nested blockquote's + // content, which — before the container-emptiness check — left an + // innermost {"type":"blockquote","content":[]} at the cap boundary: + // schema-invalid ADF Jira would reject outright. A sibling paragraph + // keeps the overall doc non-empty despite the nested blockquote + // collapsing entirely, so this exercises the container-emptiness + // check on its own, independent of MarkdownToADF's separate + // empty-doc error (see TestMarkdownToADF_AllContentDroppedReturnsError). + const depth = 60 // > maxADFWriteDepth (50) + src := strings.Repeat("> ", depth) + "leaf" + "\n\nsibling paragraph" + doc := mustADF(t, src) + assertNoEmptyContainers(t, doc) +} + +func TestMarkdownToADF_AllContentDroppedReturnsError(t *testing.T) { + // A chain of nested blockquotes deeper than maxADFWriteDepth, with no + // other top-level content, collapses all the way up to an empty doc: + // each level past the cutoff drops its empty container, which empties + // its parent, and so on to the top. Posting that to Jira would create + // a visibly empty comment with no error. MarkdownToADF must fail + // instead of returning {"type":"doc","content":[]}. + const depth = 60 // > maxADFWriteDepth (50) + src := strings.Repeat("> ", depth) + "leaf" + + _, err := MarkdownToADF(src) + if err == nil { + t.Error("MarkdownToADF(all-dropped nested blockquotes) returned no error; want one rather than an empty ADF doc") + } +} + +func TestMarkdownToADF_OversizedInputReturnsError(t *testing.T) { + src := strings.Repeat("a", maxMarkdownParseBytes+1) + + _, err := MarkdownToADF(src) + if err == nil { + t.Errorf("MarkdownToADF(%d bytes) returned no error; want one for input over the %d byte limit", len(src), maxMarkdownParseBytes) + } +} + +func TestMarkdownToADF_ThematicBreak(t *testing.T) { + doc := mustADF(t, "above\n\n---\n\nbelow") + + content := asSlice(t, doc["content"]) + if len(content) != 3 { + t.Fatalf("doc content len = %d, want 3", len(content)) + } + rule := asMap(t, content[1]) + if rule["type"] != "rule" { + t.Errorf("block type = %v, want %q", rule["type"], "rule") + } +} + +func TestMarkdownToADF_DeepNestingIsBounded(t *testing.T) { + // Mirrors TestADFToPlainText_DeepNestingIsBounded: MarkdownToADF's + // block/inline converters recurse once per markdown nesting level with + // no cap, so deeply nested input (e.g. thousands of ">" blockquote + // markers) must not be walked to full depth. A sibling paragraph keeps + // the doc non-empty despite the nested blockquote collapsing entirely + // past the depth cap. + const depth = 10000 + src := strings.Repeat("> ", depth) + "leaf" + "\n\nsibling paragraph" + + doc := mustADF(t, src) + + walked := 0 + content := doc["content"] + for { + items, ok := content.([]any) + if !ok || len(items) == 0 { + break + } + node, ok := items[0].(map[string]any) + if !ok || node["type"] != "blockquote" { + break + } + walked++ + content = node["content"] + } + if walked >= depth { + t.Errorf("MarkdownToADF walked all %d nesting levels; want it capped well below that", depth) + } +} + +func TestMarkdownToADF_ParseTimeIsBounded(t *testing.T) { + // maxADFWriteDepth only bounds the post-parse AST walk; goldmark's own + // Parse() is ~O(N^2) on deeply nested blockquotes and dominates the + // cost long before the walk-depth cap is ever reached (confirmed by + // benchmark: 80,000 nesting levels take ~3.2s to parse alone). Without + // an input size limit rejected before Parse(), MarkdownToADF blocks + // for seconds on adversarial input regardless of maxADFWriteDepth. + // This input is well over maxMarkdownParseBytes, so MarkdownToADF + // returns an error rather than parsing it at all — both return values + // are discarded since this test only cares about timing. + const depth = 80000 + src := strings.Repeat("> ", depth) + "leaf" + + start := time.Now() + MarkdownToADF(src) //nolint:errcheck // timing-only test, error is expected + elapsed := time.Since(start) + + const budget = 750 * time.Millisecond + if elapsed > budget { + t.Errorf("MarkdownToADF(%d nesting levels) took %s, want under %s (parse time must be bounded independent of maxADFWriteDepth)", depth, elapsed, budget) + } +} + +func TestMarkdownToADF_HardBreak(t *testing.T) { + doc := mustADF(t, "line one \nline two") + + para := asMap(t, asSlice(t, doc["content"])[0]) + nodes := asSlice(t, para["content"]) + + var foundHardBreak bool + for _, n := range nodes { + node := asMap(t, n) + if node["type"] == "hardBreak" { + foundHardBreak = true + } + } + if !foundHardBreak { + t.Errorf("expected a hardBreak node in paragraph content, got %+v", nodes) + } +} + +// --------------------------------------------------------------------------- +// ADFToPlainText +// --------------------------------------------------------------------------- + +func TestADFToPlainText_String(t *testing.T) { + got := ADFToPlainText("plain text body") + if got != "plain text body" { + t.Errorf("ADFToPlainText(string) = %q, want %q", got, "plain text body") + } +} + +func TestADFToPlainText_Paragraph(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "version": 1, + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{"type": "text", "text": "hello there"}, + }, + }, + }, + } + got := ADFToPlainText(adf) + if got != "hello there" { + t.Errorf("ADFToPlainText(paragraph) = %q, want %q", got, "hello there") + } +} + +func TestADFToPlainText_MultiParagraphAndHeading(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "version": 1, + "content": []any{ + map[string]any{ + "type": "heading", + "content": []any{ + map[string]any{"type": "text", "text": "Title"}, + }, + }, + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{"type": "text", "text": "Body"}, + }, + }, + }, + } + got := ADFToPlainText(adf) + want := "Title\nBody" + if got != want { + t.Errorf("ADFToPlainText(heading+paragraph) = %q, want %q", got, want) + } +} + +func TestADFToPlainText_List(t *testing.T) { + adf := map[string]any{ + "type": "bulletList", + "content": []any{ + map[string]any{ + "type": "listItem", + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{"type": "text", "text": "item one"}, + }, + }, + }, + }, + map[string]any{ + "type": "listItem", + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{"type": "text", "text": "item two"}, + }, + }, + }, + }, + }, + } + got := ADFToPlainText(adf) + want := "item one\nitem two" + if got != want { + t.Errorf("ADFToPlainText(list) = %q, want %q", got, want) + } +} + +func TestADFToPlainText_HardBreak(t *testing.T) { + adf := map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{"type": "text", "text": "line one"}, + map[string]any{"type": "hardBreak"}, + map[string]any{"type": "text", "text": "line two"}, + }, + } + got := ADFToPlainText(adf) + want := "line one\nline two" + if got != want { + t.Errorf("ADFToPlainText(hardBreak) = %q, want %q", got, want) + } +} + +func TestADFToPlainText_DeepNestingIsBounded(t *testing.T) { + // Mirrors jirapoll's TestExtractPlainText_DeepNestingIsBounded: the + // depth cap must match since this is a fresh implementation of the + // same defensive behavior for attacker-controlled ADF bodies. + const depth = 10000 + root := map[string]any{ + "type": "paragraph", + "text": "level-0", + } + leaf := root + for i := 1; i < depth; i++ { + child := map[string]any{ + "type": "paragraph", + "text": fmt.Sprintf("level-%d", i), + } + leaf["content"] = []any{child} + leaf = child + } + + got := ADFToPlainText(root) + if !strings.HasPrefix(got, "level-0") { + t.Errorf("ADFToPlainText(deeply nested ADF) = %q, want it to start with %q", got, "level-0") + } + if strings.Contains(got, fmt.Sprintf("level-%d", depth-1)) { + t.Errorf("ADFToPlainText(deeply nested ADF) walked all %d levels; want it capped well below that", depth) + } +} + +func TestADFToPlainText_UnexpectedType(t *testing.T) { + got := ADFToPlainText(42) + if got != "" { + t.Errorf("ADFToPlainText(int) = %q, want empty string", got) + } +} + +// --------------------------------------------------------------------------- +// ADFToMarkdown +// --------------------------------------------------------------------------- + +func TestADFToMarkdown_String(t *testing.T) { + got := ADFToMarkdown("plain text body") + if got != "plain text body" { + t.Errorf("ADFToMarkdown(string) = %q, want %q", got, "plain text body") + } +} + +func TestADFToMarkdown_UnexpectedType(t *testing.T) { + got := ADFToMarkdown(42) + if got != "" { + t.Errorf("ADFToMarkdown(int) = %q, want empty string", got) + } +} + +func TestADFToMarkdown_Paragraph(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "version": 1, + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{"type": "text", "text": "hello there"}, + }, + }, + }, + } + got := ADFToMarkdown(adf) + if got != "hello there" { + t.Errorf("ADFToMarkdown(paragraph) = %q, want %q", got, "hello there") + } +} + +func TestADFToMarkdown_MultipleParagraphsSeparatedByBlankLine(t *testing.T) { + // Unlike ADFToPlainText, which joins blocks with a single newline, + // Markdown paragraphs must be separated by a blank line, or a + // Markdown parser (including this package's own MarkdownToADF) reads + // them back as one paragraph instead of two. + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{map[string]any{"type": "text", "text": "first"}}, + }, + map[string]any{ + "type": "paragraph", + "content": []any{map[string]any{"type": "text", "text": "second"}}, + }, + }, + } + got := ADFToMarkdown(adf) + want := "first\n\nsecond" + if got != want { + t.Errorf("ADFToMarkdown(two paragraphs) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_Heading(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "heading", + "attrs": map[string]any{"level": 2}, + "content": []any{map[string]any{"type": "text", "text": "Title"}}, + }, + }, + } + got := ADFToMarkdown(adf) + want := "## Title" + if got != want { + t.Errorf("ADFToMarkdown(heading level 2) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_StrongEmCodeMarks(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{"type": "text", "text": "bold", "marks": []any{map[string]any{"type": "strong"}}}, + map[string]any{"type": "text", "text": " and "}, + map[string]any{"type": "text", "text": "italic", "marks": []any{map[string]any{"type": "em"}}}, + map[string]any{"type": "text", "text": " and "}, + map[string]any{"type": "text", "text": "code", "marks": []any{map[string]any{"type": "code"}}}, + }, + }, + }, + } + got := ADFToMarkdown(adf) + want := "**bold** and *italic* and `code`" + if got != want { + t.Errorf("ADFToMarkdown(marks) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_MultipleMarksNestInApplicationOrder(t *testing.T) { + // MarkdownToADF builds a text node's marks slice outer-to-inner (see + // walkInline's Emphasis/Link cases): the outermost enclosing mark is + // appended first, so a "**[text](url)**" source produces + // marks: [strong, link]. Rendering must reverse that order, applying + // link (innermost) first and strong (outermost) last, or the + // asymmetric marks (link, strong) round-trip into the wrong nesting. + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{ + "type": "text", + "text": "text", + "marks": []any{ + map[string]any{"type": "strong"}, + map[string]any{"type": "link", "attrs": map[string]any{"href": "https://example.com"}}, + }, + }, + }, + }, + }, + } + got := ADFToMarkdown(adf) + want := "**[text](https://example.com)**" + if got != want { + t.Errorf("ADFToMarkdown(nested marks) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_Link(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{ + "type": "text", + "text": "the docs", + "marks": []any{ + map[string]any{"type": "link", "attrs": map[string]any{"href": "https://example.com/docs"}}, + }, + }, + }, + }, + }, + } + got := ADFToMarkdown(adf) + want := "[the docs](https://example.com/docs)" + if got != want { + t.Errorf("ADFToMarkdown(link) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_CodeBlockWithLanguage(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "codeBlock", + "attrs": map[string]any{"language": "go"}, + "content": []any{ + map[string]any{"type": "text", "text": "fmt.Println(\"hi\")"}, + }, + }, + }, + } + got := ADFToMarkdown(adf) + want := "```go\nfmt.Println(\"hi\")\n```" + if got != want { + t.Errorf("ADFToMarkdown(codeBlock) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_CodeBlockEmpty(t *testing.T) { + // An ADF codeBlock with no text children must render as an empty fenced + // block (no blank line between the fences), not "```\n\n```" which + // introduces a spurious blank line on round-trip. + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{"type": "codeBlock"}, + }, + } + got := ADFToMarkdown(adf) + want := "```\n```" + if got != want { + t.Errorf("ADFToMarkdown(empty codeBlock) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_CodeBlockEmptyWithLanguage(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "codeBlock", + "attrs": map[string]any{"language": "go"}, + }, + }, + } + got := ADFToMarkdown(adf) + want := "```go\n```" + if got != want { + t.Errorf("ADFToMarkdown(empty codeBlock with language) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_CodeBlockWithoutLanguage(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "codeBlock", + "content": []any{map[string]any{"type": "text", "text": "plain"}}, + }, + }, + } + got := ADFToMarkdown(adf) + want := "```\nplain\n```" + if got != want { + t.Errorf("ADFToMarkdown(codeBlock without language) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_CodeBlockFenceLongerThanContentBackticks(t *testing.T) { + // A fixed 3-backtick fence can be broken out of by content that + // itself contains a ``` line: the closing fence needs only be *at + // least as many* backticks as the opener per CommonMark, so a + // content line of exactly 3 backticks would close the block early. + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "codeBlock", + "content": []any{map[string]any{"type": "text", "text": "example:\n```go\nfunc main() {}\n```"}}, + }, + }, + } + got := ADFToMarkdown(adf) + want := "````\nexample:\n```go\nfunc main() {}\n```\n````" + if got != want { + t.Errorf("ADFToMarkdown(codeBlock with fence-length content) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_CodeBlockSanitizesLanguageAttr(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "codeBlock", + "attrs": map[string]any{"language": "go\n```\n# injected"}, + "content": []any{map[string]any{"type": "text", "text": "code"}}, + }, + }, + } + got := ADFToMarkdown(adf) + want := "```\ncode\n```" + if got != want { + t.Errorf("ADFToMarkdown(codeBlock with hostile language attr) = %q, want %q (language dropped)", got, want) + } +} + +func TestADFToMarkdown_CodeBlockAllowsSafeLanguageAttr(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "codeBlock", + "attrs": map[string]any{"language": "c++"}, + "content": []any{map[string]any{"type": "text", "text": "code"}}, + }, + }, + } + got := ADFToMarkdown(adf) + want := "```c++\ncode\n```" + if got != want { + t.Errorf("ADFToMarkdown(codeBlock with safe language attr) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_BulletList(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "bulletList", + "content": []any{ + map[string]any{ + "type": "listItem", + "content": []any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "one"}}}}, + }, + map[string]any{ + "type": "listItem", + "content": []any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "two"}}}}, + }, + }, + }, + }, + } + got := ADFToMarkdown(adf) + want := "- one\n- two" + if got != want { + t.Errorf("ADFToMarkdown(bulletList) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_OrderedListStartsAtAttrsOrder(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "orderedList", + "attrs": map[string]any{"order": 5}, + "content": []any{ + map[string]any{ + "type": "listItem", + "content": []any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "five"}}}}, + }, + map[string]any{ + "type": "listItem", + "content": []any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "six"}}}}, + }, + }, + }, + }, + } + got := ADFToMarkdown(adf) + want := "5. five\n6. six" + if got != want { + t.Errorf("ADFToMarkdown(orderedList) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_Blockquote(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "blockquote", + "content": []any{ + map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "quoted text"}}}, + }, + }, + }, + } + got := ADFToMarkdown(adf) + want := "> quoted text" + if got != want { + t.Errorf("ADFToMarkdown(blockquote) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_Rule(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "above"}}}, + map[string]any{"type": "rule"}, + map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "below"}}}, + }, + } + got := ADFToMarkdown(adf) + want := "above\n\n---\n\nbelow" + if got != want { + t.Errorf("ADFToMarkdown(rule) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_HardBreak(t *testing.T) { + adf := map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{"type": "text", "text": "line one"}, + map[string]any{"type": "hardBreak"}, + map[string]any{"type": "text", "text": "line two"}, + }, + } + got := ADFToMarkdown(adf) + // A backslash-newline hard break, rather than trailing spaces: + // trailing whitespace is silently stripped by enough editors, git + // diffs, and web forms that a hard break built from it wouldn't + // reliably survive a copy/paste round trip. + want := "line one\\\nline two" + if got != want { + t.Errorf("ADFToMarkdown(hardBreak) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_UnknownContainerNodeRecursesIntoBlockChildren(t *testing.T) { + // Unknown ADF container types (panel, table, expand, taskList) wrap + // block-level content (paragraphs, tableRows, ...), not inline text. + // adfMarkdownInline alone can't see any of it, since it only reads + // direct children's top-level "text" fields. + for _, tc := range []struct { + name string + node map[string]any + want string + }{ + { + name: "panel", + node: map[string]any{ + "type": "panel", + "attrs": map[string]any{"panelType": "info"}, + "content": []any{ + map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "important warning text"}}}, + }, + }, + want: "important warning text", + }, + { + name: "table", + node: map[string]any{ + "type": "table", + "content": []any{ + map[string]any{"type": "tableRow", "content": []any{ + map[string]any{"type": "tableCell", "content": []any{ + map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "a"}}}, + }}, + map[string]any{"type": "tableCell", "content": []any{ + map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "b"}}}, + }}, + }}, + }, + }, + want: "a\n\nb", + }, + { + name: "expand", + node: map[string]any{ + "type": "expand", + "attrs": map[string]any{"title": "Click to expand"}, + "content": []any{ + map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "hidden content"}}}, + }, + }, + want: "hidden content", + }, + { + name: "taskList", + node: map[string]any{ + "type": "taskList", + "content": []any{ + map[string]any{ + "type": "taskItem", + "attrs": map[string]any{"localId": "1", "state": "TODO"}, + "content": []any{map[string]any{"type": "text", "text": "buy milk"}}, + }, + }, + }, + want: "buy milk", + }, + } { + doc := map[string]any{"type": "doc", "content": []any{tc.node}} + got := ADFToMarkdown(doc) + if got != tc.want { + t.Errorf("%s: ADFToMarkdown(%+v) = %q, want %q (content dropped)", tc.name, tc.node, got, tc.want) + } + } +} + +func TestADFToMarkdown_InlineAtomNodesFallBackToText(t *testing.T) { + // mention/emoji/status/date/inlineCard carry their visible text in + // attrs, not a top-level "text" field; without a case for them, + // adfMarkdownInline silently drops them. + for _, tc := range []struct { + name string + node map[string]any + want string + }{ + { + name: "mention", + node: map[string]any{"type": "mention", "attrs": map[string]any{"id": "557058:abc", "text": "@Alice"}}, + want: "@Alice", + }, + { + name: "emoji with text", + node: map[string]any{"type": "emoji", "attrs": map[string]any{"shortName": ":smile:", "text": "😄"}}, + want: "😄", + }, + { + name: "emoji falls back to shortName", + node: map[string]any{"type": "emoji", "attrs": map[string]any{"shortName": ":smile:"}}, + want: ":smile:", + }, + { + name: "status", + node: map[string]any{"type": "status", "attrs": map[string]any{"text": "DONE", "color": "green"}}, + want: "DONE", + }, + { + name: "date", + node: map[string]any{"type": "date", "attrs": map[string]any{"timestamp": "1584645600000"}}, + want: "1584645600000", + }, + { + name: "inlineCard", + node: map[string]any{"type": "inlineCard", "attrs": map[string]any{"url": "https://example.com/issue/1"}}, + want: "https://example.com/issue/1", + }, + } { + para := map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{"type": "text", "text": "ping "}, + tc.node, + map[string]any{"type": "text", "text": " please review"}, + }, + } + got := ADFToMarkdown(para) + want := "ping " + tc.want + " please review" + if got != want { + t.Errorf("%s: ADFToMarkdown(%+v) = %q, want %q", tc.name, tc.node, got, want) + } + } +} + +func TestADFToMarkdown_DeepNestingIsBounded(t *testing.T) { + // Unlike ADFToPlainText's single generic node walker, ADFToMarkdown + // only recurses through container types that can genuinely nest in + // real ADF (blockquote, bulletList/orderedList, listItem); a + // paragraph's own content is inline runs, not further blocks. So the + // attacker-controlled-nesting vector here is a blockquote chain, + // mirroring MarkdownToADF's own deep-blockquote-nesting tests, rather + // than ADFToPlainText's paragraph-chain shape. + const depth = 10000 + leaf := map[string]any{ + "type": "paragraph", + "content": []any{map[string]any{"type": "text", "text": "leaf"}}, + } + nested := leaf + for i := 0; i < depth; i++ { + nested = map[string]any{"type": "blockquote", "content": []any{nested}} + } + doc := map[string]any{ + "type": "doc", + "content": []any{ + nested, + map[string]any{ + "type": "paragraph", + "content": []any{map[string]any{"type": "text", "text": "sibling"}}, + }, + }, + } + + got := ADFToMarkdown(doc) + if strings.Contains(got, "leaf") { + t.Errorf("ADFToMarkdown(%d nested blockquotes) walked all the way to the leaf; want it capped well below that", depth) + } + if !strings.Contains(got, "sibling") { + t.Errorf("ADFToMarkdown(deeply nested blockquote + sibling) = %q, want it to still contain the sibling paragraph", got) + } +} + +func TestADFToMarkdown_Float64Attrs(t *testing.T) { + // JSON-decoded ADF has float64 for numeric attrs, not int. Verify + // ADFToMarkdown handles this correctly (the real shape from json.Decode). + heading := map[string]any{ + "type": "doc", + "version": float64(1), + "content": []any{ + map[string]any{ + "type": "heading", + "attrs": map[string]any{"level": float64(3)}, + "content": []any{ + map[string]any{"type": "text", "text": "Title"}, + }, + }, + }, + } + got := ADFToMarkdown(heading) + if got != "### Title" { + t.Errorf("ADFToMarkdown(heading with float64 level 3) = %q, want %q", got, "### Title") + } + + orderedList := map[string]any{ + "type": "doc", + "version": float64(1), + "content": []any{ + map[string]any{ + "type": "orderedList", + "attrs": map[string]any{"order": float64(5)}, + "content": []any{ + map[string]any{ + "type": "listItem", + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{map[string]any{"type": "text", "text": "item"}}, + }, + }, + }, + }, + }, + }, + } + got = ADFToMarkdown(orderedList) + if got != "5. item" { + t.Errorf("ADFToMarkdown(orderedList with float64 order 5) = %q, want %q", got, "5. item") + } +} + +func TestADFToMarkdown_HeadingLevelOutOfRangeDoesNotPanic(t *testing.T) { + for _, tc := range []struct { + name string + level any + want string + }{ + {"negative int", -1, "# hi"}, + {"negative float64", float64(-1), "# hi"}, + {"zero", 0, "# hi"}, + {"seven", 7, "###### hi"}, + } { + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "heading", + "attrs": map[string]any{"level": tc.level}, + "content": []any{map[string]any{"type": "text", "text": "hi"}}, + }, + }, + } + got := ADFToMarkdown(adf) + if got != tc.want { + t.Errorf("%s: ADFToMarkdown(heading level %v) = %q, want %q", tc.name, tc.level, got, tc.want) + } + } +} + +func TestADFToMarkdown_OrderedListOrderOutOfRangeIsBounded(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "orderedList", + "attrs": map[string]any{"order": -5}, + "content": []any{ + map[string]any{ + "type": "listItem", + "content": []any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "item"}}}}, + }, + }, + }, + }, + } + got := ADFToMarkdown(adf) + want := "0. item" + if got != want { + t.Errorf("ADFToMarkdown(orderedList order -5) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_EscapesMarkdownSignificantChars(t *testing.T) { + // Jira-native ADF (not from a MarkdownToADF round-trip) can contain + // literal markdown-significant characters in text nodes. Without + // escaping, wrapping "a * b" in **...** produces "**a * b**" which + // is ambiguous markdown. + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{"type": "text", "text": "a * b and [c]"}, + }, + }, + }, + } + got := ADFToMarkdown(adf) + want := `a \* b and \[c\]` + if got != want { + t.Errorf("ADFToMarkdown(text with markdown chars) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_EscapesLineLeadingBlockSyntax(t *testing.T) { + // Unmarked text that begins a rendered line with block syntax + // changes node type on a round trip through MarkdownToADF (e.g. a + // paragraph "# not a heading" reparses as a real heading), even + // though escapeMDText already covers inline-significant characters. + for _, tc := range []struct { + name, text, want string + }{ + {"heading hash", "# not a heading", `\# not a heading`}, + {"bullet dash", "- not a list item", `\- not a list item`}, + {"blockquote", "> not a quote", `\> not a quote`}, + {"plus list", "+ not a list item", `\+ not a list item`}, + {"ordered list dot", "1. not a list item", `1\. not a list item`}, + {"ordered list paren", "1) not a list item", `1\) not a list item`}, + {"mid-text hash not escaped", "see the C# language", "see the C# language"}, + {"mid-text dash not escaped", "well-known issue", "well-known issue"}, + } { + adf := map[string]any{ + "type": "paragraph", + "content": []any{map[string]any{"type": "text", "text": tc.text}}, + } + got := ADFToMarkdown(adf) + if got != tc.want { + t.Errorf("%s: ADFToMarkdown(%q) = %q, want %q", tc.name, tc.text, got, tc.want) + } + } +} + +func TestADFToMarkdown_LineLeadingBlockSyntaxRoundTripsAsParagraph(t *testing.T) { + for _, text := range []string{"# not a heading", "- not a list item", "> not a quote", "1. not a list item"} { + adf := map[string]any{ + "type": "paragraph", + "content": []any{map[string]any{"type": "text", "text": text}}, + } + md := ADFToMarkdown(adf) + doc, err := MarkdownToADF(md) + if err != nil { + t.Fatalf("MarkdownToADF(%q) returned unexpected error: %v", md, err) + } + block := asMap(t, asSlice(t, doc["content"])[0]) + if block["type"] != "paragraph" { + t.Errorf("round trip of %q via markdown %q reparsed as %v, want paragraph", text, md, block["type"]) + } + } +} + +func TestADFToMarkdown_EscapesAmpersand(t *testing.T) { + // escapeMDText doesn't escape "&"; textValue on the MarkdownToADF + // side resolves HTML entity/numeric references, so literal "©" + // text mutates into "©" on a round trip without escaping. + adf := map[string]any{ + "type": "paragraph", + "content": []any{map[string]any{"type": "text", "text": "© and & stay literal"}}, + } + got := ADFToMarkdown(adf) + want := `\© and \& stay literal` + if got != want { + t.Errorf("ADFToMarkdown(ampersand text) = %q, want %q", got, want) + } + + doc, err := MarkdownToADF(got) + if err != nil { + t.Fatalf("MarkdownToADF(%q) returned unexpected error: %v", got, err) + } + para := asMap(t, asSlice(t, doc["content"])[0]) + text := asMap(t, asSlice(t, para["content"])[0]) + wantText := "© and & stay literal" + if text["text"] != wantText { + t.Errorf("round trip text = %q, want %q", text["text"], wantText) + } +} + +func TestADFToMarkdown_NoEscapeInsideCodeMark(t *testing.T) { + // Text inside a code span is verbatim in Markdown; escaping would + // insert visible backslashes. + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{ + "type": "text", + "text": "a * b", + "marks": []any{map[string]any{"type": "code"}}, + }, + }, + }, + }, + } + got := ADFToMarkdown(adf) + want := "`a * b`" + if got != want { + t.Errorf("ADFToMarkdown(code with markdown chars) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_CoalescesAdjacentSameMarkedTextNodes(t *testing.T) { + // Real Jira-Cloud-authored ADF commonly splits one bold run across + // adjacent text nodes carrying identical marks. Wrapping each node + // independently produces "**hello ****world**", which re-parses + // with the middle "****" as literal asterisks rather than as one + // continuous bold run. + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{"type": "text", "text": "hello ", "marks": []any{map[string]any{"type": "strong"}}}, + map[string]any{"type": "text", "text": "world", "marks": []any{map[string]any{"type": "strong"}}}, + }, + }, + }, + } + got := ADFToMarkdown(adf) + want := "**hello world**" + if got != want { + t.Errorf("ADFToMarkdown(adjacent same-marked text) = %q, want %q", got, want) + } + + doc, err := MarkdownToADF(got) + if err != nil { + t.Fatalf("MarkdownToADF(%q) returned unexpected error: %v", got, err) + } + para := asMap(t, asSlice(t, doc["content"])[0]) + text := asMap(t, asSlice(t, para["content"])[0]) + if text["text"] != "hello world" { + t.Errorf("round trip text = %q, want %q", text["text"], "hello world") + } + marks := asSlice(t, text["marks"]) + if len(marks) != 1 || asMap(t, marks[0])["type"] != "strong" { + t.Errorf("round trip marks = %v, want a single strong mark", marks) + } +} + +func TestADFToMarkdown_MarkSpanningSoftBreakRoundTrips(t *testing.T) { + // walkInline's soft-break case emits a " " text node carrying + // inherited marks, so writing "**bold\nsoft**" through + // MarkdownToADF produces three adjacent strong-marked text nodes: + // "bold", " ", "soft". Reading that back naively (wrapping each + // independently) produces "**bold**** ****soft**", which does not + // round trip. + src := "**bold\nsoft**" + doc := mustADF(t, src) + got := ADFToMarkdown(doc) + want := "**bold soft**" + if got != want { + t.Errorf("ADFToMarkdown(MarkdownToADF(%q)) = %q, want %q", src, got, want) + } +} + +func TestADFToMarkdown_HoistsWhitespaceOutsideEmphasisDelimiters(t *testing.T) { + // A single marked text node with leading/trailing whitespace can't + // be wrapped directly ("**hello **") since CommonMark's flanking + // rule refuses to treat a "**" preceded by whitespace as a closer; + // the whitespace must be hoisted outside the delimiters. + adf := map[string]any{ + "type": "paragraph", + "content": []any{map[string]any{"type": "text", "text": " hello ", "marks": []any{map[string]any{"type": "strong"}}}}, + } + got := ADFToMarkdown(adf) + want := " **hello** " + if got != want { + t.Errorf("ADFToMarkdown(leading/trailing whitespace strong) = %q, want %q", got, want) + } + + doc, err := MarkdownToADF(got) + if err != nil { + t.Fatalf("MarkdownToADF(%q) returned unexpected error: %v", got, err) + } + para := asMap(t, asSlice(t, doc["content"])[0]) + content := asSlice(t, para["content"]) + var boldFound bool + for _, item := range content { + m := asMap(t, item) + if m["text"] == "hello" { + marks := asSlice(t, m["marks"]) + if len(marks) == 1 && asMap(t, marks[0])["type"] == "strong" { + boldFound = true + } + } + } + if !boldFound { + t.Errorf("round trip content = %v, want a strong-marked %q text node", content, "hello") + } +} + +func TestADFToMarkdown_RoundTripsThroughMarkdownToADF(t *testing.T) { + for _, src := range []string{ + "hello world", + "**bold** and *italic* and `code`", + "# Heading\n\nsome body text", + "- one\n- two", + "> quoted text", + "[the docs](https://example.com/docs)", + } { + doc := mustADF(t, src) + got := ADFToMarkdown(doc) + if got != src { + t.Errorf("ADFToMarkdown(MarkdownToADF(%q)) = %q, want the original markdown back unchanged", src, got) + } + } +} diff --git a/internal/forge/jira/client.go b/internal/forge/jira/client.go index a0ac40c065..2cabdb497c 100644 --- a/internal/forge/jira/client.go +++ b/internal/forge/jira/client.go @@ -57,9 +57,11 @@ func WithHTTPClient(client *http.Client) Option { } } -// validateBaseURL checks that the base URL uses https, unless it points to a +// ValidateBaseURL checks that the base URL uses https, unless it points to a // loopback address (for httptest servers), and rejects embedded credentials. -func validateBaseURL(rawURL string) error { +// Exported so other Jira-backed clients (e.g. tracker.JiraClient) can apply +// the same policy to base URLs they accept. +func ValidateBaseURL(rawURL string) error { u, err := url.Parse(rawURL) if err != nil { return fmt.Errorf("invalid base URL: %w", err) @@ -124,7 +126,7 @@ func New(token string, opts ...Option) (*LiveClient, error) { if c.baseURL == "" { return nil, fmt.Errorf("jira: base URL must be set via WithBaseURL") } - if err := validateBaseURL(c.baseURL); err != nil { + if err := ValidateBaseURL(c.baseURL); err != nil { return nil, err } return c, nil @@ -456,6 +458,51 @@ func (c *LiveClient) ListComments(ctx context.Context, issueIDOrKey string) ([]C return all, nil } +// commentRequest is the request body shape for creating or updating a +// comment: Jira Cloud only accepts a comment body in ADF, not markdown. +type commentRequest struct { + Body map[string]any `json:"body"` +} + +// CreateComment adds a new comment to an issue. body is markdown; it's +// converted to ADF before being sent, since Jira Cloud doesn't accept +// markdown directly. +func (c *LiveClient) CreateComment(ctx context.Context, issueIDOrKey, body string) (*Comment, error) { + adf, err := MarkdownToADF(body) + if err != nil { + return nil, fmt.Errorf("convert comment body to ADF: %w", err) + } + reqBody, err := json.Marshal(commentRequest{Body: adf}) + if err != nil { + return nil, fmt.Errorf("marshal create comment request: %w", err) + } + var comment Comment + path := "/issue/" + url.PathEscape(issueIDOrKey) + "/comment" + if err := c.do(ctx, http.MethodPost, path, bytes.NewReader(reqBody), &comment); err != nil { + return nil, fmt.Errorf("create comment on %s: %w", issueIDOrKey, err) + } + return &comment, nil +} + +// UpdateComment replaces the body of an existing comment. body is +// markdown; it's converted to ADF before being sent, for the same reason +// as CreateComment. +func (c *LiveClient) UpdateComment(ctx context.Context, issueIDOrKey, commentID, body string) error { + adf, err := MarkdownToADF(body) + if err != nil { + return fmt.Errorf("convert comment body to ADF: %w", err) + } + reqBody, err := json.Marshal(commentRequest{Body: adf}) + if err != nil { + return fmt.Errorf("marshal update comment request: %w", err) + } + path := "/issue/" + url.PathEscape(issueIDOrKey) + "/comment/" + url.PathEscape(commentID) + if err := c.do(ctx, http.MethodPut, path, bytes.NewReader(reqBody), nil); err != nil { + return fmt.Errorf("update comment %s on %s: %w", commentID, issueIDOrKey, err) + } + return nil +} + // ListChangelog fetches all changelog entries for an issue, exhausting // pagination up to maxListPages pages. func (c *LiveClient) ListChangelog(ctx context.Context, issueIDOrKey string) ([]ChangelogEntry, error) { diff --git a/internal/forge/jira/comment_test.go b/internal/forge/jira/comment_test.go new file mode 100644 index 0000000000..bde1c2d5d7 --- /dev/null +++ b/internal/forge/jira/comment_test.go @@ -0,0 +1,95 @@ +package jira + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/fullsend-ai/fullsend/internal/forge" +) + +func TestCreateComment(t *testing.T) { + t.Parallel() + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/rest/api/3/issue/PROJ-1/comment", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + adfBody, ok := body["body"].(map[string]any) + require.True(t, ok, "request body's \"body\" field should be an ADF doc, got: %+v", body["body"]) + assert.Equal(t, "doc", adfBody["type"]) + + writeJSON(t, w, http.StatusCreated, Comment{ + ID: "10001", + Body: adfBody, + Author: User{DisplayName: "fullsend-bot"}, + Created: "2026-08-06T00:00:00.000+0000", + }) + }) + + comment, err := client.CreateComment(ctx, "PROJ-1", "hello there") + require.NoError(t, err) + assert.Equal(t, "10001", comment.ID) + assert.Equal(t, "fullsend-bot", comment.Author.DisplayName) +} + +func TestCreateComment_NotFound(t *testing.T) { + t.Parallel() + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/rest/api/3/issue/PROJ-999/comment", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusNotFound, map[string]any{ + "errorMessages": []string{"Issue does not exist or you do not have permission to see it."}, + }) + }) + + _, err := client.CreateComment(ctx, "PROJ-999", "hello there") + require.Error(t, err) + assert.True(t, errors.Is(err, forge.ErrNotFound)) +} + +func TestUpdateComment(t *testing.T) { + t.Parallel() + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/rest/api/3/issue/PROJ-1/comment/10001", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + adfBody, ok := body["body"].(map[string]any) + require.True(t, ok, "request body's \"body\" field should be an ADF doc, got: %+v", body["body"]) + assert.Equal(t, "doc", adfBody["type"]) + + w.WriteHeader(http.StatusOK) + }) + + err := client.UpdateComment(ctx, "PROJ-1", "10001", "updated text") + require.NoError(t, err) +} + +func TestUpdateComment_NotFound(t *testing.T) { + t.Parallel() + client, mux := setupTest(t) + ctx := context.Background() + + mux.HandleFunc("/rest/api/3/issue/PROJ-1/comment/99999", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusNotFound, map[string]any{ + "errorMessages": []string{"The comment does not exist."}, + }) + }) + + err := client.UpdateComment(ctx, "PROJ-1", "99999", "updated text") + require.Error(t, err) + assert.True(t, errors.Is(err, forge.ErrNotFound)) +} diff --git a/internal/tracker/jira_client.go b/internal/tracker/jira_client.go new file mode 100644 index 0000000000..a5a24eb0be --- /dev/null +++ b/internal/tracker/jira_client.go @@ -0,0 +1,170 @@ +package tracker + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/fullsend-ai/fullsend/internal/forge/jira" +) + +// jiraTimestampFormats are the layouts Jira Cloud has been observed to +// use for created/updated timestamps. Duplicated from +// jirapoll/discover.go's unexported jiraTimestampFormats/ +// parseJiraTimestamp rather than shared, for the same reason +// adf.go/jirapoll's ADF walkers stay duplicated for now: consolidating +// onto one shared helper is a reasonable follow-up once the Jira tracker +// integration stabilizes. +var jiraTimestampFormats = []string{ + "2006-01-02T15:04:05.000-0700", + time.RFC3339, +} + +// parseJiraTimestamp parses a Jira timestamp string. +func parseJiraTimestamp(s string) (time.Time, error) { + for _, format := range jiraTimestampFormats { + if t, err := time.Parse(format, s); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("unparseable Jira timestamp: %q", s) +} + +// jiraClient is the Jira API surface this adapter needs. Implemented by +// jira.LiveClient; faked in tests. +type jiraClient interface { + GetIssue(ctx context.Context, issueIDOrKey string) (*jira.Issue, error) + ListComments(ctx context.Context, issueIDOrKey string) ([]jira.Comment, error) + CreateComment(ctx context.Context, issueIDOrKey, body string) (*jira.Comment, error) + UpdateComment(ctx context.Context, issueIDOrKey, commentID, body string) error +} + +var _ jiraClient = (*jira.LiveClient)(nil) + +// JiraClient adapts a Jira client to tracker.Client. project is a Jira +// project key (e.g. "PROJ"); (project, number) maps to the issue key +// "PROJ-123". +type JiraClient struct { + jira jiraClient + baseURL string // for browse URLs: {baseURL}/browse/{key} +} + +// NewJiraClient returns a tracker.Client backed by jc. baseURL is the +// Jira instance's base URL (e.g. "https://acme.atlassian.net"), used to +// build issue browse URLs. Returns an error if baseURL embeds credentials +// (https://user:token@host): those would otherwise propagate into every +// Issue.URL this client returns, which are commonly logged or displayed. +func NewJiraClient(jc jiraClient, baseURL string) (*JiraClient, error) { + trimmed := strings.TrimRight(baseURL, "/") + if err := jira.ValidateBaseURL(trimmed); err != nil { + return nil, err + } + return &JiraClient{jira: jc, baseURL: trimmed}, nil +} + +// issueKey builds a Jira issue key from a project key and issue number, +// e.g. issueKey("PROJ", 123) = "PROJ-123". +func issueKey(project string, number int) string { + return fmt.Sprintf("%s-%d", project, number) +} + +// GetIssue implements Client. +func (c *JiraClient) GetIssue(ctx context.Context, project string, number int) (*Issue, error) { + key := issueKey(project, number) + issue, err := c.jira.GetIssue(ctx, key) + if err != nil { + return nil, wrapNotFound(err) + } + return &Issue{ + Number: number, + Title: issue.Fields.Summary, + Body: Body(jira.ADFToMarkdown(issue.Fields.Description)), + URL: c.baseURL + "/browse/" + key, + Labels: issue.Fields.Labels, + }, nil +} + +// ListComments implements Client. +func (c *JiraClient) ListComments(ctx context.Context, project string, number int) ([]Comment, error) { + key := issueKey(project, number) + comments, err := c.jira.ListComments(ctx, key) + if err != nil { + return nil, wrapNotFound(err) + } + result := make([]Comment, len(comments)) + for i, comment := range comments { + result[i] = fromJiraComment(comment) + } + return result, nil +} + +// CreateComment implements Client. The returned Comment's Body is +// ADFToMarkdown(comment.Body), the same representation ListComments uses, +// so tracker.Comment.Body has a stable meaning regardless of which method +// produced it. +func (c *JiraClient) CreateComment(ctx context.Context, project string, number int, body Body) (*Comment, error) { + key := issueKey(project, number) + comment, err := c.jira.CreateComment(ctx, key, string(body)) + if err != nil { + return nil, wrapNotFound(err) + } + result := fromJiraComment(*comment) + return &result, nil +} + +// UpdateComment implements Client. Unlike GitHub/GitLab, Jira's +// update-comment endpoint needs the issue key, which is why number is +// part of the signature here. +func (c *JiraClient) UpdateComment(ctx context.Context, project string, number int, commentID string, body Body) error { + key := issueKey(project, number) + return wrapNotFound(c.jira.UpdateComment(ctx, key, commentID, string(body))) +} + +// fromJiraComment converts a jira.Comment to a tracker.Comment. HTMLURL is +// deliberately left empty: Jira's comment permalink format isn't confirmed +// against real Jira Cloud behavior, so rather than guess at a URL shape +// that might be wrong, it's left unset instead of risking a broken link. +// +// Author is UpdateAuthor when the comment has been edited, not Author: +// someone with Edit-All-Comments can rewrite another user's comment, and +// this is adapted from jirapoll/discover.go's attribute-to-the-editor +// logic (ADR 0054) so edited content isn't misattributed to the original +// author. Note that tracker.Comment.Author is a display name only — +// unlike jirapoll, which authorizes on the stable AccountID, nothing here +// makes Author a trustworthy identifier for authorization decisions; a +// future consumer needing that would have to add one. +func fromJiraComment(c jira.Comment) Comment { + author := c.Author.DisplayName + if commentEdited(c) { + author = c.UpdateAuthor.DisplayName + } + return Comment{ + ID: c.ID, + Body: Body(jira.ADFToMarkdown(c.Body)), + Author: author, + CreatedAt: c.Created, + } +} + +// commentEdited reports whether c has genuinely been edited since +// creation. UpdateAuthor.AccountID alone is Jira's signal for this +// (observed Cloud behavior, not a documented contract — see +// jirapoll/discover.go's identical hedge), so this additionally requires +// Updated to parse and be after Created, mirroring jirapoll's own +// edit-detection gate for defense in depth against that signal alone +// being wrong. +func commentEdited(c jira.Comment) bool { + if c.UpdateAuthor.AccountID == "" { + return false + } + created, err := parseJiraTimestamp(c.Created) + if err != nil { + return false + } + updated, err := parseJiraTimestamp(c.Updated) + if err != nil { + return false + } + return updated.After(created) +} diff --git a/internal/tracker/jira_client_test.go b/internal/tracker/jira_client_test.go new file mode 100644 index 0000000000..8b10ead386 --- /dev/null +++ b/internal/tracker/jira_client_test.go @@ -0,0 +1,315 @@ +package tracker + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/fullsend-ai/fullsend/internal/forge" + "github.com/fullsend-ai/fullsend/internal/forge/jira" +) + +// fakeJiraClient is a hand-written fake implementing the jiraClient +// interface, mirroring how internal/jirapoll's tests mock JiraClient. +type fakeJiraClient struct { + issues map[string]*jira.Issue + comments map[string][]jira.Comment + + createdBody string + updatedIssueKey string + updatedCommentID string + updatedBody string +} + +func (f *fakeJiraClient) GetIssue(_ context.Context, issueIDOrKey string) (*jira.Issue, error) { + issue, ok := f.issues[issueIDOrKey] + if !ok { + return nil, fmt.Errorf("get issue %s: %w", issueIDOrKey, forge.ErrNotFound) + } + return issue, nil +} + +func (f *fakeJiraClient) ListComments(_ context.Context, issueIDOrKey string) ([]jira.Comment, error) { + return f.comments[issueIDOrKey], nil +} + +func (f *fakeJiraClient) CreateComment(_ context.Context, issueIDOrKey, body string) (*jira.Comment, error) { + f.createdBody = body + adf, err := jira.MarkdownToADF(body) // mirrors Jira echoing back the ADF it stored + if err != nil { + return nil, err + } + comment := jira.Comment{ + ID: "50001", + Body: adf, + Author: jira.User{DisplayName: "fullsend-bot"}, + Created: "2026-08-06T00:00:00.000+0000", + } + if f.comments == nil { + f.comments = make(map[string][]jira.Comment) + } + f.comments[issueIDOrKey] = append(f.comments[issueIDOrKey], comment) + return &comment, nil +} + +func (f *fakeJiraClient) UpdateComment(_ context.Context, issueIDOrKey, commentID, body string) error { + f.updatedIssueKey = issueIDOrKey + f.updatedCommentID = commentID + f.updatedBody = body + return nil +} + +var _ jiraClient = (*fakeJiraClient)(nil) + +// newTestJiraClient constructs a JiraClient for tests, failing immediately +// on a validation error so call sites can stay a single line. +func newTestJiraClient(t *testing.T, jc jiraClient, baseURL string) *JiraClient { + t.Helper() + c, err := NewJiraClient(jc, baseURL) + if err != nil { + t.Fatalf("NewJiraClient(%q) returned error: %v", baseURL, err) + } + return c +} + +func TestNewJiraClient_RejectsCredentialBaseURL(t *testing.T) { + _, err := NewJiraClient(&fakeJiraClient{}, "https://user:token@acme.atlassian.net") + if err == nil { + t.Fatal("NewJiraClient with credential-bearing base URL: got nil error, want an error") + } +} + +func TestJiraClient_GetIssue(t *testing.T) { + fc := &fakeJiraClient{ + issues: map[string]*jira.Issue{ + "PROJ-42": { + Key: "PROJ-42", + Fields: jira.IssueFields{ + Summary: "Widget is broken", + Description: map[string]any{ + "type": "doc", + "version": 1, + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{"type": "text", "text": "details here"}, + }, + }, + }, + }, + Labels: []string{"bug"}, + }, + }, + }, + } + + c := newTestJiraClient(t, fc, "https://acme.atlassian.net") + issue, err := c.GetIssue(context.Background(), "PROJ", 42) + if err != nil { + t.Fatalf("GetIssue returned error: %v", err) + } + if issue.Number != 42 { + t.Errorf("issue.Number = %d, want 42", issue.Number) + } + if issue.Title != "Widget is broken" { + t.Errorf("issue.Title = %q, want %q", issue.Title, "Widget is broken") + } + if issue.Body != "details here" { + t.Errorf("issue.Body = %q, want %q", issue.Body, "details here") + } + if len(issue.Labels) != 1 || issue.Labels[0] != "bug" { + t.Errorf("issue.Labels = %+v, want [bug]", issue.Labels) + } + if issue.URL != "https://acme.atlassian.net/browse/PROJ-42" { + t.Errorf("issue.URL = %q, want %q", issue.URL, "https://acme.atlassian.net/browse/PROJ-42") + } +} + +func TestJiraClient_GetIssue_NotFound(t *testing.T) { + fc := &fakeJiraClient{issues: map[string]*jira.Issue{}} + c := newTestJiraClient(t, fc, "https://acme.atlassian.net") + + _, err := c.GetIssue(context.Background(), "PROJ", 999) + if !IsNotFound(err) { + t.Errorf("GetIssue error = %v, want tracker.IsNotFound", err) + } +} + +func TestJiraClient_ListComments(t *testing.T) { + fc := &fakeJiraClient{ + comments: map[string][]jira.Comment{ + "PROJ-42": { + { + ID: "1", + Body: "plain string body", + Author: jira.User{DisplayName: "Jane Doe"}, + Created: "2026-08-06T00:00:00.000+0000", + }, + }, + }, + } + c := newTestJiraClient(t, fc, "https://acme.atlassian.net") + + comments, err := c.ListComments(context.Background(), "PROJ", 42) + if err != nil { + t.Fatalf("ListComments returned error: %v", err) + } + if len(comments) != 1 { + t.Fatalf("len(comments) = %d, want 1", len(comments)) + } + if comments[0].Body != "plain string body" { + t.Errorf("comments[0].Body = %q, want %q", comments[0].Body, "plain string body") + } + if comments[0].Author != "Jane Doe" { + t.Errorf("comments[0].Author = %q, want %q", comments[0].Author, "Jane Doe") + } +} + +func TestJiraClient_ListComments_EditedCommentAttributesToEditor(t *testing.T) { + // Jira sets UpdateAuthor when a comment has been edited, which may + // differ from Author if someone with Edit-All-Comments rewrote + // another user's comment. Consumers of tracker.Comment.Author must + // see the editor's identity, not the original author's, mirroring + // jirapoll/discover.go's edit-attribution logic (ADR 0054) so a + // rewritten comment can't be misattributed to whoever originally + // posted it. + fc := &fakeJiraClient{ + comments: map[string][]jira.Comment{ + "PROJ-42": { + { + ID: "1", + Body: "edited body", + Author: jira.User{DisplayName: "Original Author"}, + UpdateAuthor: jira.User{AccountID: "acct-2", DisplayName: "Editor"}, + Created: "2026-08-06T00:00:00.000+0000", + Updated: "2026-08-06T01:00:00.000+0000", + }, + { + ID: "2", + Body: "unedited body", + Author: jira.User{DisplayName: "Original Author"}, + Created: "2026-08-06T00:00:00.000+0000", + }, + }, + }, + } + c := newTestJiraClient(t, fc, "https://acme.atlassian.net") + + comments, err := c.ListComments(context.Background(), "PROJ", 42) + if err != nil { + t.Fatalf("ListComments returned error: %v", err) + } + if len(comments) != 2 { + t.Fatalf("len(comments) = %d, want 2", len(comments)) + } + if comments[0].Author != "Editor" { + t.Errorf("edited comment Author = %q, want %q (the editor, not the original author)", comments[0].Author, "Editor") + } + if comments[1].Author != "Original Author" { + t.Errorf("unedited comment Author = %q, want %q (UpdateAuthor unset, so Author stands)", comments[1].Author, "Original Author") + } +} + +func TestJiraClient_ListComments_UpdateAuthorIgnoredWithoutLaterUpdatedTimestamp(t *testing.T) { + // UpdateAuthor.AccountID alone isn't a reliable edit signal — mirror + // jirapoll/discover.go's defense-in-depth gate of also requiring + // Updated to parse and be after Created before trusting it. + fc := &fakeJiraClient{ + comments: map[string][]jira.Comment{ + "PROJ-42": { + { + ID: "1", + Body: "not actually edited", + Author: jira.User{DisplayName: "Original Author"}, + UpdateAuthor: jira.User{AccountID: "acct-2", DisplayName: "Editor"}, + Created: "2026-08-06T00:00:00.000+0000", + Updated: "2026-08-06T00:00:00.000+0000", + }, + { + ID: "2", + Body: "unparseable updated timestamp", + Author: jira.User{DisplayName: "Original Author"}, + UpdateAuthor: jira.User{AccountID: "acct-2", DisplayName: "Editor"}, + Created: "2026-08-06T00:00:00.000+0000", + Updated: "not-a-timestamp", + }, + }, + }, + } + c := newTestJiraClient(t, fc, "https://acme.atlassian.net") + + comments, err := c.ListComments(context.Background(), "PROJ", 42) + if err != nil { + t.Fatalf("ListComments returned error: %v", err) + } + if len(comments) != 2 { + t.Fatalf("len(comments) = %d, want 2", len(comments)) + } + if comments[0].Author != "Original Author" { + t.Errorf("Updated == Created comment Author = %q, want %q (not after Created, so not treated as edited)", comments[0].Author, "Original Author") + } + if comments[1].Author != "Original Author" { + t.Errorf("unparseable Updated comment Author = %q, want %q (can't confirm edit, so not treated as edited)", comments[1].Author, "Original Author") + } +} + +func TestJiraClient_CreateComment(t *testing.T) { + fc := &fakeJiraClient{} + c := newTestJiraClient(t, fc, "https://acme.atlassian.net") + + comment, err := c.CreateComment(context.Background(), "PROJ", 42, "**hello** there") + if err != nil { + t.Fatalf("CreateComment returned error: %v", err) + } + if fc.createdBody != "**hello** there" { + t.Errorf("underlying jira client received body %q, want %q", fc.createdBody, "**hello** there") + } + if _, ok := fc.comments["PROJ-42"]; !ok { + t.Fatal("expected comment to be created against issue key PROJ-42") + } + if comment.Body != "**hello** there" { + t.Errorf("comment.Body = %q, want %q (Markdown, consistent with ListComments)", comment.Body, "**hello** there") + } +} + +func TestJiraClient_UpdateComment(t *testing.T) { + fc := &fakeJiraClient{} + c := newTestJiraClient(t, fc, "https://acme.atlassian.net") + + if err := c.UpdateComment(context.Background(), "PROJ", 42, "50001", "updated text"); err != nil { + t.Fatalf("UpdateComment returned error: %v", err) + } + if fc.updatedIssueKey != "PROJ-42" { + t.Errorf("underlying jira client received issue key %q, want %q", fc.updatedIssueKey, "PROJ-42") + } + if fc.updatedCommentID != "50001" { + t.Errorf("underlying jira client received comment ID %q, want %q", fc.updatedCommentID, "50001") + } + if fc.updatedBody != "updated text" { + t.Errorf("underlying jira client received body %q, want %q", fc.updatedBody, "updated text") + } +} + +func TestJiraClient_NotFoundWrapping(t *testing.T) { + // JiraClient must wrap forge.ErrNotFound into tracker.ErrNotFound so + // callers using tracker.IsNotFound get the expected result. Verify + // that the wrapper also preserves the underlying forge.ErrNotFound. + fc := &fakeJiraClient{issues: map[string]*jira.Issue{}} + c := newTestJiraClient(t, fc, "https://acme.atlassian.net") + + _, err := c.GetIssue(context.Background(), "PROJ", 999) + if err == nil { + t.Fatal("GetIssue on missing issue: got nil error, want not-found") + } + if !IsNotFound(err) { + t.Errorf("GetIssue error does not satisfy tracker.IsNotFound: %v", err) + } + // The underlying forge.ErrNotFound should still be reachable for debug. + if !errors.Is(err, forge.ErrNotFound) { + t.Errorf("GetIssue error does not unwrap to forge.ErrNotFound: %v", err) + } +} + +var _ Client = (*JiraClient)(nil) diff --git a/pkg/e2etest/cleanup.go b/pkg/e2etest/cleanup.go index 923be5c783..309215a22b 100644 --- a/pkg/e2etest/cleanup.go +++ b/pkg/e2etest/cleanup.go @@ -104,6 +104,16 @@ func CleanupStaleResources(ctx context.Context, client forge.Client, token, org t.Logf("[cleanup] Warning: could not delete per-repo guard variable: %v", delErr) } + // 8. Delete stale repo-level FULLSEND_MINT_URL variable from test-repo. + // Per-repo install drivers (e.g. cfmint) write this as a repo variable, + // which takes precedence over the org-level FULLSEND_MINT_URL set by + // admin install. Ephemeral preview mints (cfmint) are torn down at the + // end of their run, so a leaked repo variable points to a dead URL and + // breaks dispatch for any later run that reuses this org. + if delErr := client.DeleteRepoVariable(ctx, org, TestRepo, "FULLSEND_MINT_URL"); delErr != nil { + t.Logf("[cleanup] Warning: could not delete stale repo-level mint URL variable: %v", delErr) + } + t.Log("[cleanup] Stale resource scan complete") } @@ -117,6 +127,14 @@ func TeardownPerRepoInstall(ctx context.Context, client forge.Client, token, org deleteBranch(ctx, token, org, repo, "fullsend/onboard", log) deleteBranch(ctx, token, org, repo, "fullsend/offboard", log) deleteShimWorkflow(ctx, token, org, repo, log) + // Delete the repo-level FULLSEND_MINT_URL variable written by + // per-repo install drivers. It takes precedence over org-level + // FULLSEND_MINT_URL, so leaving it behind breaks dispatch for any + // later run (e.g. an org-level admin install) that reuses this repo + // from a shared org pool. + if delErr := client.DeleteRepoVariable(ctx, org, repo, "FULLSEND_MINT_URL"); delErr != nil { + log.Logf("[cleanup] Warning: could not delete repo-level mint URL variable: %v", delErr) + } prs, err := client.ListRepoPullRequests(ctx, org, repo) if err != nil { log.Logf("[cleanup] Warning: could not list PRs: %v", err)