relay-flow 0.2.1-alpha → 0.2.3-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 +193 -25
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +79 -1
- package/cmd/relay-flow/main.go +50 -1
- package/cmd/relay-flow/scenario_test.go +48 -12
- package/cmd/relay-flow/serve.go +16 -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 +124 -56
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +20 -0
- package/internal/execution/goworkflows/engine_test.go +33 -15
- package/internal/execution/goworkflows/fakes_test.go +84 -10
- package/internal/execution/goworkflows/interpreter.go +84 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_test.go +30 -5
- package/internal/execution/goworkflows/projection.go +52 -11
- package/internal/execution/goworkflows/recovery_test.go +131 -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 +238 -0
- package/internal/harness/opencode/repo_setup.go +401 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/identity/identity.go +28 -1
- package/internal/identity/identity_test.go +40 -0
- 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/manager.go +193 -25
- package/internal/run/run.go +30 -10
- package/internal/run/run_manager_test.go +100 -0
- package/internal/server/api_test.go +54 -0
- package/internal/server/client.go +11 -0
- package/internal/server/fixture_test.go +18 -0
- package/internal/server/server.go +16 -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/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 +856 -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 +276 -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/helpers_test.go +3 -0
- package/internal/task/jira/jira.go +187 -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 +47 -2
- package/internal/task/task.go +40 -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
|
+
}
|
|
@@ -6,6 +6,7 @@ import (
|
|
|
6
6
|
"testing"
|
|
7
7
|
|
|
8
8
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/retry"
|
|
9
10
|
"github.com/rajpopat27/relay-flow/internal/task"
|
|
10
11
|
)
|
|
11
12
|
|
|
@@ -27,6 +28,7 @@ type fakeJira struct {
|
|
|
27
28
|
taskTransitions []string
|
|
28
29
|
assignments []string
|
|
29
30
|
assignErr error
|
|
31
|
+
transitionErr error
|
|
30
32
|
events []string
|
|
31
33
|
// searchJSON is the raw Jira search response Poll serves.
|
|
32
34
|
searchJSON []byte
|
|
@@ -128,8 +130,10 @@ func TestEndDefaultParentDone(t *testing.T) {
|
|
|
128
130
|
|
|
129
131
|
// end: omitted transitionTo. The end effective config carries no
|
|
130
132
|
// transition; the adapter must still default the parent to Done. Run
|
|
131
|
-
// orchestration
|
|
132
|
-
|
|
133
|
+
// orchestration merges EndDefaults under the end node config before
|
|
134
|
+
// applying it to the parent target.
|
|
135
|
+
endCfg := config.Merge(sys.(task.LifecycleDefaults).EndDefaults(), config.RawValues{})
|
|
136
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, endCfg); err != nil {
|
|
133
137
|
t.Fatalf("ApplyTaskConfig end failed: %v", err)
|
|
134
138
|
}
|
|
135
139
|
if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "Done" {
|
|
@@ -137,6 +141,47 @@ func TestEndDefaultParentDone(t *testing.T) {
|
|
|
137
141
|
}
|
|
138
142
|
}
|
|
139
143
|
|
|
144
|
+
func TestPrepareRestartReopensMailboxesWithoutChangingParent(t *testing.T) {
|
|
145
|
+
fake := &fakeJira{}
|
|
146
|
+
sys := newSystemWithFake(t, fake)
|
|
147
|
+
preparer, ok := sys.(task.RestartPreparer)
|
|
148
|
+
if !ok {
|
|
149
|
+
t.Fatal("Jira system does not implement RestartPreparer")
|
|
150
|
+
}
|
|
151
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101"}
|
|
152
|
+
mailboxes := []task.Mailbox{
|
|
153
|
+
{ID: "2", Key: "PAY-102", Node: "coding"},
|
|
154
|
+
{ID: "3", Key: "PAY-103", Node: "review"},
|
|
155
|
+
}
|
|
156
|
+
if err := preparer.PrepareRestart(context.Background(), parent, mailboxes); err != nil {
|
|
157
|
+
t.Fatalf("PrepareRestart failed: %v", err)
|
|
158
|
+
}
|
|
159
|
+
if len(fake.taskTransitions) != 2 || fake.taskTransitions[0] != "To Do" || fake.taskTransitions[1] != "To Do" {
|
|
160
|
+
t.Fatalf("mailbox transitions = %v, want two To Do transitions", fake.taskTransitions)
|
|
161
|
+
}
|
|
162
|
+
if len(fake.parentTransitions) != 0 {
|
|
163
|
+
t.Fatalf("parent transitions = %v, want none (start owns parent status)", fake.parentTransitions)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func TestPrepareRestartPreservesHumanJiraStateOnConflict(t *testing.T) {
|
|
168
|
+
fake := &fakeJira{transitionErr: errors.New(`transition to "To Do" is not available for PAY-102`)}
|
|
169
|
+
sys := newSystemWithFake(t, fake)
|
|
170
|
+
preparer := sys.(task.RestartPreparer)
|
|
171
|
+
err := preparer.PrepareRestart(context.Background(), task.TicketRef{Key: "PAY-101"}, []task.Mailbox{
|
|
172
|
+
{ID: "2", Key: "PAY-102", Node: "coding"},
|
|
173
|
+
})
|
|
174
|
+
if err == nil {
|
|
175
|
+
t.Fatal("incompatible Jira mailbox status was accepted")
|
|
176
|
+
}
|
|
177
|
+
if got := retry.Classify(err).Kind; got != retry.Conflict {
|
|
178
|
+
t.Fatalf("failure kind = %q, want conflict: %v", got, err)
|
|
179
|
+
}
|
|
180
|
+
if len(fake.parentTransitions) != 0 {
|
|
181
|
+
t.Fatalf("parent transitions = %v, want none", fake.parentTransitions)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
140
185
|
func TestExplicitTransitionsWin(t *testing.T) {
|
|
141
186
|
fake := &fakeJira{}
|
|
142
187
|
sys := newSystemWithFake(t, fake)
|
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
|
|
@@ -67,6 +99,14 @@ type System interface {
|
|
|
67
99
|
ResetForRecovery(ctx context.Context, parent TicketRef, mailboxes []Mailbox, taskConfig config.RawValues) error
|
|
68
100
|
}
|
|
69
101
|
|
|
102
|
+
// RestartPreparer is an optional adapter capability used only by explicit
|
|
103
|
+
// run restarts. It reopens relay-owned mailbox state while preserving all
|
|
104
|
+
// comments, labels, and descriptions. A human-owned incompatible state must
|
|
105
|
+
// be returned as retry.ConflictError; core never names provider statuses.
|
|
106
|
+
type RestartPreparer interface {
|
|
107
|
+
PrepareRestart(ctx context.Context, parent TicketRef, mailboxes []Mailbox) error
|
|
108
|
+
}
|
|
109
|
+
|
|
70
110
|
// LifecycleDefaults is an optional adapter capability: a System whose
|
|
71
111
|
// taskConfig carries lifecycle-dependent defaults (e.g. Jira's deterministic
|
|
72
112
|
// transitionTo defaults) exposes them here. The lifecycle-aware caller
|
|
@@ -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{
|