relay-flow 0.2.0-alpha → 0.2.2-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +171 -26
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +593 -15
  4. package/cmd/relay-flow/main.go +356 -119
  5. package/cmd/relay-flow/scenario_test.go +246 -35
  6. package/cmd/relay-flow/serve.go +5 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +88 -0
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +136 -87
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +41 -8
  16. package/internal/execution/goworkflows/engine_test.go +100 -22
  17. package/internal/execution/goworkflows/fakes_test.go +63 -25
  18. package/internal/execution/goworkflows/interpreter.go +45 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  21. package/internal/execution/goworkflows/node_runtime_test.go +72 -23
  22. package/internal/execution/goworkflows/recovery_test.go +7 -7
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  25. package/internal/harness/contract_test.go +15 -0
  26. package/internal/harness/factory.go +25 -3
  27. package/internal/harness/harness.go +30 -4
  28. package/internal/harness/opencode/opencode.go +125 -10
  29. package/internal/harness/opencode/opencode_test.go +183 -0
  30. package/internal/harness/opencode/repo_setup.go +361 -0
  31. package/internal/harness/plugin_selection_test.go +5 -5
  32. package/internal/paths/paths.go +18 -16
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/repo.go +13 -0
  35. package/internal/repo/service.go +10 -0
  36. package/internal/repo/service_test.go +55 -6
  37. package/internal/router/router.go +3 -2
  38. package/internal/router/router_test.go +87 -0
  39. package/internal/run/manager.go +14 -1
  40. package/internal/run/run.go +6 -4
  41. package/internal/run/run_manager_test.go +21 -1
  42. package/internal/runner/contract_test.go +64 -26
  43. package/internal/runner/orca/orca.go +30 -54
  44. package/internal/runner/orca/orca_test.go +143 -4
  45. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  46. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  47. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  48. package/internal/runner/runner.go +15 -8
  49. package/internal/task/auth_test.go +48 -0
  50. package/internal/task/beads/bdcli/bdcli.go +323 -0
  51. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  52. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  56. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  57. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  58. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  59. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  60. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  61. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  62. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  63. package/internal/task/beads/beads.go +840 -0
  64. package/internal/task/beads/beads_test.go +609 -0
  65. package/internal/task/beads/comments_test.go +242 -0
  66. package/internal/task/beads/config_compatibility_test.go +163 -0
  67. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  68. package/internal/task/beads/repo_composition_test.go +232 -0
  69. package/internal/task/beads/runtime_config_test.go +81 -0
  70. package/internal/task/beads/status_compatibility_test.go +233 -0
  71. package/internal/task/beads/status_test.go +257 -0
  72. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  73. package/internal/task/beads/validation_test.go +110 -0
  74. package/internal/task/contract_test.go +12 -0
  75. package/internal/task/factory.go +52 -3
  76. package/internal/task/jira/auth.go +209 -0
  77. package/internal/task/jira/auth_test.go +160 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +96 -16
  80. package/internal/task/jira/helpers_test.go +29 -19
  81. package/internal/task/jira/jira.go +263 -90
  82. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  83. package/internal/task/jira/normalize.go +32 -14
  84. package/internal/task/jira/rest/adf.go +165 -0
  85. package/internal/task/jira/rest/adf_test.go +60 -0
  86. package/internal/task/jira/rest/client.go +573 -0
  87. package/internal/task/jira/rest/client_test.go +381 -0
  88. package/internal/task/jira/templates_test.go +118 -0
  89. package/internal/task/jira/transition_defaults_test.go +22 -18
  90. package/internal/task/jira/validation_test.go +1 -1
  91. package/internal/task/task.go +32 -0
  92. package/internal/workflow/report_test.go +45 -0
  93. package/internal/workflow/workflow.go +9 -6
  94. package/internal/workflow/workflow_test.go +14 -12
  95. package/package.json +2 -1
  96. package/internal/task/jira/acli/acli.go +0 -306
  97. package/internal/task/jira/acli/acli_test.go +0 -208
  98. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  99. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  100. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  101. /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
  102. /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
