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.
- package/README.md +171 -26
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +593 -15
- package/cmd/relay-flow/main.go +356 -119
- package/cmd/relay-flow/scenario_test.go +246 -35
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +136 -87
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +100 -22
- package/internal/execution/goworkflows/fakes_test.go +63 -25
- package/internal/execution/goworkflows/interpreter.go +45 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +72 -23
- package/internal/execution/goworkflows/recovery_test.go +7 -7
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +15 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +183 -0
- package/internal/harness/opencode/repo_setup.go +361 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/paths/paths.go +18 -16
- package/internal/recover/recover.go +11 -6
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +55 -6
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run.go +6 -4
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -0
- package/internal/task/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +840 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +233 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +12 -0
- package/internal/task/factory.go +52 -3
- package/internal/task/jira/auth.go +209 -0
- package/internal/task/jira/auth_test.go +160 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +96 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +263 -90
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +165 -0
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +22 -18
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
- /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
|
|
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
|
|
40
|
-
//
|
|
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
|
+
}
|