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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions tools/jtk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Raw ADF JSON passthrough for `--description` (`issues create`/`update`), `--body` (`comments add`), and rich-text `--field` overrides: a value that parses as `{"type":"doc","version":1,...}` with content is sent as a structured ADF document instead of going through markdown conversion, so documents with node types markdown can't express (e.g. `inlineCard` smart links) can be written directly. ([#486](https://github.com/open-cli-collective/atlassian-cli/pull/486))
- `issues get` now accepts multiple issue keys and renders a summary table for the batch. ([#327](https://github.com/open-cli-collective/atlassian-cli/pull/327))
- `issues check <issue-key>` subcommand to audit an issue for populated/missing field values, with `--require` (hard-fail) and `--warn` (advisory) flags. A curated default warn-list (Summary, Description, Assignee, Priority, Labels, Story Points, Sprint, Components, Fix Version/s) applies when no flags are passed. Useful as a transition guardrail or CI step.
- `users get <account-id>` subcommand to look up a user by account ID ([#189](https://github.com/open-cli-collective/atlassian-cli/pull/189))
Expand Down
6 changes: 3 additions & 3 deletions tools/jtk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ jtk issues create -p MYPROJECT -t Task -s "Their task" --assignee "Aaron Wong"
| `--project` | `-p` | | Project key or name (**required**) |
| `--type` | `-t` | `Task` | Issue type: `Task`, `Bug`, `Story`, etc. |
| `--summary` | `-s` | | Issue summary (**required**) |
| `--description` | `-d` | | Issue description (supports `\n`, `\t`, `\\` escape sequences) |
| `--description` | `-d` | | Issue description (supports `\n`, `\t`, `\\` escape sequences; input recognized as a raw ADF document — `{"type":"doc","version":1,...}` — is sent as structured ADF and skips escape interpretation) |
| `--parent` | | | Parent issue key (epic or parent issue) |
| `--assignee` | `-a` | | Assignee (account ID, email, display name, or `"me"`) |
| `--field` | `-f` | | Additional field in `key=value` format (can be repeated) |
Expand Down Expand Up @@ -398,7 +398,7 @@ jtk issues update PROJ-123 --field customfield_10050=Option1 --field customfield
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--summary` | `-s` | | New summary |
| `--description` | `-d` | | New description (supports `\n`, `\t`, `\\` escape sequences) |
| `--description` | `-d` | | New description (supports `\n`, `\t`, `\\` escape sequences; input recognized as a raw ADF document — `{"type":"doc","version":1,...}` — is sent as structured ADF and skips escape interpretation) |
| `--parent` | | | Parent issue key (epic or parent issue) |
| `--assignee` | `-a` | | Assignee (account ID, email, display name, `"me"`, or `"none"` to unassign) |
| `--type` | `-t` | | New issue type (uses Jira Cloud bulk move API) |
Expand Down Expand Up @@ -797,7 +797,7 @@ jtk comments add PROJ-123 --body "Line one\nLine two\n\tIndented line"

| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--body` | `-b` | | Comment text (supports `\n`, `\t`, `\\` escape sequences) (**required**) |
| `--body` | `-b` | | Comment text (supports `\n`, `\t`, `\\` escape sequences; input recognized as a raw ADF document — `{"type":"doc","version":1,...}` — is sent as structured ADF and skips escape interpretation) (**required**) |

**Arguments:**
- `<issue-key>` - The issue key (**required**)
Expand Down
43 changes: 43 additions & 0 deletions tools/jtk/api/fields_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,49 @@ func TestFormatFieldValue_TextareaField(t *testing.T) {
testutil.Equal(t, adf.Content[0].Type, "paragraph")
}

// TestFormatFieldValue_TextareaField_RawADFPassthrough verifies that a
// rich-text --field override (a textarea custom field) accepts raw ADF JSON
// the same way --description does: preserved as a structured document,
// including an inlineCard node the markdown converter has no syntax to
// produce. See #484.
func TestFormatFieldValue_TextareaField_RawADFPassthrough(t *testing.T) {
field := &Field{
ID: "customfield_10046",
Name: "QA Notes",
Schema: FieldSchema{
Type: "string",
Custom: "com.atlassian.jira.plugin.system.customfieldtypes:textarea",
},
}

rawADF := `{
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{"type": "inlineCard", "attrs": {"url": "https://example.com/doc"}}
]
}
]
}`

got := FormatFieldValue(field, rawADF)

adf, ok := got.(*ADFDocument)
testutil.True(t, ok, fmt.Sprintf("expected *ADFDocument, got %T", got))
testutil.NotNil(t, adf)
testutil.Equal(t, adf.Type, "doc")
testutil.Len(t, adf.Content, 1)
para := adf.Content[0]
testutil.Equal(t, para.Type, "paragraph")
testutil.Len(t, para.Content, 1)
card := para.Content[0]
testutil.Equal(t, card.Type, "inlineCard")
testutil.Equal(t, card.Attrs["url"], "https://example.com/doc")
}

func TestClient_GetFieldOptionsFromEditMeta(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
testutil.Contains(t, r.URL.Path, "/issue/PROJ-123/editmeta")
Expand Down
55 changes: 54 additions & 1 deletion tools/jtk/api/markdown.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package api //nolint:revive // package name is intentional

import (
"encoding/json"

"github.com/open-cli-collective/atlassian-go/adf"
)

// MarkdownToADF converts markdown text to an Atlassian Document Format document.
// MarkdownToADF converts text to an Atlassian Document Format document.
// Supports: headings (h1-h6), paragraphs, bold, italic, strikethrough, code,
// code blocks, bullet lists, numbered lists, links, blockquotes, and tables.
//
Expand All @@ -14,11 +16,28 @@ import (
// This bias is intentional for mixed content from LLM agents and user input.
// Callers that know the input is wiki markup should call WikiToADFMarkdown +
// adf.ToDocumentWiki directly to bypass heuristics.
//
// Before any markdown handling, the input is checked for raw ADF: if it
// parses as JSON shaped like an ADF document ({"type":"doc","version":1,...}
// with at least one content node), it is unmarshaled into the internal ADF
// types and re-marshaled from them rather than returned as the original
// bytes. This lets callers pass through documents built by another tool
// (e.g. containing inlineCard nodes) without the markdown converter
// mangling them, but it is not a byte-for-byte passthrough: unknown node
// keys outside type/attrs/content/text/marks are dropped, and numeric attrs
// round-trip through float64. Spec-conformant ADF is unaffected. Anything
// else — invalid JSON, valid JSON that isn't a doc, a doc with version != 1
// or no content, or markdown that merely starts with "{" — falls through to
// markdown conversion unchanged.
func MarkdownToADF(markdown string) *ADFDocument {
if markdown == "" {
return nil
}

if doc, ok := parseRawADFDocument(markdown); ok {
return doc
}

// Auto-detect and convert wiki markup to markdown.
// Wiki-converted text uses the extended parser (subscript, superscript,
// insert) since ~text~ and ^text^ are intentional wiki formatting.
Expand All @@ -31,3 +50,37 @@ func MarkdownToADF(markdown string) *ADFDocument {

return adf.ToDocument(markdown)
}

// IsRawADFDocument reports whether text would be treated as raw ADF
// passthrough by MarkdownToADF. Callers that apply CLI-only text
// conveniences (such as escape-sequence interpretation for \n and \t)
// before handing text to NewADFDocument / MarkdownToADF should skip those
// conveniences when this returns true: escape interpretation is a markdown
// convenience, and applying it to raw ADF JSON first can corrupt it (e.g. by
// turning an escaped "\n" inside a JSON string into a literal newline byte)
// before the ADF parser ever sees it.
func IsRawADFDocument(text string) bool {
_, ok := parseRawADFDocument(text)
return ok
}

// parseRawADFDocument reports whether text is a pre-built ADF document
// passed through as raw JSON, returning the parsed document when it is.
// It requires the JSON to unmarshal cleanly into an ADF document shape with
// type "doc", version exactly 1 (the only version the ADF spec and issue
// #484 define), and at least one content node; anything else (invalid
// JSON, valid JSON of a different shape, an unrecognized version, or a
// content-less doc) is rejected so it falls through to markdown conversion.
// Content-less docs are rejected rather than passed through because
// marshaling a nil Content slice produces "content": null, which Jira's
// API rejects with a 400.
func parseRawADFDocument(text string) (*ADFDocument, bool) {
var doc ADFDocument
if err := json.Unmarshal([]byte(text), &doc); err != nil {
return nil, false
}
if doc.Type != "doc" || doc.Version != 1 || len(doc.Content) == 0 {
return nil, false
}
return &doc, true
}
170 changes: 170 additions & 0 deletions tools/jtk/api/markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -755,3 +755,173 @@ func TestMarkdownToADF_InlineOnlyWikiNotDetected(t *testing.T) {
})
}
}

// --- Raw ADF passthrough (#484) ---
//
// MarkdownToADF checks, before any markdown or wiki handling, whether the
// input is already a JSON-encoded ADF document ({"type":"doc","version":N,
// ...}). If so it is unmarshaled and returned unconverted, so callers can
// pass through documents built by another tool — including node types (like
// inlineCard) the markdown converter has no syntax to produce. Anything else
// falls through to the existing markdown/wiki conversion unchanged.

func TestMarkdownToADF_RawADFPassthrough_ValidDoc(t *testing.T) {
t.Parallel()
raw := `{
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{"type": "text", "text": "Hello from raw ADF"}
]
}
]
}`

result := MarkdownToADF(raw)
testutil.NotNil(t, result)
testutil.Equal(t, result.Type, "doc")
testutil.Equal(t, result.Version, 1)
testutil.Len(t, result.Content, 1)
testutil.Equal(t, result.Content[0].Type, "paragraph")
testutil.Len(t, result.Content[0].Content, 1)
testutil.Equal(t, result.Content[0].Content[0].Type, "text")
testutil.Equal(t, result.Content[0].Content[0].Text, "Hello from raw ADF")
}

// TestMarkdownToADF_RawADFPassthrough_InlineCard verifies that node types the
// markdown converter cannot itself produce (inlineCard has no markdown
// syntax) survive passthrough intact, attrs included.
func TestMarkdownToADF_RawADFPassthrough_InlineCard(t *testing.T) {
t.Parallel()
raw := `{
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{
"type": "inlineCard",
"attrs": {"url": "https://example.com/doc"}
}
]
}
]
}`

result := MarkdownToADF(raw)
testutil.NotNil(t, result)
testutil.Len(t, result.Content, 1)
para := result.Content[0]
testutil.Equal(t, para.Type, "paragraph")
testutil.Len(t, para.Content, 1)
card := para.Content[0]
testutil.Equal(t, card.Type, "inlineCard")
testutil.Equal(t, card.Attrs["url"], "https://example.com/doc")
}

// TestMarkdownToADF_RawADFPassthrough_InvalidJSON verifies malformed JSON
// falls through to markdown conversion rather than erroring.
func TestMarkdownToADF_RawADFPassthrough_InvalidJSON(t *testing.T) {
t.Parallel()
input := `{"type": "doc", "version": 1, "content": [` // truncated / malformed

result := MarkdownToADF(input)
testutil.NotNil(t, result)
// Falls through to the markdown converter's raw-text fallback: a single
// paragraph containing the literal input text.
testutil.Len(t, result.Content, 1)
testutil.Equal(t, result.Content[0].Type, "paragraph")
}

// TestMarkdownToADF_RawADFPassthrough_NotADoc verifies valid JSON that isn't
// shaped like a passthrough-eligible ADF document — wrong/missing "type", a
// JSON array, an unrecognized version (anything other than exactly 1), or a
// doc with no content nodes — is rejected by the detector and falls through
// to markdown conversion. The version-0 and no-content cases matter beyond
// the generic "not a doc" cases: version 0 is Go's int zero value, so it
// must be rejected the same as a missing version key; and a content-less
// doc must be rejected rather than passed through, since marshaling it
// would produce "content": null, which Jira's API 400s on.
func TestMarkdownToADF_RawADFPassthrough_NotADoc(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
}{
{name: "object with wrong type", input: `{"type": "paragraph"}`},
{name: "doc type but no version", input: `{"type": "doc", "content": []}`},
{name: "json array", input: `[1, 2, 3]`},
{name: "version 99", input: `{"type": "doc", "version": 99, "content": [{"type": "paragraph"}]}`},
{name: "version -1", input: `{"type": "doc", "version": -1, "content": [{"type": "paragraph"}]}`},
{name: "version 0", input: `{"type": "doc", "version": 0, "content": [{"type": "paragraph"}]}`},
{name: "version 1, no content", input: `{"type": "doc", "version": 1}`},
{name: "version 1, empty content", input: `{"type": "doc", "version": 1, "content": []}`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
testutil.False(t, IsRawADFDocument(tt.input), "should not be detected as raw ADF")

result := MarkdownToADF(tt.input)
testutil.NotNil(t, result)
// Went through markdown conversion, not raw-JSON passthrough: the
// literal JSON text becomes the fallback single-paragraph content.
testutil.Len(t, result.Content, 1)
testutil.Equal(t, result.Content[0].Type, "paragraph")
})
}
}

// TestMarkdownToADF_RawADFPassthrough_MarkdownStartingWithBrace verifies
// ordinary markdown/plain text that merely starts with "{" (but is not valid
// JSON) is treated as markdown, not rejected or mishandled.
func TestMarkdownToADF_RawADFPassthrough_MarkdownStartingWithBrace(t *testing.T) {
t.Parallel()
input := "{not json} just a sentence that starts with a brace"

testutil.False(t, IsRawADFDocument(input))

result := MarkdownToADF(input)
testutil.NotNil(t, result)
testutil.Len(t, result.Content, 1)
testutil.Equal(t, result.Content[0].Type, "paragraph")
testutil.Equal(t, result.Content[0].Content[0].Type, "text")
testutil.Equal(t, result.Content[0].Content[0].Text, input)
}

// TestIsRawADFDocument covers the detector directly (used by call sites that
// must skip CLI escape-sequence interpretation for raw ADF input — see
// IsRawADFDocument's doc comment).
func TestIsRawADFDocument(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want bool
}{
{name: "valid ADF doc", input: `{"type":"doc","version":1,"content":[{"type":"paragraph"}]}`, want: true},
{name: "valid ADF doc with inlineCard", input: `{"type":"doc","version":1,"content":[{"type":"inlineCard","attrs":{"url":"https://example.com"}}]}`, want: true},
{name: "invalid JSON", input: `{"type": "doc", "version": 1,`, want: false},
{name: "valid JSON, not a doc (paragraph)", input: `{"type":"paragraph"}`, want: false},
{name: "valid JSON, not a doc (array)", input: `[1,2,3]`, want: false},
{name: "markdown starting with brace", input: "{this is not json at all", want: false},
{name: "empty string", input: "", want: false},
{name: "version 99", input: `{"type":"doc","version":99,"content":[{"type":"paragraph"}]}`, want: false},
{name: "version -1", input: `{"type":"doc","version":-1,"content":[{"type":"paragraph"}]}`, want: false},
{name: "version 0", input: `{"type":"doc","version":0,"content":[{"type":"paragraph"}]}`, want: false},
{name: "version 1, no content", input: `{"type":"doc","version":1}`, want: false},
{name: "version 1, empty content", input: `{"type":"doc","version":1,"content":[]}`, want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
testutil.Equal(t, IsRawADFDocument(tt.input), tt.want)
})
}
}
6 changes: 4 additions & 2 deletions tools/jtk/internal/cmd/comments/comments.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ func newAddCmd(opts *root.Options) *cobra.Command {
},
}

cmd.Flags().StringVarP(&body, "body", "b", "", "Comment text (required)")
cmd.Flags().StringVarP(&body, "body", "b", "", "Comment text (raw ADF JSON is sent as structured ADF) (required)")
_ = cmd.MarkFlagRequired("body")

return cmd
Expand All @@ -181,7 +181,9 @@ func runAdd(ctx context.Context, opts *root.Options, issueKey, body string) erro
return err
}

comment, err := client.AddComment(ctx, issueKey, text.InterpretEscapes(body))
// Raw ADF passthrough (e.g. --body "$(cat doc.adf.json)") must reach
// AddComment unmodified; see text.InterpretEscapesUnlessRawADF.
comment, err := client.AddComment(ctx, issueKey, text.InterpretEscapesUnlessRawADF(body))
if err != nil {
return err
}
Expand Down
Loading
Loading