@@ -0,0 +1,172 @@
1
+ package jira
2
+
3
+ import (
4
+ "context"
5
+ "testing"
6
+
7
+ "github.com/rajpopat27/relay-flow/internal/config"
8
+ "github.com/rajpopat27/relay-flow/internal/task"
9
+ )
10
+
11
+ // Inherited root/repository taskConfig must have the same effect at runtime
12
+ // that validation implies. ApplyTaskConfig receives only the configuration the
13
+ // caller assembles (lifecycle defaults merged under workflow/node values), so
14
+ // an adapter that keeps inherited values to itself accepts configuration,
15
+ // validates it against the live task system, and then silently discards it.
16
+ //
17
+ // The adapter therefore returns its inherited transitionTo/assignee values as
18
+ // part of its lifecycle defaults, producing the effective precedence
19
+ // built-in default < root < repo < workflow < node.
20
+ //
21
+ // internal/task/beads/lifecycle_inheritance_test.go is the sibling of this
22
+ // file and asserts the same invariants against the Beads adapter. Keep the two
23
+ // in sync: a change that breaks inheritance in one adapter must fail here too.
24
+
25
+ // repoScopedSystem builds the adapter with root/repository-scoped values, the
26
+ // scopes that never appear in an ApplyTaskConfig argument.
27
+ func repoScopedSystem(t *testing.T, fake *fakeJira, repoValues config.RawValues) task.System {
28
+ t.Helper()
29
+ repoConfig := config.Merge(config.RawValues{"project": "PAY", "component": "api"}, repoValues)
30
+ sys, err := newSystem(context.Background(), &fakeClient{fake: fake}, task.RepoSpec{
31
+ Name: "payments",
32
+ RepoConfig: repoConfig,
33
+ })
34
+ if err != nil {
35
+ t.Fatalf("adapter construction failed: %v", err)
36
+ }
37
+ return sys
38
+ }
39
+
40
+ // operationConfig reproduces what the caller assembles before ApplyTaskConfig.
41
+ func operationConfig(defaults, workflow, node config.RawValues) config.RawValues {
42
+ return config.Merge(defaults, config.Merge(workflow, node))
43
+ }
44
+
45
+ func lifecycleDefaultsOf(t *testing.T, sys task.System) task.LifecycleDefaults {
46
+ t.Helper()
47
+ d, ok := sys.(task.LifecycleDefaults)
48
+ if !ok {
49
+ t.Fatal("adapter does not expose task.LifecycleDefaults; inherited values cannot reach runtime")
50
+ }
51
+ return d
52
+ }
53
+
54
+ func TestLifecycleDefaultsCarryInheritedTransitionTo(t *testing.T) {
55
+ parent := task.TicketRef{ID: "1", Key: "PAY-101"}
56
+ mailbox := &task.Mailbox{ID: "2", Key: "PAY-102", Node: "implement"}
57
+
58
+ t.Run("repo parentStatus reaches start", func(t *testing.T) {
59
+ fake := &fakeJira{}
60
+ sys := repoScopedSystem(t, fake, config.RawValues{
61
+ "transitionTo": map[string]any{"parentStatus": "In Review"},
62
+ })
63
+ cfg := operationConfig(lifecycleDefaultsOf(t, sys).StartDefaults(), nil, nil)
64
+
65
+ if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, cfg); err != nil {
66
+ t.Fatal(err)
67
+ }
68
+ if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "In Review" {
69
+ t.Fatalf("parent transitions = %v, want the inherited repo value to beat the built-in default",
70
+ fake.parentTransitions)
71
+ }
72
+ })
73
+
74
+ t.Run("repo taskStatus reaches a work node", func(t *testing.T) {
75
+ fake := &fakeJira{}
76
+ sys := repoScopedSystem(t, fake, config.RawValues{
77
+ "transitionTo": map[string]any{"taskStatus": "In Review"},
78
+ })
79
+ cfg := operationConfig(lifecycleDefaultsOf(t, sys).WorkDefaults(), nil, nil)
80
+
81
+ if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: mailbox}, cfg); err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != "In Review" {
85
+ t.Fatalf("mailbox transitions = %v, want the inherited repo value to beat the built-in default",
86
+ fake.taskTransitions)
87
+ }
88
+ })
89
+ }
90
+
91
+ func TestLifecycleDefaultsCarryInheritedAssignee(t *testing.T) {
92
+ fake := &fakeJira{}
93
+ sys := repoScopedSystem(t, fake, config.RawValues{"assignee": "repo-bot@example.com"})
94
+ parent := task.TicketRef{ID: "1", Key: "PAY-101"}
95
+ mailbox := &task.Mailbox{ID: "2", Key: "PAY-102", Node: "implement"}
96
+ cfg := operationConfig(lifecycleDefaultsOf(t, sys).WorkDefaults(), nil, nil)
97
+
98
+ if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: mailbox}, cfg); err != nil {
99
+ t.Fatal(err)
100
+ }
101
+ if len(fake.assignments) != 1 || fake.assignments[0] != "PAY-102:repo-bot@example.com" {
102
+ t.Fatalf("assignments = %v, want the inherited repo assignee applied to the mailbox", fake.assignments)
103
+ }
104
+ }
105
+
106
+ func TestLifecycleDefaultsPrecedenceDefaultRepoWorkflowNode(t *testing.T) {
107
+ parent := task.TicketRef{ID: "1", Key: "PAY-101"}
108
+ mailbox := &task.Mailbox{ID: "2", Key: "PAY-102", Node: "implement"}
109
+
110
+ for _, tc := range []struct {
111
+ name string
112
+ workflow config.RawValues
113
+ node config.RawValues
114
+ want string
115
+ }{
116
+ {name: "repo beats built-in default", want: "Repo Status"},
117
+ {
118
+ name: "workflow beats repo",
119
+ workflow: config.RawValues{"transitionTo": map[string]any{"taskStatus": "Workflow Status"}},
120
+ want: "Workflow Status",
121
+ },
122
+ {
123
+ name: "node beats workflow",
124
+ workflow: config.RawValues{"transitionTo": map[string]any{"taskStatus": "Workflow Status"}},
125
+ node: config.RawValues{"transitionTo": map[string]any{"taskStatus": "Node Status"}},
126
+ want: "Node Status",
127
+ },
128
+ } {
129
+ t.Run(tc.name, func(t *testing.T) {
130
+ fake := &fakeJira{}
131
+ sys := repoScopedSystem(t, fake, config.RawValues{
132
+ "transitionTo": map[string]any{"taskStatus": "Repo Status"},
133
+ })
134
+ cfg := operationConfig(lifecycleDefaultsOf(t, sys).WorkDefaults(), tc.workflow, tc.node)
135
+
136
+ if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: mailbox}, cfg); err != nil {
137
+ t.Fatal(err)
138
+ }
139
+ if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != tc.want {
140
+ t.Fatalf("mailbox transitions = %v, want [%s]", fake.taskTransitions, tc.want)
141
+ }
142
+ })
143
+ }
144
+ }
145
+
146
+ // With nothing inherited, the built-in lifecycle values still apply.
147
+ func TestLifecycleDefaultsWithoutInheritedValues(t *testing.T) {
148
+ fake := &fakeJira{}
149
+ sys := repoScopedSystem(t, fake, nil)
150
+ d := lifecycleDefaultsOf(t, sys)
151
+ parent := task.TicketRef{ID: "1", Key: "PAY-101"}
152
+ mailbox := &task.Mailbox{ID: "2", Key: "PAY-102", Node: "implement"}
153
+
154
+ if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, operationConfig(d.StartDefaults(), nil, nil)); err != nil {
155
+ t.Fatal(err)
156
+ }
157
+ if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: mailbox}, operationConfig(d.WorkDefaults(), nil, nil)); err != nil {
158
+ t.Fatal(err)
159
+ }
160
+ if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, operationConfig(d.EndDefaults(), nil, nil)); err != nil {
161
+ t.Fatal(err)
162
+ }
163
+ if len(fake.parentTransitions) != 2 || fake.parentTransitions[0] != defaultStartParentStatus || fake.parentTransitions[1] != defaultEndParentStatus {
164
+ t.Fatalf("parent transitions = %v, want [%s %s]", fake.parentTransitions, defaultStartParentStatus, defaultEndParentStatus)
165
+ }
166
+ if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != defaultWorkTaskStatus {
167
+ t.Fatalf("mailbox transitions = %v, want [%s]", fake.taskTransitions, defaultWorkTaskStatus)
168
+ }
169
+ if len(fake.assignments) != 0 {
170
+ t.Fatalf("assignments = %v, want none when no assignee is configured", fake.assignments)
171
+ }
172
+ }
@@ -3,13 +3,12 @@ package jira
3
3
  import (
4
4
  "encoding/json"
5
5
  "fmt"
6
+ "strings"
6
7
 
7
8
  "github.com/rajpopat27/relay-flow/internal/task"
8
9
  )
