relay-flow 0.2.1-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 +155 -22
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +74 -0
- package/cmd/relay-flow/main.go +25 -0
- package/cmd/relay-flow/scenario_test.go +48 -12
- package/cmd/relay-flow/serve.go +4 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +6 -6
- 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 +105 -56
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine_test.go +33 -15
- package/internal/execution/goworkflows/fakes_test.go +50 -4
- package/internal/execution/goworkflows/interpreter.go +36 -29
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_test.go +30 -5
- package/internal/execution/goworkflows/recovery_test.go +2 -2
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/harness/contract_test.go +10 -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/recover/recover.go +11 -6
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +51 -2
- package/internal/run/run.go +6 -4
- 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/empty.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 +10 -0
- package/internal/task/factory.go +37 -4
- package/internal/task/jira/auth.go +27 -1
- package/internal/task/jira/auth_test.go +54 -1
- package/internal/task/jira/filters_test.go +60 -0
- package/internal/task/jira/jira.go +171 -29
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/rest/adf.go +119 -82
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client_test.go +1 -1
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +4 -2
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- package/package.json +1 -1
|
@@ -2,109 +2,146 @@ package rest
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"encoding/json"
|
|
5
|
-
"regexp"
|
|
6
5
|
"strings"
|
|
6
|
+
|
|
7
|
+
"github.com/yuin/goldmark"
|
|
8
|
+
"github.com/yuin/goldmark/ast"
|
|
9
|
+
"github.com/yuin/goldmark/text"
|
|
7
10
|
)
|
|
8
11
|
|
|
9
|
-
var
|
|
12
|
+
var markdown = goldmark.New()
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if i < len(lines) {
|
|
28
|
-
i++
|
|
29
|
-
}
|
|
30
|
-
content = append(content, map[string]any{"type": "codeBlock", "content": []any{textNode(strings.Join(code, "\n"))}})
|
|
31
|
-
continue
|
|
32
|
-
}
|
|
33
|
-
if strings.HasPrefix(line, "- ") || orderedItem.MatchString(line) {
|
|
34
|
-
ordered := orderedItem.MatchString(line)
|
|
35
|
-
items := make([]any, 0)
|
|
36
|
-
for i < len(lines) {
|
|
37
|
-
item := strings.TrimSpace(lines[i])
|
|
38
|
-
if ordered {
|
|
39
|
-
if !orderedItem.MatchString(item) {
|
|
40
|
-
break
|
|
41
|
-
}
|
|
42
|
-
item = orderedItem.ReplaceAllString(item, "")
|
|
43
|
-
} else {
|
|
44
|
-
if !strings.HasPrefix(item, "- ") {
|
|
45
|
-
break
|
|
46
|
-
}
|
|
47
|
-
item = strings.TrimPrefix(item, "- ")
|
|
48
|
-
}
|
|
49
|
-
items = append(items, map[string]any{
|
|
50
|
-
"type": "listItem",
|
|
51
|
-
"content": []any{paragraph(item)},
|
|
52
|
-
})
|
|
53
|
-
i++
|
|
54
|
-
}
|
|
55
|
-
kind := "bulletList"
|
|
56
|
-
if ordered {
|
|
57
|
-
kind = "orderedList"
|
|
58
|
-
}
|
|
59
|
-
content = append(content, map[string]any{"type": kind, "content": items})
|
|
60
|
-
continue
|
|
61
|
-
}
|
|
62
|
-
if heading(line) {
|
|
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:
|
|
63
30
|
content = append(content, map[string]any{
|
|
64
31
|
"type": "heading",
|
|
65
|
-
"attrs": map[string]any{"level":
|
|
66
|
-
"content":
|
|
32
|
+
"attrs": map[string]any{"level": node.Level},
|
|
33
|
+
"content": inlineContent(node, source, nil),
|
|
67
34
|
})
|
|
68
|
-
|
|
69
|
-
|
|
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)...)
|
|
70
47
|
}
|
|
71
|
-
content = append(content, paragraph(line))
|
|
72
|
-
i++
|
|
73
|
-
}
|
|
74
|
-
if len(content) == 0 {
|
|
75
|
-
content = append(content, paragraph(""))
|
|
76
48
|
}
|
|
77
|
-
return
|
|
49
|
+
return content
|
|
78
50
|
}
|
|
79
51
|
|
|
80
|
-
func
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
}
|
|
83
69
|
}
|
|
84
|
-
return
|
|
70
|
+
return result
|
|
85
71
|
}
|
|
86
72
|
|
|
87
|
-
func
|
|
88
|
-
|
|
89
|
-
|
|
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)},
|
|
90
77
|
}
|
|
91
|
-
|
|
78
|
+
if len(language) != 0 {
|
|
79
|
+
result["attrs"] = map[string]any{"language": string(language)}
|
|
80
|
+
}
|
|
81
|
+
return result
|
|
92
82
|
}
|
|
93
83
|
|
|
94
|
-
func
|
|
95
|
-
|
|
96
|
-
|
|
84
|
+
func paragraph(content []any) map[string]any {
|
|
85
|
+
result := map[string]any{"type": "paragraph"}
|
|
86
|
+
if len(content) != 0 {
|
|
87
|
+
result["content"] = content
|
|
97
88
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
}
|
|
102
129
|
}
|
|
103
|
-
return
|
|
130
|
+
return content
|
|
104
131
|
}
|
|
105
132
|
|
|
106
|
-
func
|
|
107
|
-
|
|
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
|
|
108
145
|
}
|
|
109
146
|
|
|
110
147
|
func ADFText(raw json.RawMessage) string {
|
|
@@ -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
|
+
}
|
|
@@ -355,7 +355,7 @@ func TestErrorRedactsToken(t *testing.T) {
|
|
|
355
355
|
}
|
|
356
356
|
|
|
357
357
|
func TestADFFormatsHeadingsListsAndRoundTripsMarker(t *testing.T) {
|
|
358
|
-
doc := ADF("
|
|
358
|
+
doc := ADF("### Summary\n\n**Node:** implement\n\n- first\n- second\n\n```\ngo test ./...\n```\n\n<!-- visit:summary -->")
|
|
359
359
|
raw, err := json.Marshal(doc)
|
|
360
360
|
if err != nil {
|
|
361
361
|
t.Fatal(err)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"os"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
func TestTaskTextTemplatesExposeValues(t *testing.T) {
|
|
16
|
+
fake := &fakeJira{}
|
|
17
|
+
sys, err := newSystem(context.Background(), &fakeClient{fake: fake}, task.RepoSpec{
|
|
18
|
+
Name: "payments",
|
|
19
|
+
RootConfig: config.RawValues{"templates": map[string]any{
|
|
20
|
+
"mailboxDescription": "mailbox {{runID}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nodeType}}|{{agent}}|{{nodeDescription}}|{{nextSteps}}|{{successRoutes}}|{{failureRoutes}}|{{mailbox}}",
|
|
21
|
+
"summaryComment": "summary {{sourceNode}}|{{targetNode}}|{{mailbox}}\n{{summaryReport}}",
|
|
22
|
+
"feedbackComment": "feedback {{sourceNode}}|{{targetNode}}|{{mailbox}}\n{{feedbackReport}}",
|
|
23
|
+
}},
|
|
24
|
+
RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
|
|
25
|
+
})
|
|
26
|
+
if err != nil {
|
|
27
|
+
t.Fatal(err)
|
|
28
|
+
}
|
|
29
|
+
data := task.TextData{
|
|
30
|
+
RunID: "run-1", Ticket: "PAY-1", Workflow: "flow", Repo: "payments",
|
|
31
|
+
Node: "review", NodeType: "hitl", Agent: "reviewer", NodeDescription: "Review changes.",
|
|
32
|
+
NextSteps: "implement; end", SuccessRoutes: "end", FailureRoutes: "implement",
|
|
33
|
+
Mailbox: "PAY-3", SourceNode: "review", TargetNode: "implement",
|
|
34
|
+
SummaryReport: "COMPLETED:\nreviewed", FeedbackReport: "REQUIRED ACTIONS:\nfix it",
|
|
35
|
+
}
|
|
36
|
+
description, err := sys.RenderText(task.TextMailboxDescription, data)
|
|
37
|
+
if err != nil {
|
|
38
|
+
t.Fatal(err)
|
|
39
|
+
}
|
|
40
|
+
for _, want := range []string{"run-1", "PAY-1", "flow", "payments", "review", "hitl", "reviewer", "Review changes.", "implement; end", "PAY-3"} {
|
|
41
|
+
if !strings.Contains(description, want) {
|
|
42
|
+
t.Fatalf("description missing %q: %q", want, description)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
summary, err := sys.RenderText(task.TextSummaryComment, data)
|
|
46
|
+
if err != nil || !strings.Contains(summary, data.SummaryReport) {
|
|
47
|
+
t.Fatalf("summary = %q, %v", summary, err)
|
|
48
|
+
}
|
|
49
|
+
feedback, err := sys.RenderText(task.TextFeedbackComment, data)
|
|
50
|
+
if err != nil || !strings.Contains(feedback, "feedback review|implement|PAY-3") || !strings.Contains(feedback, data.FeedbackReport) {
|
|
51
|
+
t.Fatalf("feedback = %q, %v", feedback, err)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func TestCommentTemplatesRequireReportValues(t *testing.T) {
|
|
56
|
+
for _, tc := range []struct {
|
|
57
|
+
name string
|
|
58
|
+
override map[string]any
|
|
59
|
+
wantError string
|
|
60
|
+
}{
|
|
61
|
+
{name: "summary", override: map[string]any{"summaryComment": "missing"}, wantError: "summaryComment must contain {{summaryReport}}"},
|
|
62
|
+
{name: "feedback", override: map[string]any{"feedbackComment": "missing"}, wantError: "feedbackComment must contain {{feedbackReport}}"},
|
|
63
|
+
{name: "unknown", override: map[string]any{"mailboxDescription": "{{mailboxInstructions}}"}, wantError: "unknown template variable {{mailboxInstructions}}"},
|
|
64
|
+
} {
|
|
65
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
66
|
+
raw := config.Merge(DefaultConfig(), config.RawValues{"templates": tc.override})
|
|
67
|
+
var cfg Config
|
|
68
|
+
if err := config.DecodeStrict(raw, &cfg); err != nil {
|
|
69
|
+
t.Fatal(err)
|
|
70
|
+
}
|
|
71
|
+
err := validateTemplates(cfg.Templates)
|
|
72
|
+
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
|
|
73
|
+
t.Fatalf("validateTemplates error = %v, want %q", err, tc.wantError)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func TestTaskTemplatesRenderSharedReportContractValues(t *testing.T) {
|
|
80
|
+
b, err := os.ReadFile("../../../testdata/report-contract.json")
|
|
81
|
+
if err != nil {
|
|
82
|
+
t.Fatal(err)
|
|
83
|
+
}
|
|
84
|
+
var fixtures map[string]struct {
|
|
85
|
+
Envelope run.ReportRequest `json:"envelope"`
|
|
86
|
+
SummaryReport string `json:"summaryReport"`
|
|
87
|
+
FeedbackReport string `json:"feedbackReport"`
|
|
88
|
+
}
|
|
89
|
+
if err := json.Unmarshal(b, &fixtures); err != nil {
|
|
90
|
+
t.Fatal(err)
|
|
91
|
+
}
|
|
92
|
+
fixture := fixtures["work"]
|
|
93
|
+
sys, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
|
|
94
|
+
Name: "payments", RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
|
|
95
|
+
})
|
|
96
|
+
if err != nil {
|
|
97
|
+
t.Fatal(err)
|
|
98
|
+
}
|
|
99
|
+
data := task.TextData{
|
|
100
|
+
Node: "coding", SourceNode: "coding", TargetNode: "coding", Mailbox: "PAY-101:coding",
|
|
101
|
+
SummaryReport: fixture.SummaryReport, FeedbackReport: fixture.FeedbackReport,
|
|
102
|
+
}
|
|
103
|
+
summary, err := sys.RenderText(task.TextSummaryComment, data)
|
|
104
|
+
if err != nil {
|
|
105
|
+
t.Fatal(err)
|
|
106
|
+
}
|
|
107
|
+
if summary != "Summary for coding\n\n"+fixture.SummaryReport {
|
|
108
|
+
t.Fatalf("summary template did not render shared summaryReport:\n%s", summary)
|
|
109
|
+
}
|
|
110
|
+
feedback, err := sys.RenderText(task.TextFeedbackComment, data)
|
|
111
|
+
if err != nil {
|
|
112
|
+
t.Fatal(err)
|
|
113
|
+
}
|
|
114
|
+
wantFeedback := "Feedback from coding to coding mailbox PAY-101:coding\n\n" + fixture.FeedbackReport
|
|
115
|
+
if feedback != wantFeedback {
|
|
116
|
+
t.Fatalf("feedback template did not render shared feedbackReport:\n%s", feedback)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -128,8 +128,10 @@ func TestEndDefaultParentDone(t *testing.T) {
|
|
|
128
128
|
|
|
129
129
|
// end: omitted transitionTo. The end effective config carries no
|
|
130
130
|
// transition; the adapter must still default the parent to Done. Run
|
|
131
|
-
// orchestration
|
|
132
|
-
|
|
131
|
+
// orchestration merges EndDefaults under the end node config before
|
|
132
|
+
// applying it to the parent target.
|
|
133
|
+
endCfg := config.Merge(sys.(task.LifecycleDefaults).EndDefaults(), config.RawValues{})
|
|
134
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, endCfg); err != nil {
|
|
133
135
|
t.Fatalf("ApplyTaskConfig end failed: %v", err)
|
|
134
136
|
}
|
|
135
137
|
if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "Done" {
|
package/internal/task/task.go
CHANGED
|
@@ -37,6 +37,37 @@ type MailboxSpec struct {
|
|
|
37
37
|
Title string `json:"title"`
|
|
38
38
|
Description string `json:"description"`
|
|
39
39
|
TaskConfig config.RawValues `json:"taskConfig,omitempty"`
|
|
40
|
+
TextData TextData `json:"textData"`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type TextKind string
|
|
44
|
+
|
|
45
|
+
const (
|
|
46
|
+
TextMailboxDescription TextKind = "mailboxDescription"
|
|
47
|
+
TextSummaryComment TextKind = "summaryComment"
|
|
48
|
+
TextFeedbackComment TextKind = "feedbackComment"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// TextData contains existing run, mailbox, and node values supplied to a
|
|
52
|
+
// task-system text template. The task system owns rendering; harness prompt
|
|
53
|
+
// templates are a separate contract.
|
|
54
|
+
type TextData struct {
|
|
55
|
+
RunID string
|
|
56
|
+
Ticket string
|
|
57
|
+
Workflow string
|
|
58
|
+
Repo string
|
|
59
|
+
Node string
|
|
60
|
+
NodeType string
|
|
61
|
+
Agent string
|
|
62
|
+
NodeDescription string
|
|
63
|
+
NextSteps string
|
|
64
|
+
SuccessRoutes string
|
|
65
|
+
FailureRoutes string
|
|
66
|
+
Mailbox string
|
|
67
|
+
SourceNode string
|
|
68
|
+
TargetNode string
|
|
69
|
+
SummaryReport string
|
|
70
|
+
FeedbackReport string
|
|
40
71
|
}
|
|
41
72
|
|
|
42
73
|
type Target struct {
|
|
@@ -59,6 +90,7 @@ type System interface {
|
|
|
59
90
|
Claim(ctx context.Context, ticket TicketRef, workflow string) error
|
|
60
91
|
|
|
61
92
|
ValidateConfig(ctx context.Context, workflowTaskConfig config.RawValues, nodeTaskConfigs map[string]config.RawValues) error
|
|
93
|
+
RenderText(kind TextKind, data TextData) (string, error)
|
|
62
94
|
EnsureMailboxes(ctx context.Context, parent TicketRef, workflow string, specs []MailboxSpec) (map[string]Mailbox, error)
|
|
63
95
|
ApplyTaskConfig(ctx context.Context, target Target, taskConfig config.RawValues) error
|
|
64
96
|
CompleteMailbox(ctx context.Context, mailbox Mailbox) error
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
package workflow_test
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"os"
|
|
4
6
|
"strings"
|
|
5
7
|
"testing"
|
|
6
8
|
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
7
10
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
8
11
|
)
|
|
9
12
|
|
|
@@ -39,6 +42,48 @@ func noneFeedback() workflow.Feedback {
|
|
|
39
42
|
}
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
type reportContractFixture struct {
|
|
46
|
+
AssistantText string `json:"assistantText"`
|
|
47
|
+
Envelope run.ReportRequest `json:"envelope"`
|
|
48
|
+
SummaryReport string `json:"summaryReport"`
|
|
49
|
+
FeedbackReport string `json:"feedbackReport"`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func reportContractFixtures(t *testing.T) map[string]reportContractFixture {
|
|
53
|
+
t.Helper()
|
|
54
|
+
b, err := os.ReadFile("../../testdata/report-contract.json")
|
|
55
|
+
if err != nil {
|
|
56
|
+
t.Fatal(err)
|
|
57
|
+
}
|
|
58
|
+
var fixtures map[string]reportContractFixture
|
|
59
|
+
if err := json.Unmarshal(b, &fixtures); err != nil {
|
|
60
|
+
t.Fatal(err)
|
|
61
|
+
}
|
|
62
|
+
return fixtures
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func TestSharedReportContractFixtureMatchesGoValidation(t *testing.T) {
|
|
66
|
+
wf := parse(t, "basicFlow", minimalValid)
|
|
67
|
+
fixtures := reportContractFixtures(t)
|
|
68
|
+
for _, name := range []string{"work", "end"} {
|
|
69
|
+
t.Run(name, func(t *testing.T) {
|
|
70
|
+
fixture := fixtures[name]
|
|
71
|
+
if fixture.Envelope.Report.Summary.Completed == "" || fixture.Envelope.Report.Feedback.ReasonForNextStep == "" {
|
|
72
|
+
t.Fatal("shared report fixture must contain both summary and feedback")
|
|
73
|
+
}
|
|
74
|
+
if err := wf.ValidateReport(fixture.Envelope.Node, fixture.Envelope.Report); err != nil {
|
|
75
|
+
t.Fatalf("shared report fixture rejected: %v", err)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
invalidEnd := fixtures["end"].Envelope.Report
|
|
81
|
+
invalidEnd.Feedback.RequiredActions = "do more work"
|
|
82
|
+
if err := wf.ValidateReport("coding", invalidEnd); err == nil {
|
|
83
|
+
t.Fatal("end fixture with feedback content accepted")
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
42
87
|
func TestValidateReportAcceptsCompleteSuccess(t *testing.T) {
|
|
43
88
|
wf := parse(t, "basicFlow", minimalValid)
|
|
44
89
|
r := workflow.Report{
|