diff --git a/docs/plan.md b/docs/plan.md index 32b949f..5cfde2a 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -59,8 +59,8 @@ The first milestone is a walking skeleton (`skeleton`): one feature on a throwaw | `guard-orchestrator` | In the orchestrator's pane, `baton hook guard` asks the user before each outward action, and before each `baton feature start` while `planApproval`, a new setting, is on; it lets baton's commands for the orchestrator run, denies the other commands that it watches, and denies `edit` and `create` outside the session's folder; the orchestrator runs with `--allow-all` (decisions 8, 19, 45 and 54; fact 37) | Done (#36) | | `belt` | Risk 2's second belt, in a role's pane: settings in the environment of the role's `copilot`, which each command and script there inherits, so that git's pushes out of the machine fail, sent by `pushInsteadOf` rules to a remote helper that doesn't exist, with a rule for each remote's URL and the remotes' push URLs cleared, and `gh` fails, its config folder under `/dev/null`; the pane goes without the GitHub tokens of the user's environment, and `copilot` gets `GH_TOKEN`'s or `GITHUB_TOKEN`'s as `COPILOT_GITHUB_TOKEN`, for its own login (decision 55, fact 38, risk 2) | Done (#37) | | `gates` | The repo's gate, a new setting: the user's command that checks the repo's code, which the engineer's handover, QA's approval and the lead's final report run in the feature's worktree, and which refuses the handover while it fails; `baton start` and `baton feature start` refuse a repo without one, and the guard denies a role's `create` and `edit` in baton's config folder (decision 56, risk 13) | Done (#38) | -| `adf` | `internal/adf`: the Atlassian Document Format, the JSON of a Jira ticket's description and comments, as Markdown to read: the walk over a document's nodes, and the inline text, with its marks, mentions, emoji, dates, cards and attachments; a node without a rule of its own shows its content, or the text that stands in for it (decision 57, fact 39) | In review (#39) | -| `adf-blocks` | `internal/adf`'s blocks: headings, lists and task lists, code blocks, quotes and panels, rules, tables and expands (decision 57) | Todo | +| `adf` | `internal/adf`: the Atlassian Document Format, the JSON of a Jira ticket's description and comments, as Markdown to read: the walk over a document's nodes, and the inline text, with its marks, mentions, emoji, dates, cards and attachments; a node without a rule of its own shows its content, or the text that stands in for it (decision 57, fact 39) | Done (#39) | +| `adf-blocks` | `internal/adf`'s blocks: headings, lists and task lists, code blocks, quotes and panels, rules, tables and expands (decision 57) | In review (#40) | | `jira` | `baton jira `: the ticket as text, from Jira's REST API with a read-only token in the Keychain (decision 6) | Todo | | `deliver-github` | `baton deliver` for GitHub: push the branch and open the pull request with `gh`, the lead's final report as its body | Todo | | `close` | `baton feature close `: ends the feature's session; keeps its branch and, unless asked, its worktree | Todo | @@ -282,7 +282,7 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 54. **`baton hook guard` asks the user in the orchestrator's pane** (lead's call, flagged; decisions 8, 17, 19, 45, 48, 49 and 52, facts 4, 28 and 37, risks 2 and 3). The orchestrator's `copilot` runs with `--allow-all`, so its tools run unasked, and the guard answers `ask` where the user should decide, which copilot shows in the pane with the reason and the call, for the user to allow, or to decline with a word for the orchestrator (fact 37); the orchestrator's prompt tells it to ask the user what they want instead. For `bash`, the guard reads the script's commands (decision 51). Baton's commands for the orchestrator run unasked: `baton wait`, `baton answer`, `baton handover`, `baton status` and baton's help. `baton feature start` asks while plan approval is on (decision 8), so the user checks the plan first, and runs unasked once `planApproval` is `false` (decision 49); the guard reads the config file only for a script that starts a feature, for the repo of the hook's folder, and a mistake in the file, or a folder outside a repo, fails the hook, so `copilot` denies the script (fact 4). An outward command asks, so the user approves each push and each pull request, until `baton deliver` comes (decision 17). The guard denies, with a reason for the orchestrator, `tmux` and `osascript`, another agent's CLI, baton's other commands, such as `baton ask` or `baton pane`, and, as in a role's pane (decision 52), a program that only the run decides and a script that it can't read. A script runs whole or not at all: a command that the guard denies denies the script, and otherwise the first command that asks names the question, so the user's yes runs the whole script. The guard names baton's `feature` and `hook` commands by two words (decision 52), so that `baton feature start` asks and a later `baton feature close` can differ. For `create` and `edit`, it denies a path outside the session's folder, `session-state/` in copilot's home folder, which `COPILOT_HOME` names in the pane, or else `~/.copilot`, with the walk of decision 52; the input's `sessionId` names the session (fact 37). A session ID that isn't one folder's name fails the hook, and a session's folder that isn't there holds nothing. The orchestrator keeps `ask_user` and copilot's folder-trust question (decision 45). The guard misses what it misses in a role's pane (decision 52): the orchestrator can write any file with `bash`, and run local commands, such as `rm`, unasked (risk 3), and `web_fetch` can send data in a URL. Its extra tools, such as nudge's, run unasked, and send what they send. The guard reads the plan approval of the hook's folder's repo, not of a repo that the script `cd`s into, and `baton feature start -h` asks too. Rejected: denying outward commands in this pane until `baton deliver` comes, which would leave the user no way to push from it; asking about every command, as copilot did before (decision 45), which buries the questions that matter among the rest; and copilot's `--deny-tool` rules, which only deny (fact 28). 55. **A role's pane runs with a second belt** (the user's call, 2026-11-04, for the git and `gh` parts; lead's call, flagged, for the rest; decisions 9, 17, 52 and 54, facts 28 and 38, risk 2). The guard sees only what a command shows (risk 2), so `baton pane` gives a role's `copilot` settings in its environment, which each command and script that the role runs inherits, whatever runs it, and which make git's pushes out of the machine and `gh` fail. For git, after the settings that `GIT_CONFIG_COUNT` counts in baton's own environment, which it keeps, it adds a `pushInsteadOf` rule for each character that starts a URL that leaves the machine, a letter, a digit or `[`, which sends the push to `baton-blocks-push::`, a remote helper that doesn't exist, so that git fails before any network and names baton's belt; a rule that keeps `file://` URLs as they are; and, for the remotes in git's config for the repo, the user's own config included, as the pane finds them when it starts, a rule for each remote's URL that leaves, which beats the user's own rules for a shorter prefix, and an empty `pushurl` for each remote with a push URL that leaves, which clears its push URLs, so that its pushes go to its URL. A git before 2.46, which can't clear them, gets an `insteadOf` rule for each such push URL instead, which also blocks fetches from the URLs that start with it (fact 38). Pushes to a path on the machine, `file://` URLs among them, still work, and fetches, pulls and clones don't change. For `gh`, `GH_CONFIG_DIR` names a folder under `/dev/null`, so that every `gh` command fails before it starts and never reaches the user's login in the Keychain, as it would with an empty folder (fact 38). The pane goes without `GH_TOKEN`, `GITHUB_TOKEN`, `GH_ENTERPRISE_TOKEN` and `GITHUB_ENTERPRISE_TOKEN`, which other tools would use; but `copilot` takes its own login from `GH_TOKEN` or `GITHUB_TOKEN`, or, after its own login, from `gh` (fact 38), so it gets the first of the two that is set as `COPILOT_GITHUB_TOKEN`, unless that is set already, and a user whose `copilot` logs in only through `gh` runs `copilot login` once (README). `baton pane` makes the belt before it waits, and fails when it can't: when git can't read the repo's config or its own version, or when `GIT_CONFIG_COUNT` is a count that git reads but Go doesn't, such as ` 1`, since baton can't tell then where to put its settings. The orchestrator's pane goes without the belt, since the user approves its outward actions there (decision 54). The costs: a push to a relative path that starts with a letter or a digit, such as `git push backup.git`, fails too, as does one to a git server on the machine by its URL, such as `http://localhost/…`; and the roles lose `gh`'s reads, which their prompts don't ask for. Like the guard, the belt stops a role's mistakes, and what the guard can't see, not a role that sets out to get past it (risk 2). Rejected: an empty `GH_CONFIG_DIR` folder, which leaves `gh` the user's login in the Keychain; `BASH_ENV`, which `copilot` reads only with a setting of the user's (fact 38); `copilot`'s deny rules, which match the command that the bash tool runs, as the guard does, not what it runs in turn (fact 28); and `copilot`'s command sandbox, whose policy, such as whether commands reach the network, lives in the user's settings, which each of their `copilot` sessions shares (risk 2). 56. **A repo's gate checks the code before each handover that vouches for it** (lead's call, flagged; the user's call, 2026-11-04, for its `//nolint:gosec`; decisions 13, 34, 49 and 52, fact 16, risk 13). The config file's `gate`, a setting like the others (decision 49), is the user's command that checks the repo's code, such as `mise run check`. The engineer's handover, QA's approval and the lead's final report run it, since each of these roles vouches for the code that it hands on; the rejections, the reviewer's approval, which follows QA's on the same code, the lead's brief and the orchestrator's moves don't. baton reads the gate at each of these handovers, for the repo of the role's folder, and runs it with `/bin/sh -c` in the feature's worktree, wherever in the repo the role runs baton, with baton's environment, the belt's in a role's pane (decision 55), nothing on its input, and its output and its errors on baton's output, in order, after a line that names the gate and the folder, so the role reads what failed. It runs once baton has checked the move and the role's open question on the feature as it stands, so a move that baton refuses runs no gate, and without the feature's lock, so that the other roles' `baton wait` and `baton status` don't wait for it; baton checks the move again under the lock when it writes the handover. A gate that fails refuses the handover, which changes nothing: the role fixes what failed, or, for QA, rejects with the failures. The journal logs the gate that a handover ran, with the handover. The gate has no time limit, since a check can take minutes; Ctrl-C or a hangup stops it, as it stops baton (decision 42): baton kills the gate's shell, and Ctrl-C reaches the commands that the shell started too, but a signal to baton alone leaves them to run to their end. Every repo needs a gate, so that none is forgotten: `baton start`, when it makes the orchestrator's session, and `baton feature start` refuse a repo without one, and say where to set it; `"gate": "true"` checks nothing, for a repo that has no checks. A gated handover refuses too, if the user takes the gate out while a feature runs; the others still go. The config file refuses a gate that is only spaces, and one that starts with `-`, which `sh -c` reads as its own options (bash, dash and zsh all do). The gate is a command line by design, the user's own, from the config file, which only the user writes, so its one place to run carries `//nolint:gosec` for G204, with its reason; decision 34's reason, arguments that no shell reads, doesn't fit it. The guard denies a role's `create` and `edit` in baton's config folder (decision 52), so a role's file tools can't change the gate, and a config folder that isn't there holds nothing, as no feature runs without the file; a role's `bash` still can, and a role can weaken the checks that the gate runs from inside the repo, such as its `mise.toml` (risk 13). Rejected: running the gate in the role's working folder, which may be any folder of the repo; a time limit, which would fail a slow but sound check; a repo without a gate, which would make the gate easy to forget; and gating QA's rejection, which reports a failure rather than vouching for the code. -57. **A ticket's text as Markdown, through `internal/adf`** (lead's call, flagged; decision 6, fact 39). `internal/adf` renders a ticket's ADF as Markdown for the orchestrator and the user to read, which takes far fewer tokens than the JSON, and which nobody needs to render: so the text stays as Jira has it, with no escapes, but for a `|` in a table's cell. Strong, em, strike, code and links show as Markdown's marks, code innermost and a link outermost, and the other marks, such as underline and colours, leave the text plain; code that holds backticks gets a longer fence. A mention, an emoji, a status, a card and an extension show their text or their address, a date its day in UTC, as Jira shows it, and a file or an image `[attachment]`, with its alternative text or its address, since text can't show it (fact 39). A node without a rule of its own, such as one that a later ADF adds, shows its content, or the text that stands in for it, so that each of a ticket's words reaches the orchestrator; a run of inline nodes in a block makes a paragraph. The converter came to about 240 lines, so it came in two steps, `adf`, the walk and the inline text, and `adf-blocks`, the blocks' structure, ahead of `jira`, so that each stays near 150 lines. Rejected: handing the orchestrator the ADF itself, which costs it many more tokens to read. +57. **A ticket's text as Markdown, through `internal/adf`** (lead's call, flagged; decision 6, fact 39). `internal/adf` renders a ticket's ADF as Markdown for the orchestrator and the user to read, which takes far fewer tokens than the JSON, and which nobody needs to render: so the text stays as Jira has it, with no escapes, but for a `|` in a table's cell. Strong, em, strike, code and links show as Markdown's marks, code innermost and a link outermost, and the other marks, such as underline and colours, leave the text plain; code that holds backticks gets a longer fence. A mention, an emoji, a status, a card and an extension show their text or their address, a date its day in UTC, as Jira shows it, and a file or an image `[attachment]`, with its alternative text or its address, since text can't show it (fact 39). A node without a rule of its own, such as one that a later ADF adds, shows its content, or the text that stands in for it, so that each of a ticket's words reaches the orchestrator; a run of inline nodes in a block makes a paragraph. The blocks keep their shape. A heading keeps its level, from 1 to 6. Lists keep their nesting, and an item's blocks stand on the lines under its marker; a task shows as `- [ ]` or `- [x]`, and a decision as a bullet. A code block keeps its language, in a fence that its code can't end. A quote and a panel show as a quote, without the panel's kind, a rule as `---`, and an expand, which Jira shows folded, as its title in bold above its content. A table becomes a Markdown table: its first row is the header, since a Markdown table needs one; each row is as wide as the widest; and each cell stays on one line, with `
` for a line break and `\|` for a `|`. The converter came to about 240 lines, so it came in two steps, `adf`, the walk and the inline text, and `adf-blocks`, the blocks' structure, ahead of `jira`, so that each stays near 150 lines. Rejected: handing the orchestrator the ADF itself, which costs it many more tokens to read. ## 5. Risks diff --git a/internal/adf/adf.go b/internal/adf/adf.go index 610f649..da398b0 100644 --- a/internal/adf/adf.go +++ b/internal/adf/adf.go @@ -29,27 +29,27 @@ type Mark struct { // word in its place. A node of an unknown type shows its content, or the text that stands in for // it. func Markdown(node Node) string { - return blocks([]Node{node}) + return blocks([]Node{node}, "\n\n") } -// blocks renders nodes one after the other, with a blank line between them. A run of inline -// nodes makes one block, and an empty block makes none. -func blocks(nodes []Node) string { +// blocks renders nodes one after the other, with sep between them. A run of inline nodes makes +// one block, and an empty block makes none. +func blocks(nodes []Node, sep string) string { parts := make([]string, 0, len(nodes)) for len(nodes) > 0 { - var part string if run := inlineRun(nodes); run > 0 { - part, nodes = inline(nodes[:run]), nodes[run:] + parts, nodes = append(parts, inline(nodes[:run])), nodes[run:] } else { - part, nodes = block(nodes[0]), nodes[1:] - } - - if part != "" { - parts = append(parts, part) + parts, nodes = append(parts, block(nodes[0])), nodes[1:] } } - return strings.Join(parts, "\n\n") + return join(sep, parts...) +} + +// join joins the parts that aren't empty, with sep between them. +func join(sep string, parts ...string) string { + return strings.Join(slices.DeleteFunc(parts, func(part string) bool { return part == "" }), sep) } // inlineRun returns how many of the nodes, from the first on, are inline nodes. @@ -82,3 +82,13 @@ func attr(attrs map[string]any, key string) string { return text } + +// number returns the number in the attribute, or fallback if there is none. +func number(attrs map[string]any, key string, fallback int) int { + value, ok := attrs[key].(float64) + if !ok { + return fallback + } + + return int(value) +} diff --git a/internal/adf/block.go b/internal/adf/block.go index 88197d8..1821810 100644 --- a/internal/adf/block.go +++ b/internal/adf/block.go @@ -1,15 +1,77 @@ package adf -import "cmp" +import ( + "cmp" + "strings" +) + +// deepestHeading is the deepest level that a heading can have, in ADF and in Markdown. +const deepestHeading = 6 // block renders a block node. func block(node Node) string { switch node.Type { case "paragraph": return inline(node.Content) + case "heading": + return heading(node) + case "bulletList", "orderedList", "taskList", "decisionList": + return list(node) + case "codeBlock": + return codeBlock(node) + case "blockquote", "panel": + return prefix(blocks(node.Content, "\n\n"), "> ", "> ") + case "rule": + return "---" + case "table": + return table(node) case "media": return attachment(node) + case "expand", "nestedExpand": + return expand(node) default: - return cmp.Or(blocks(node.Content), standIn(node)) + return cmp.Or(blocks(node.Content, "\n\n"), standIn(node)) } } + +// prefix puts first before the first line of the text and rest before each later line, without +// the spaces at their end on an empty line. +func prefix(text, first, rest string) string { + lines := strings.Split(text, "\n") + lead := first + + for index, line := range lines { + if line == "" { + lead = strings.TrimRight(lead, " ") + } + + lines[index], lead = lead+line, rest + } + + return strings.Join(lines, "\n") +} + +// heading renders a heading at its level. +func heading(node Node) string { + level := min(max(number(node.Attrs, "level", 1), 1), deepestHeading) + + return strings.Repeat("#", level) + " " + inline(node.Content) +} + +// codeBlock renders a code block in a fence that its code can't end. +func codeBlock(node Node) string { + code := inline(node.Content) + ticks := fence(code, len("```")) + + return ticks + attr(node.Attrs, "language") + "\n" + code + "\n" + ticks +} + +// expand renders an expand, a section that Jira shows folded, under its title in bold. +func expand(node Node) string { + title := attr(node.Attrs, "title") + if title != "" { + title = "**" + title + "**" + } + + return join("\n\n", title, blocks(node.Content, "\n\n")) +} diff --git a/internal/adf/inline.go b/internal/adf/inline.go index 2b8c287..807b607 100644 --- a/internal/adf/inline.go +++ b/internal/adf/inline.go @@ -73,15 +73,15 @@ func code(text string) string { pad = " " } - ticks := fence(text) + ticks := fence(text, 1) return ticks + pad + text + pad + ticks } -// fence returns the shortest run of backticks that the code doesn't hold, so that the code can't -// end its own fence. -func fence(code string) string { - ticks := "`" +// fence returns the shortest run of backticks, at least least long, that the code doesn't hold, +// so that the code can't end its own fence. +func fence(code string, least int) string { + ticks := strings.Repeat("`", least) for strings.Contains(code, ticks) { ticks += "`" } diff --git a/internal/adf/list.go b/internal/adf/list.go new file mode 100644 index 0000000..354209d --- /dev/null +++ b/internal/adf/list.go @@ -0,0 +1,39 @@ +package adf + +import ( + "strconv" + "strings" +) + +// list renders a list: each item after its marker, with the item's later lines indented under +// its first. +func list(node Node) string { + start := number(node.Attrs, "order", 1) + items := make([]string, 0, len(node.Content)) + + for i, item := range node.Content { + marker, body := bullet(node.Type, item, start+i), blocks(item.Content, "\n") + if item.Type == node.Type { // a task list holds a nested list as it is, not in an item + marker, body = " ", block(item) + } + + items = append(items, prefix(body, marker, strings.Repeat(" ", len(marker)))) + } + + return strings.Join(items, "\n") +} + +// bullet returns the marker of the list's item: its number in an ordered list, and its box in a +// task list. +func bullet(kind string, item Node, position int) string { + switch { + case kind == "orderedList": + return strconv.Itoa(position) + ". " + case attr(item.Attrs, "state") == "DONE": + return "- [x] " + case kind == "taskList": + return "- [ ] " + default: + return "- " + } +} diff --git a/internal/adf/table.go b/internal/adf/table.go new file mode 100644 index 0000000..bdfb4a2 --- /dev/null +++ b/internal/adf/table.go @@ -0,0 +1,38 @@ +package adf + +import "strings" + +// table renders a table as a Markdown table, every row as wide as the widest. Its first row is +// the header, since a Markdown table needs one. +func table(node Node) string { + rows := make([][]string, 0, len(node.Content)) + width := 0 + + for _, row := range node.Content { + cells := make([]string, 0, len(row.Content)) + for _, cell := range row.Content { + cells = append(cells, cellText(cell)) + } + + rows = append(rows, cells) + width = max(width, len(cells)) + } + + lines := make([]string, 0, len(rows)+1) + for i, cells := range rows { + cells = append(cells, make([]string, width-len(cells))...) + + lines = append(lines, "| "+strings.Join(cells, " | ")+" |") + if i == 0 { + lines = append(lines, "|"+strings.Repeat(" --- |", width)) + } + } + + return strings.Join(lines, "\n") +} + +// cellText renders a cell's blocks on one line, as a Markdown table's cell holds no more: a line +// break becomes
, and a | is escaped. +func cellText(cell Node) string { + return strings.NewReplacer("|", `\|`, "\n", "
").Replace(blocks(cell.Content, "\n")) +} diff --git a/internal/adf/testdata/blocks.json b/internal/adf/testdata/blocks.json index f237ae7..e3125a2 100644 --- a/internal/adf/testdata/blocks.json +++ b/internal/adf/testdata/blocks.json @@ -2,7 +2,8 @@ "version": 1, "type": "doc", "content": [ - {"type": "paragraph", "content": [{"type": "text", "text": "A paragraph."}]}, + {"type": "paragraph", "content": [{"type": "text", "text": "Above the rule."}]}, + {"type": "rule"}, { "type": "mediaSingle", "attrs": {"layout": "center"}, @@ -29,6 +30,24 @@ {"type": "media", "attrs": {"type": "file", "id": "2", "collection": "c"}} ] }, + { + "type": "expand", + "attrs": {"title": "Details"}, + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Folded at first."}]}, + { + "type": "nestedExpand", + "attrs": {"title": "More"}, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Inside."}]}] + } + ] + }, + { + "type": "expand", + "attrs": {"title": ""}, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": "No title."}]}] + }, + {"type": "expand", "attrs": {"title": "Empty"}, "content": []}, {"type": "blockCard", "attrs": {"url": "https://example.com/DEMO-3"}}, {"type": "extension", "attrs": {"extensionType": "demo", "extensionKey": "k", "text": "A macro's text"}}, { diff --git a/internal/adf/testdata/blocks.md b/internal/adf/testdata/blocks.md index f9df551..046db5f 100644 --- a/internal/adf/testdata/blocks.md +++ b/internal/adf/testdata/blocks.md @@ -1,4 +1,6 @@ -A paragraph. +Above the rule. + +--- [attachment: screen.png] @@ -10,6 +12,18 @@ The *login* screen [attachment] +**Details** + +Folded at first. + +**More** + +Inside. + +No title. + +**Empty** + https://example.com/DEMO-3 A macro's text diff --git a/internal/adf/testdata/code.json b/internal/adf/testdata/code.json new file mode 100644 index 0000000..ea7f814 --- /dev/null +++ b/internal/adf/testdata/code.json @@ -0,0 +1,36 @@ +{ + "version": 1, + "type": "doc", + "content": [ + { + "type": "codeBlock", + "attrs": {"language": "go"}, + "content": [{"type": "text", "text": "func main() {\n fmt.Println(\"hi\")\n}"}] + }, + {"type": "codeBlock", "content": [{"type": "text", "text": "Fence code with ``` in Markdown."}]}, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Run:"}]}, + {"type": "codeBlock", "attrs": {"language": "sh"}, "content": [{"type": "text", "text": "make\n\nmake test"}]} + ] + } + ] + }, + { + "type": "blockquote", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Quoted."}]}, + {"type": "paragraph", "content": [{"type": "text", "text": "Twice."}]} + ] + }, + { + "type": "panel", + "attrs": {"panelType": "warning"}, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Mind the gap."}]}] + } + ] +} diff --git a/internal/adf/testdata/code.md b/internal/adf/testdata/code.md new file mode 100644 index 0000000..46289f0 --- /dev/null +++ b/internal/adf/testdata/code.md @@ -0,0 +1,22 @@ +```go +func main() { + fmt.Println("hi") +} +``` + +```` +Fence code with ``` in Markdown. +```` + +- Run: + ```sh + make + + make test + ``` + +> Quoted. +> +> Twice. + +> Mind the gap. diff --git a/internal/adf/testdata/headings.json b/internal/adf/testdata/headings.json new file mode 100644 index 0000000..e4df88a --- /dev/null +++ b/internal/adf/testdata/headings.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "type": "doc", + "content": [ + {"type": "heading", "attrs": {"level": 1}, "content": [{"type": "text", "text": "Goal"}]}, + { + "type": "heading", + "attrs": {"level": 3}, + "content": [{"type": "text", "text": "Out of "}, {"type": "text", "text": "scope", "marks": [{"type": "em"}]}] + }, + {"type": "heading", "attrs": {"level": 9}, "content": [{"type": "text", "text": "Too deep"}]}, + {"type": "heading", "attrs": {"level": 0}, "content": [{"type": "text", "text": "Too high"}]}, + {"type": "heading", "content": [{"type": "text", "text": "No level"}]} + ] +} diff --git a/internal/adf/testdata/headings.md b/internal/adf/testdata/headings.md new file mode 100644 index 0000000..f3274f2 --- /dev/null +++ b/internal/adf/testdata/headings.md @@ -0,0 +1,9 @@ +# Goal + +### Out of *scope* + +###### Too deep + +# Too high + +# No level diff --git a/internal/adf/testdata/inline.md b/internal/adf/testdata/inline.md index 7396353..a024b59 100644 --- a/internal/adf/testdata/inline.md +++ b/internal/adf/testdata/inline.md @@ -1,2 +1,2 @@ -Ask @Demo User 😄:demo: by 2025-12-31 or soon, -IN PROGRESS, https://example.com/DEMO-2, [attachment: log.txt], a placeholder and an extension +- [ ] Ask @Demo User 😄:demo: by 2025-12-31 or soon, + IN PROGRESS, https://example.com/DEMO-2, [attachment: log.txt], a placeholder and an extension diff --git a/internal/adf/testdata/lists.json b/internal/adf/testdata/lists.json new file mode 100644 index 0000000..d976f37 --- /dev/null +++ b/internal/adf/testdata/lists.json @@ -0,0 +1,97 @@ +{ + "version": 1, + "type": "doc", + "content": [ + { + "type": "bulletList", + "content": [ + {"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "First"}]}]}, + { + "type": "listItem", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Second, in steps:"}]}, + { + "type": "orderedList", + "attrs": {"order": 9}, + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "Ninth"}, {"type": "hardBreak"}, {"type": "text", "text": "and more"}] + } + ] + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "Tenth"}, {"type": "hardBreak"}, {"type": "text", "text": "and more"}] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "orderedList", + "content": [ + {"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "One"}]}]}, + {"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Two"}]}]} + ] + }, + { + "type": "orderedList", + "attrs": {"order": 0}, + "content": [{"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Zero"}]}]}] + }, + { + "type": "taskList", + "attrs": {"localId": "a"}, + "content": [ + { + "type": "taskItem", + "attrs": {"localId": "b", "state": "DONE"}, + "content": [{"type": "text", "text": "Write the brief"}] + }, + { + "type": "taskItem", + "attrs": {"localId": "c", "state": "TODO"}, + "content": [{"type": "text", "text": "Review it"}] + }, + { + "type": "taskList", + "attrs": {"localId": "d"}, + "content": [ + { + "type": "taskItem", + "attrs": {"localId": "e", "state": "TODO"}, + "content": [{"type": "text", "text": "Nested step"}] + } + ] + }, + { + "type": "blockTaskItem", + "attrs": {"localId": "f", "state": "DONE"}, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": "A step in a paragraph"}]}] + } + ] + }, + { + "type": "decisionList", + "attrs": {"localId": "g"}, + "content": [ + { + "type": "decisionItem", + "attrs": {"localId": "h", "state": "DECIDED"}, + "content": [{"type": "text", "text": "Ship it on Monday"}] + } + ] + } + ] +} diff --git a/internal/adf/testdata/lists.md b/internal/adf/testdata/lists.md new file mode 100644 index 0000000..a965496 --- /dev/null +++ b/internal/adf/testdata/lists.md @@ -0,0 +1,18 @@ +- First +- Second, in steps: + 9. Ninth + and more + 10. Tenth + and more + +1. One +2. Two + +0. Zero + +- [x] Write the brief +- [ ] Review it + - [ ] Nested step +- [x] A step in a paragraph + +- Ship it on Monday diff --git a/internal/adf/testdata/table.json b/internal/adf/testdata/table.json new file mode 100644 index 0000000..28483f3 --- /dev/null +++ b/internal/adf/testdata/table.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "type": "doc", + "content": [ + { + "type": "table", + "attrs": {"isNumberColumnEnabled": false, "layout": "default"}, + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "attrs": {}, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Case", "marks": [{"type": "strong"}]}]}] + }, + {"type": "tableHeader", "attrs": {}, "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Result"}]}]}, + {"type": "tableHeader", "attrs": {}, "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Note"}]}]} + ] + }, + { + "type": "tableRow", + "content": [ + {"type": "tableCell", "attrs": {}, "content": [{"type": "paragraph", "content": [{"type": "text", "text": "a | b"}]}]}, + { + "type": "tableCell", + "attrs": {"colspan": 1}, + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "pass"}]}, + {"type": "paragraph", "content": [{"type": "text", "text": "twice"}]} + ] + }, + {"type": "tableCell", "attrs": {}, "content": [{"type": "paragraph", "content": []}]} + ] + }, + { + "type": "tableRow", + "content": [ + {"type": "tableCell", "attrs": {}, "content": [{"type": "paragraph", "content": [{"type": "text", "text": "short row"}]}]} + ] + } + ] + }, + {"type": "table", "content": []} + ] +} diff --git a/internal/adf/testdata/table.md b/internal/adf/testdata/table.md new file mode 100644 index 0000000..6f65e65 --- /dev/null +++ b/internal/adf/testdata/table.md @@ -0,0 +1,4 @@ +| **Case** | Result | Note | +| --- | --- | --- | +| a \| b | pass
twice | | +| short row | | |