9
10
 
10
- // rawIssue matches one entry in acli's search output. acli emits a BARE
11
- // ARRAY of these (no {"issues":[...]} REST envelope) — the adapter owns
12
- // the acli wire contract, not the REST API's.
11
+ // rawIssue is the subset of Jira REST issue fields normalized by the adapter.
13
12
  type rawIssue struct {
14
13
  ID string `json:"id"`
15
14
  Key string `json:"key"`
@@ -33,11 +32,26 @@ type rawIssue struct {
33
32
  Summary string `json:"summary"`
34
33
  } `json:"fields"`
35
34
  } `json:"subtasks"`
35
+ IssueLinks []struct {
36
+ Type struct {
37
+ Name string `json:"name"`
38
+ } `json:"type"`
39
+ InwardIssue *struct {
40
+ Key string `json:"key"`
41
+ Fields struct {
42
+ Status struct {
43
+ StatusCategory struct {
44
+ Key string `json:"key"`
45
+ } `json:"statusCategory"`
46
+ } `json:"status"`
47
+ } `json:"fields"`
48
+ } `json:"inwardIssue"`
49
+ } `json:"issuelinks"`
36
50
  } `json:"fields"`
37
51
  }
38
52
 
39
- // normalizeSearchResponse converts raw acli search JSON (a bare array of
40
- // issue objects) into normalized parent tickets: status, issueType, labels,
53
+ // normalizeSearchResponse converts REST issue objects into normalized parent
54
+ // tickets: status, issueType, labels,
41
55
  // and assignee become plain Fields entries. Assignee is normalized to the
42
56
  // user's email address — the stable, machine-comparable identity workflow
43
57
  // filters match against (displayName is human-readable, not an identifier).
@@ -49,6 +63,9 @@ func normalizeSearchResponse(raw []byte) ([]task.Ticket, error) {
49
63
  }
50
64
  out := make([]task.Ticket, 0, len(issues))
51
65
  for _, issue := range issues {
66
+ if hasOpenBlocker(issue) {
67
+ continue
68
+ }
52
69
  fields := map[string]any{
53
70
  "status": issue.Fields.Status.Name,
54
71
  "issueType": issue.Fields.IssueType.Name,
@@ -68,6 +85,16 @@ func normalizeSearchResponse(raw []byte) ([]task.Ticket, error) {
68
85
  return out, nil
69
86
  }
70
87
 
88
+ func hasOpenBlocker(issue rawIssue) bool {
89
+ for _, link := range issue.Fields.IssueLinks {
90
+ if link.Type.Name == "Blocks" && link.InwardIssue != nil &&
91
+ !strings.EqualFold(link.InwardIssue.Fields.Status.StatusCategory.Key, "done") {
92
+ return true
93
+ }
94
+ }
95
+ return false
96
+ }
97
+
71
98
  func claimLabels(labels []string) []string {
72
99
  var out []string
73
100
  for _, l := range labels {
@@ -78,15 +105,6 @@ func claimLabels(labels []string) []string {
78
105
  return out
79
106
  }
80
107
 
81
- // labelsOf extracts label strings from a raw Jira issue view.
82
- func labelsOf(raw []byte) ([]string, error) {
83
- var issue rawIssue
84
- if err := json.Unmarshal(raw, &issue); err != nil {
85
- return nil, fmt.Errorf("jira view: parse json: %w", err)
86
- }
87
- return issue.Fields.Labels, nil
88
- }
89
-
90
108
  // subtasksOf maps existing subtask titles (<ticket>:<node>) to mailboxes.
91
109
  func subtasksOf(raw []byte) (map[string]task.Mailbox, error) {
92
110
  var issue rawIssue
@@ -0,0 +1,165 @@
1
+ package rest
2
+
3
+ import (
4
+ "encoding/json"
5
+ "strings"
6
+
7
+ "github.com/yuin/goldmark"
8
+ "github.com/yuin/goldmark/ast"
9
+ "github.com/yuin/goldmark/text"
10
+ )
11
+
12
+ var markdown = goldmark.New()
13
+
14
+ // ADF converts explicit Markdown syntax to Atlassian Document Format.
15
+ func ADF(value string) map[string]any {
16
+ source := []byte(strings.ReplaceAll(value, "\r\n", "\n"))
17
+ document := markdown.Parser().Parse(text.NewReader(source))
18
+ content := blockContent(document, source)
19
+ if len(content) == 0 {
20
+ content = []any{paragraph(nil)}
21
+ }
22
+ return map[string]any{"type": "doc", "version": 1, "content": content}
23
+ }
24
+
25
+ func blockContent(parent ast.Node, source []byte) []any {
26
+ content := make([]any, 0, parent.ChildCount())
27
+ for node := parent.FirstChild(); node != nil; node = node.NextSibling() {
28
+ switch node := node.(type) {
29
+ case *ast.Heading:
30
+ content = append(content, map[string]any{
31
+ "type": "heading",
32
+ "attrs": map[string]any{"level": node.Level},
33
+ "content": inlineContent(node, source, nil),
34
+ })
35
+ case *ast.Paragraph, *ast.TextBlock:
36
+ content = append(content, paragraph(inlineContent(node, source, nil)))
37
+ case *ast.List:
38
+ content = append(content, list(node, source))
39
+ case *ast.FencedCodeBlock:
40
+ content = append(content, codeBlock(node.Text(source), node.Language(source)))
41
+ case *ast.CodeBlock:
42
+ content = append(content, codeBlock(node.Text(source), nil))
43
+ case *ast.HTMLBlock:
44
+ content = append(content, paragraph([]any{textNode(string(node.Text(source)), nil)}))
45
+ default:
46
+ content = append(content, blockContent(node, source)...)
47
+ }
48
+ }
49
+ return content
50
+ }
51
+
52
+ func list(value *ast.List, source []byte) map[string]any {
53
+ items := make([]any, 0, value.ChildCount())
54
+ for child := value.FirstChild(); child != nil; child = child.NextSibling() {
55
+ itemContent := blockContent(child, source)
56
+ if len(itemContent) == 0 {
57
+ itemContent = []any{paragraph(nil)}
58
+ }
59
+ items = append(items, map[string]any{"type": "listItem", "content": itemContent})
60
+ }
61
+
62
+ kind := "bulletList"
63
+ result := map[string]any{"type": kind, "content": items}
64
+ if value.IsOrdered() {
65
+ result["type"] = "orderedList"
66
+ if value.Start != 1 {
67
+ result["attrs"] = map[string]any{"order": value.Start}
68
+ }
69
+ }
70
+ return result
71
+ }
72
+
73
+ func codeBlock(value, language []byte) map[string]any {
74
+ result := map[string]any{
75
+ "type": "codeBlock",
76
+ "content": []any{textNode(strings.TrimSuffix(string(value), "\n"), nil)},
77
+ }
78
+ if len(language) != 0 {
79
+ result["attrs"] = map[string]any{"language": string(language)}
80
+ }
81
+ return result
82
+ }
83
+
84
+ func paragraph(content []any) map[string]any {
85
+ result := map[string]any{"type": "paragraph"}
86
+ if len(content) != 0 {
87
+ result["content"] = content
88
+ }
89
+ return result
90
+ }
91
+
92
+ func inlineContent(parent ast.Node, source []byte, marks []any) []any {
93
+ content := make([]any, 0, parent.ChildCount())
94
+ for node := parent.FirstChild(); node != nil; node = node.NextSibling() {
95
+ switch node := node.(type) {
96
+ case *ast.Text:
97
+ if value := string(node.Text(source)); value != "" {
98
+ content = append(content, textNode(value, marks))
99
+ }
100
+ if node.HardLineBreak() {
101
+ content = append(content, map[string]any{"type": "hardBreak"})
102
+ } else if node.SoftLineBreak() {
103
+ content = append(content, textNode(" ", marks))
104
+ }
105
+ case *ast.String:
106
+ if value := string(node.Value); value != "" {
107
+ content = append(content, textNode(value, marks))
108
+ }
109
+ case *ast.Emphasis:
110
+ kind := "em"
111
+ if node.Level == 2 {
112
+ kind = "strong"
113
+ }
114
+ content = append(content, inlineContent(node, source, withMark(marks, map[string]any{"type": kind}))...)
115
+ case *ast.Link:
116
+ mark := map[string]any{"type": "link", "attrs": map[string]any{"href": string(node.Destination)}}
117
+ content = append(content, inlineContent(node, source, withMark(marks, mark))...)
118
+ case *ast.AutoLink:
119
+ content = append(content, textNode(string(node.Label(source)), withMark(marks, map[string]any{
120
+ "type": "link", "attrs": map[string]any{"href": string(node.URL(source))},
121
+ })))
122
+ case *ast.CodeSpan:
123
+ content = append(content, inlineContent(node, source, withMark(marks, map[string]any{"type": "code"}))...)
124
+ case *ast.RawHTML:
125
+ content = append(content, textNode(string(node.Text(source)), marks))
126
+ default:
127
+ content = append(content, inlineContent(node, source, marks)...)
128
+ }
129
+ }
130
+ return content
131
+ }
132
+
133
+ func withMark(marks []any, mark map[string]any) []any {
134
+ result := make([]any, len(marks), len(marks)+1)
135
+ copy(result, marks)
136
+ return append(result, mark)
137
+ }
138
+
139
+ func textNode(value string, marks []any) map[string]any {
140
+ result := map[string]any{"type": "text", "text": value}
141
+ if len(marks) != 0 {
142
+ result["marks"] = marks
143
+ }
144
+ return result
145
+ }
146
+
147
+ func ADFText(raw json.RawMessage) string {
148
+ var node struct {
149
+ Text string `json:"text"`
150
+ Content []json.RawMessage `json:"content"`
151
+ }
152
+ if json.Unmarshal(raw, &node) != nil {
153
+ return ""
154
+ }
155
+ parts := make([]string, 0, len(node.Content)+1)
156
+ if node.Text != "" {
157
+ parts = append(parts, node.Text)
158
+ }
159
+ for _, child := range node.Content {
160
+ if text := ADFText(child); text != "" {
161
+ parts = append(parts, text)
162
+ }
163
+ }
164
+ return strings.Join(parts, "\n")
165
+ }
@@ -0,0 +1,60 @@
1
+ package rest
2
+
3
+ import (
4
+ "encoding/json"
5
+ "reflect"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func TestADFConvertsExplicitMarkdown(t *testing.T) {
11
+ doc := ADF("# Heading\n\nWords with **strong**, *emphasis*, [a link](https://example.com), and `code`.\n\n1. first\n2. second\n\n```go\nfmt.Println(\"ok\")\n```")
12
+ content := doc["content"].([]any)
13
+
14
+ if got := content[0].(map[string]any); got["type"] != "heading" || !reflect.DeepEqual(got["attrs"], map[string]any{"level": 1}) {
15
+ t.Fatalf("heading = %#v", got)
16
+ }
17
+
18
+ raw, err := json.Marshal(doc)
19
+ if err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ for _, want := range []string{
23
+ `"type":"strong"`,
24
+ `"type":"em"`,
25
+ `"attrs":{"href":"https://example.com"},"type":"link"`,
26
+ `"type":"code"`,
27
+ `"type":"orderedList"`,
28
+ `"attrs":{"language":"go"}`,
29
+ `"type":"codeBlock"`,
30
+ } {
31
+ if !strings.Contains(string(raw), want) {
32
+ t.Errorf("ADF missing %s: %s", want, raw)
33
+ }
34
+ }
35
+ }
36
+
37
+ func TestADFDoesNotInferFormatting(t *testing.T) {
38
+ doc := ADF("SUMMARY\n\nNode: implement\n\nHTTPS://EXAMPLE.COM")
39
+ raw, err := json.Marshal(doc)
40
+ if err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ if strings.Contains(string(raw), `"type":"heading"`) || strings.Contains(string(raw), `"type":"strong"`) {
44
+ t.Fatalf("plain text was inferred as formatting: %s", raw)
45
+ }
46
+ if got := len(doc["content"].([]any)); got != 3 {
47
+ t.Fatalf("paragraph count = %d, want 3", got)
48
+ }
49
+ }
50
+
51
+ func TestADFMarkerRoundTrip(t *testing.T) {
52
+ const marker = "<!-- run-1:summary -->"
53
+ raw, err := json.Marshal(ADF("Done.\n\n" + marker))
54
+ if err != nil {
55
+ t.Fatal(err)
56
+ }
57
+ if got := ADFText(raw); !strings.Contains(got, marker) {
58
+ t.Fatalf("ADFText() = %q, want marker %q", got, marker)
59
+ }
60
+ }