relay-flow 0.0.1 → 0.2.0-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 +148 -143
- package/cmd/relay-flow/commands_test.go +464 -0
- package/cmd/relay-flow/main.go +670 -180
- package/cmd/relay-flow/scenario_test.go +1135 -0
- package/cmd/relay-flow/serve.go +609 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +487 -0
- package/internal/execution/goworkflows/engine_test.go +600 -0
- package/internal/execution/goworkflows/fakes_test.go +517 -0
- package/internal/execution/goworkflows/interpreter.go +605 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
- package/internal/execution/goworkflows/node_runtime_test.go +486 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +169 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +67 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +119 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +83 -0
- package/internal/router/router_test.go +144 -0
- package/internal/run/manager.go +108 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +266 -0
- package/internal/runner/contract_test.go +221 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +363 -170
- package/internal/runner/orca/orca_test.go +134 -160
- package/internal/runner/orca/orcacli/orcacli.go +215 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +47 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/contract_test.go +223 -0
- package/internal/task/factory.go +103 -0
- package/internal/task/jira/acli/acli.go +306 -0
- package/internal/task/jira/acli/acli_test.go +208 -0
- package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
- package/internal/task/jira/acli/testdata/search_success.json +1 -0
- package/internal/task/jira/filters_test.go +234 -0
- package/internal/task/jira/helpers_test.go +60 -0
- package/internal/task/jira/jira.go +507 -0
- package/internal/task/jira/normalize.go +101 -0
- package/internal/task/jira/testdata/acli_search.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +156 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +342 -0
- package/internal/workflow/workflow_test.go +410 -0
- package/package.json +1 -1
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- package/internal/tasks/tasks_test.go +0 -91
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
package workflow_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// 3.1: parse/validate behavior per specs/workflow-definition.
|
|
11
|
+
|
|
12
|
+
const minimalValid = `
|
|
13
|
+
name: basicFlow
|
|
14
|
+
repos: [payments]
|
|
15
|
+
nodes:
|
|
16
|
+
start:
|
|
17
|
+
onSuccess:
|
|
18
|
+
- target: coding
|
|
19
|
+
coding:
|
|
20
|
+
type: agent
|
|
21
|
+
agent: build
|
|
22
|
+
description: Do the coding work.
|
|
23
|
+
onSuccess:
|
|
24
|
+
- target: end
|
|
25
|
+
onFailure:
|
|
26
|
+
- target: coding
|
|
27
|
+
end: {}
|
|
28
|
+
`
|
|
29
|
+
|
|
30
|
+
func parse(t *testing.T, name, yaml string) *workflow.Workflow {
|
|
31
|
+
t.Helper()
|
|
32
|
+
wf, err := workflow.Parse(name, []byte(yaml))
|
|
33
|
+
if err != nil {
|
|
34
|
+
t.Fatalf("Parse(%q) failed: %v", name, err)
|
|
35
|
+
}
|
|
36
|
+
return wf
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func TestParseMinimalValid(t *testing.T) {
|
|
40
|
+
wf := parse(t, "basicFlow", minimalValid)
|
|
41
|
+
if err := wf.Validate(); err != nil {
|
|
42
|
+
t.Fatalf("Validate failed: %v", err)
|
|
43
|
+
}
|
|
44
|
+
if wf.Name != "basicFlow" {
|
|
45
|
+
t.Fatalf("Name = %q, want basicFlow", wf.Name)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func TestParseRejectsUnknownRootFields(t *testing.T) {
|
|
50
|
+
// Legacy fields and plugin selectors are rejected by strict parsing.
|
|
51
|
+
for _, tc := range []struct{ name, yaml string }{
|
|
52
|
+
{"legacy closeOn", strings.Replace(minimalValid, "repos: [payments]", "repos: [payments]\ncloseOn: [end]", 1)},
|
|
53
|
+
{"legacy tasks", strings.Replace(minimalValid, "repos: [payments]", "repos: [payments]\ntasks: {}", 1)},
|
|
54
|
+
{"runner plugin selector", strings.Replace(minimalValid, "repos: [payments]", "repos: [payments]\nrunnerPlugin: orca", 1)},
|
|
55
|
+
{"harness plugin selector", strings.Replace(minimalValid, "repos: [payments]", "repos: [payments]\nharnessPlugin: opencode", 1)},
|
|
56
|
+
{"task plugin selector", strings.Replace(minimalValid, "repos: [payments]", "repos: [payments]\ntaskPlugin: jira", 1)},
|
|
57
|
+
{"poll interval override", strings.Replace(minimalValid, "repos: [payments]", "repos: [payments]\npollIntervalSeconds: 5", 1)},
|
|
58
|
+
{"arbitrary query field", strings.Replace(minimalValid, "repos: [payments]", "repos: [payments]\njql: project = PAY", 1)},
|
|
59
|
+
{"legacy node-level when", strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n when: In Progress", 1)},
|
|
60
|
+
{"unknown node-level field", strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n timeout: 30", 1)},
|
|
61
|
+
} {
|
|
62
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
63
|
+
if _, err := workflow.Parse("basicFlow", []byte(tc.yaml)); err == nil {
|
|
64
|
+
t.Fatal("Parse succeeded, want strict-schema rejection")
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func TestValidateWorkflowName(t *testing.T) {
|
|
71
|
+
for _, bad := range []string{"BasicFlow", "basic flow", "basic-flow", "basic_flow", "1flow", ""} {
|
|
72
|
+
yaml := strings.Replace(minimalValid, "name: basicFlow", "name: "+bad, 1)
|
|
73
|
+
wf, err := workflow.Parse(bad, []byte(yaml))
|
|
74
|
+
if err == nil {
|
|
75
|
+
err = wf.Validate()
|
|
76
|
+
}
|
|
77
|
+
if err == nil {
|
|
78
|
+
t.Fatalf("name %q accepted, want rejection", bad)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if err := parse(t, "basicFlow", minimalValid).Validate(); err != nil {
|
|
82
|
+
t.Fatalf("basicFlow rejected: %v", err)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func TestValidateReposRequiredAndUnique(t *testing.T) {
|
|
87
|
+
t.Run("no repos", func(t *testing.T) {
|
|
88
|
+
yaml := strings.Replace(minimalValid, "repos: [payments]", "repos: []", 1)
|
|
89
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
90
|
+
if err := wf.Validate(); err == nil {
|
|
91
|
+
t.Fatal("empty repos accepted")
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
t.Run("duplicate repo", func(t *testing.T) {
|
|
95
|
+
yaml := strings.Replace(minimalValid, "repos: [payments]", "repos: [payments, payments]", 1)
|
|
96
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
97
|
+
if err := wf.Validate(); err == nil {
|
|
98
|
+
t.Fatal("duplicate repo accepted")
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func TestValidateStartNode(t *testing.T) {
|
|
104
|
+
t.Run("missing start", func(t *testing.T) {
|
|
105
|
+
yaml := strings.Replace(minimalValid, " start:\n onSuccess:\n - target: coding\n", "", 1)
|
|
106
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
107
|
+
if err := wf.Validate(); err == nil {
|
|
108
|
+
t.Fatal("workflow without start accepted")
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
t.Run("start with several targets", func(t *testing.T) {
|
|
112
|
+
yaml := strings.Replace(minimalValid, " - target: coding\n coding:", " - target: coding\n - target: end\n coding:", 1)
|
|
113
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
114
|
+
if err := wf.Validate(); err == nil {
|
|
115
|
+
t.Fatal("start with two success routes accepted")
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
t.Run("start with agent", func(t *testing.T) {
|
|
119
|
+
yaml := strings.Replace(minimalValid, " start:\n onSuccess:", " start:\n agent: build\n onSuccess:", 1)
|
|
120
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
121
|
+
if err := wf.Validate(); err == nil {
|
|
122
|
+
t.Fatal("start with agent accepted")
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
t.Run("start with type", func(t *testing.T) {
|
|
126
|
+
yaml := strings.Replace(minimalValid, " start:\n onSuccess:", " start:\n type: agent\n onSuccess:", 1)
|
|
127
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
128
|
+
if err := wf.Validate(); err == nil {
|
|
129
|
+
t.Fatal("start with type accepted")
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
t.Run("start with description", func(t *testing.T) {
|
|
133
|
+
yaml := strings.Replace(minimalValid, " start:\n onSuccess:", " start:\n description: nope\n onSuccess:", 1)
|
|
134
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
135
|
+
if err := wf.Validate(); err == nil {
|
|
136
|
+
t.Fatal("start with description accepted")
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
t.Run("start with nudge prompt", func(t *testing.T) {
|
|
140
|
+
yaml := strings.Replace(minimalValid, " start:\n onSuccess:", " start:\n nudgePrompt: nope\n onSuccess:", 1)
|
|
141
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
142
|
+
if err := wf.Validate(); err == nil {
|
|
143
|
+
t.Fatal("start with nudgePrompt accepted")
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
t.Run("start with failure route", func(t *testing.T) {
|
|
147
|
+
yaml := strings.Replace(minimalValid, " start:\n onSuccess:\n - target: coding", " start:\n onSuccess:\n - target: coding\n onFailure:\n - target: coding", 1)
|
|
148
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
149
|
+
if err := wf.Validate(); err == nil {
|
|
150
|
+
t.Fatal("start with onFailure accepted")
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
t.Run("start with taskConfig ok", func(t *testing.T) {
|
|
154
|
+
yaml := strings.Replace(minimalValid, " start:\n onSuccess:", " start:\n taskConfig:\n transitionTo:\n parentStatus: In Progress\n onSuccess:", 1)
|
|
155
|
+
wf := parse(t, "basicFlow", yaml)
|
|
156
|
+
if err := wf.Validate(); err != nil {
|
|
157
|
+
t.Fatalf("start with taskConfig rejected: %v", err)
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func TestValidateEndNode(t *testing.T) {
|
|
163
|
+
t.Run("missing end", func(t *testing.T) {
|
|
164
|
+
yaml := strings.Replace(minimalValid, " end: {}\n", "", 1)
|
|
165
|
+
// coding needs a success target that exists; point it at itself.
|
|
166
|
+
yaml = strings.Replace(yaml, " - target: end", " - target: coding", 1)
|
|
167
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
168
|
+
if err := wf.Validate(); err == nil {
|
|
169
|
+
t.Fatal("workflow without end accepted")
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
t.Run("end with route", func(t *testing.T) {
|
|
173
|
+
yaml := strings.Replace(minimalValid, " end: {}", " end:\n onSuccess:\n - target: coding", 1)
|
|
174
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
175
|
+
if err := wf.Validate(); err == nil {
|
|
176
|
+
t.Fatal("end with outgoing route accepted")
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
t.Run("end with type", func(t *testing.T) {
|
|
180
|
+
yaml := strings.Replace(minimalValid, " end: {}", " end:\n type: agent", 1)
|
|
181
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
182
|
+
if err := wf.Validate(); err == nil {
|
|
183
|
+
t.Fatal("end with type accepted")
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
t.Run("end with agent", func(t *testing.T) {
|
|
187
|
+
yaml := strings.Replace(minimalValid, " end: {}", " end:\n agent: build", 1)
|
|
188
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
189
|
+
if err := wf.Validate(); err == nil {
|
|
190
|
+
t.Fatal("end with agent accepted")
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
t.Run("end with description", func(t *testing.T) {
|
|
194
|
+
yaml := strings.Replace(minimalValid, " end: {}", " end:\n description: nope", 1)
|
|
195
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
196
|
+
if err := wf.Validate(); err == nil {
|
|
197
|
+
t.Fatal("end with description accepted")
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
t.Run("end with nudge prompt", func(t *testing.T) {
|
|
201
|
+
yaml := strings.Replace(minimalValid, " end: {}", " end:\n nudgePrompt: nope", 1)
|
|
202
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
203
|
+
if err := wf.Validate(); err == nil {
|
|
204
|
+
t.Fatal("end with nudgePrompt accepted")
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
t.Run("end with failure route", func(t *testing.T) {
|
|
208
|
+
yaml := strings.Replace(minimalValid, " end: {}", " end:\n onFailure:\n - target: coding", 1)
|
|
209
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
210
|
+
if err := wf.Validate(); err == nil {
|
|
211
|
+
t.Fatal("end with failure route accepted")
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
t.Run("terminal is not a lifecycle node", func(t *testing.T) {
|
|
215
|
+
yaml := strings.Replace(minimalValid, " end: {}", " terminal: {}", 1)
|
|
216
|
+
yaml = strings.Replace(yaml, " - target: end", " - target: terminal", 1)
|
|
217
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
218
|
+
if err := wf.Validate(); err == nil {
|
|
219
|
+
t.Fatal("workflow using terminal as lifecycle exit accepted; reserved exit is end")
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
func TestValidateWorkNodes(t *testing.T) {
|
|
225
|
+
replaceCoding := func(node string) string {
|
|
226
|
+
return strings.Replace(minimalValid, " coding:\n type: agent\n agent: build\n description: Do the coding work.\n onSuccess:\n - target: end\n onFailure:\n - target: coding", node, 1)
|
|
227
|
+
}
|
|
228
|
+
t.Run("missing type", func(t *testing.T) {
|
|
229
|
+
wf, _ := workflow.Parse("basicFlow", []byte(replaceCoding(" coding:\n agent: build\n description: work\n onSuccess:\n - target: end\n onFailure:\n - target: coding")))
|
|
230
|
+
if err := wf.Validate(); err == nil {
|
|
231
|
+
t.Fatal("work node without type accepted")
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
t.Run("missing agent", func(t *testing.T) {
|
|
235
|
+
wf, _ := workflow.Parse("basicFlow", []byte(replaceCoding(" coding:\n type: agent\n description: work\n onSuccess:\n - target: end\n onFailure:\n - target: coding")))
|
|
236
|
+
if err := wf.Validate(); err == nil {
|
|
237
|
+
t.Fatal("work node without agent accepted")
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
t.Run("missing description", func(t *testing.T) {
|
|
241
|
+
wf, _ := workflow.Parse("basicFlow", []byte(replaceCoding(" coding:\n type: agent\n agent: build\n onSuccess:\n - target: end\n onFailure:\n - target: coding")))
|
|
242
|
+
if err := wf.Validate(); err == nil {
|
|
243
|
+
t.Fatal("work node without description accepted")
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
t.Run("missing failure route", func(t *testing.T) {
|
|
247
|
+
wf, _ := workflow.Parse("basicFlow", []byte(replaceCoding(" coding:\n type: agent\n agent: build\n description: work\n onSuccess:\n - target: end")))
|
|
248
|
+
if err := wf.Validate(); err == nil {
|
|
249
|
+
t.Fatal("work node without failure route accepted")
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
t.Run("missing success route", func(t *testing.T) {
|
|
253
|
+
wf, _ := workflow.Parse("basicFlow", []byte(replaceCoding(" coding:\n type: agent\n agent: build\n description: work\n onFailure:\n - target: coding")))
|
|
254
|
+
if err := wf.Validate(); err == nil {
|
|
255
|
+
t.Fatal("work node without success route accepted")
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
t.Run("valid hitl node", func(t *testing.T) {
|
|
259
|
+
wf := parse(t, "basicFlow", replaceCoding(" coding:\n type: hitl\n agent: build\n description: human-guided work\n onSuccess:\n - target: end\n onFailure:\n - target: coding"))
|
|
260
|
+
if err := wf.Validate(); err != nil {
|
|
261
|
+
t.Fatalf("valid hitl node rejected: %v", err)
|
|
262
|
+
}
|
|
263
|
+
if wf.Nodes["coding"].Type != workflow.NodeHITL {
|
|
264
|
+
t.Fatalf("Type = %q, want hitl", wf.Nodes["coding"].Type)
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
func TestValidateRoutes(t *testing.T) {
|
|
270
|
+
t.Run("dangling target", func(t *testing.T) {
|
|
271
|
+
yaml := strings.Replace(minimalValid, " - target: end", " - target: nowhere", 1)
|
|
272
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
273
|
+
if err := wf.Validate(); err == nil {
|
|
274
|
+
t.Fatal("route to unknown node accepted")
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
t.Run("route targets start", func(t *testing.T) {
|
|
278
|
+
yaml := strings.Replace(minimalValid, " - target: coding\n end:", " - target: start\n end:", 1)
|
|
279
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
280
|
+
if err := wf.Validate(); err == nil {
|
|
281
|
+
t.Fatal("route targeting start accepted")
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
t.Run("failure route targets start", func(t *testing.T) {
|
|
285
|
+
yaml := strings.Replace(minimalValid, " onFailure:\n - target: coding", " onFailure:\n - target: start", 1)
|
|
286
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
287
|
+
if err := wf.Validate(); err == nil {
|
|
288
|
+
t.Fatal("failure route targeting start accepted")
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
t.Run("when is allowed and not a condition", func(t *testing.T) {
|
|
292
|
+
yaml := strings.Replace(minimalValid, " - target: end", " - target: end\n when: work is complete", 1)
|
|
293
|
+
wf := parse(t, "basicFlow", yaml)
|
|
294
|
+
if err := wf.Validate(); err != nil {
|
|
295
|
+
t.Fatalf("route with when rejected: %v", err)
|
|
296
|
+
}
|
|
297
|
+
routes, err := wf.Routes("coding", workflow.OutcomeSuccess)
|
|
298
|
+
if err != nil {
|
|
299
|
+
t.Fatalf("Routes failed: %v", err)
|
|
300
|
+
}
|
|
301
|
+
if len(routes) != 1 || routes[0].When != "work is complete" {
|
|
302
|
+
t.Fatalf("Routes = %+v", routes)
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
t.Run("several routes for one outcome", func(t *testing.T) {
|
|
306
|
+
yaml := strings.Replace(minimalValid, " onFailure:\n - target: coding", " onFailure:\n - target: coding\n when: retry\n - target: end\n when: give up", 1)
|
|
307
|
+
wf := parse(t, "basicFlow", yaml)
|
|
308
|
+
if err := wf.Validate(); err != nil {
|
|
309
|
+
t.Fatalf("two failure routes rejected: %v", err)
|
|
310
|
+
}
|
|
311
|
+
routes, err := wf.Routes("coding", workflow.OutcomeFailure)
|
|
312
|
+
if err != nil || len(routes) != 2 {
|
|
313
|
+
t.Fatalf("Routes = %+v, err=%v", routes, err)
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
func TestValidateGraphReachability(t *testing.T) {
|
|
319
|
+
t.Run("unreachable node", func(t *testing.T) {
|
|
320
|
+
yaml := minimalValid + `
|
|
321
|
+
orphan:
|
|
322
|
+
type: agent
|
|
323
|
+
agent: build
|
|
324
|
+
description: never reached
|
|
325
|
+
onSuccess:
|
|
326
|
+
- target: end
|
|
327
|
+
onFailure:
|
|
328
|
+
- target: orphan
|
|
329
|
+
`
|
|
330
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
331
|
+
if err := wf.Validate(); err == nil {
|
|
332
|
+
t.Fatal("unreachable node accepted")
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
t.Run("end unreachable", func(t *testing.T) {
|
|
336
|
+
yaml := strings.Replace(minimalValid, " - target: end", " - target: coding", 1)
|
|
337
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
338
|
+
if err := wf.Validate(); err == nil {
|
|
339
|
+
t.Fatal("workflow with unreachable end accepted")
|
|
340
|
+
}
|
|
341
|
+
})
|
|
342
|
+
t.Run("self loop allowed", func(t *testing.T) {
|
|
343
|
+
// minimalValid already has coding onFailure -> coding.
|
|
344
|
+
wf := parse(t, "basicFlow", minimalValid)
|
|
345
|
+
if err := wf.Validate(); err != nil {
|
|
346
|
+
t.Fatalf("self-loop rejected: %v", err)
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
func TestValidateNudgeTemplate(t *testing.T) {
|
|
352
|
+
t.Run("supported variables", func(t *testing.T) {
|
|
353
|
+
yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"{{ticket}} {{workflow}} {{repo}} {{node}} {{nextSteps}}\"", 1)
|
|
354
|
+
wf := parse(t, "basicFlow", yaml)
|
|
355
|
+
if err := wf.Validate(); err != nil {
|
|
356
|
+
t.Fatalf("supported nudge variables rejected: %v", err)
|
|
357
|
+
}
|
|
358
|
+
})
|
|
359
|
+
t.Run("unknown variable rejected", func(t *testing.T) {
|
|
360
|
+
yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"hello {{assignee}}\"", 1)
|
|
361
|
+
wf, _ := workflow.Parse("basicFlow", []byte(yaml))
|
|
362
|
+
if err := wf.Validate(); err == nil {
|
|
363
|
+
t.Fatal("nudge with unknown variable {{assignee}} accepted")
|
|
364
|
+
}
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
func TestStartTarget(t *testing.T) {
|
|
369
|
+
wf := parse(t, "basicFlow", minimalValid)
|
|
370
|
+
target, err := wf.StartTarget()
|
|
371
|
+
if err != nil {
|
|
372
|
+
t.Fatalf("StartTarget failed: %v", err)
|
|
373
|
+
}
|
|
374
|
+
if target != "coding" {
|
|
375
|
+
t.Fatalf("StartTarget = %q, want coding", target)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
func TestCleanupRunnerOnEndDefaultsFalse(t *testing.T) {
|
|
380
|
+
wf := parse(t, "basicFlow", minimalValid)
|
|
381
|
+
if wf.CleanupRunnerOnEnd {
|
|
382
|
+
t.Fatal("CleanupRunnerOnEnd defaulted to true, want false")
|
|
383
|
+
}
|
|
384
|
+
yaml := strings.Replace(minimalValid, "repos: [payments]", "repos: [payments]\ncleanupRunnerOnEnd: true", 1)
|
|
385
|
+
wf = parse(t, "basicFlow", yaml)
|
|
386
|
+
if !wf.CleanupRunnerOnEnd {
|
|
387
|
+
t.Fatal("CleanupRunnerOnEnd true not parsed")
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
func TestRenderNudge(t *testing.T) {
|
|
392
|
+
yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"ticket={{ticket}} wf={{workflow}} repo={{repo}} node={{node}} steps={{nextSteps}}\"", 1)
|
|
393
|
+
wf := parse(t, "basicFlow", yaml)
|
|
394
|
+
out, err := wf.RenderNudge("coding", workflow.NudgeTemplateData{
|
|
395
|
+
Ticket: "PAY-101", Workflow: "basicFlow", Repo: "payments", Node: "coding", NextSteps: "end",
|
|
396
|
+
})
|
|
397
|
+
if err != nil {
|
|
398
|
+
t.Fatalf("RenderNudge failed: %v", err)
|
|
399
|
+
}
|
|
400
|
+
want := "ticket=PAY-101 wf=basicFlow repo=payments node=coding steps=end"
|
|
401
|
+
if out != want {
|
|
402
|
+
t.Fatalf("RenderNudge = %q, want %q", out, want)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
wf.Nodes["coding"] = workflow.Node{Type: workflow.NodeAgent}
|
|
406
|
+
out, err = wf.RenderNudge("coding", workflow.NudgeTemplateData{})
|
|
407
|
+
if err != nil || out != "" {
|
|
408
|
+
t.Fatalf("empty RenderNudge = %q, %v; want empty", out, err)
|
|
409
|
+
}
|
|
410
|
+
}
|
package/package.json
CHANGED
package/internal/acli/acli.go
DELETED
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
// Package acli wraps the Atlassian `acli` CLI. Every call is real — no
|
|
2
|
-
// dry-run mode: acli/Jira calls always execute (only orca CLI calls
|
|
3
|
-
// support dry-run).
|
|
4
|
-
package acli
|
|
5
|
-
|
|
6
|
-
import (
|
|
7
|
-
"encoding/json"
|
|
8
|
-
"fmt"
|
|
9
|
-
"log"
|
|
10
|
-
"os/exec"
|
|
11
|
-
"strings"
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
type Client struct{}
|
|
15
|
-
|
|
16
|
-
func New() *Client {
|
|
17
|
-
return &Client{}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// rawSearchResult matches acli search's minimal JSON shape: only `key`.
|
|
21
|
-
type rawSearchResult struct {
|
|
22
|
-
Key string `json:"key"`
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// rawTicket matches acli view's JSON shape: `key` is top-level, other
|
|
26
|
-
// fields live under `fields`.
|
|
27
|
-
type rawTicket struct {
|
|
28
|
-
Key string `json:"key"`
|
|
29
|
-
Fields struct {
|
|
30
|
-
Summary string `json:"summary"`
|
|
31
|
-
Status struct {
|
|
32
|
-
Name string `json:"name"`
|
|
33
|
-
} `json:"status"`
|
|
34
|
-
Components []struct {
|
|
35
|
-
Name string `json:"name"`
|
|
36
|
-
} `json:"components"`
|
|
37
|
-
IssueType struct {
|
|
38
|
-
Name string `json:"name"`
|
|
39
|
-
} `json:"issuetype"`
|
|
40
|
-
Description json.RawMessage `json:"description"`
|
|
41
|
-
Parent struct {
|
|
42
|
-
Key string `json:"key"`
|
|
43
|
-
} `json:"parent"`
|
|
44
|
-
Labels []string `json:"labels"`
|
|
45
|
-
} `json:"fields"`
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
type Ticket struct {
|
|
49
|
-
Key string
|
|
50
|
-
Summary string
|
|
51
|
-
Description string
|
|
52
|
-
Status string
|
|
53
|
-
IssueType string
|
|
54
|
-
// Component resolves to an Orca repo by matching repo displayName.
|
|
55
|
-
Component string
|
|
56
|
-
// ParentKey is the Jira parent ticket's key (native subtask parent
|
|
57
|
-
// link), if any. Subtasks reuse their parent's worktree/base branch
|
|
58
|
-
// instead of branching from main.
|
|
59
|
-
ParentKey string
|
|
60
|
-
// Labels are the ticket's Jira labels, e.g. "baseBranch:foo" or the
|
|
61
|
-
// "orca-workflow:<name>" claim label.
|
|
62
|
-
Labels []string
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// LabelValue returns the value portion of a "prefix:value" label on the
|
|
66
|
-
// ticket, if present, e.g. LabelValue("baseBranch") -> "foo" for label
|
|
67
|
-
// "baseBranch:foo".
|
|
68
|
-
func (t Ticket) LabelValue(prefix string) (string, bool) {
|
|
69
|
-
for _, l := range t.Labels {
|
|
70
|
-
if strings.HasPrefix(l, prefix+":") {
|
|
71
|
-
return strings.TrimPrefix(l, prefix+":"), true
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return "", false
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// adfText extracts plain text from a Jira ADF (Atlassian Document Format)
|
|
78
|
-
// description by concatenating every "text" node.
|
|
79
|
-
func adfText(raw json.RawMessage) string {
|
|
80
|
-
var node struct {
|
|
81
|
-
Text string `json:"text"`
|
|
82
|
-
Content []json.RawMessage `json:"content"`
|
|
83
|
-
}
|
|
84
|
-
if err := json.Unmarshal(raw, &node); err != nil {
|
|
85
|
-
return ""
|
|
86
|
-
}
|
|
87
|
-
var parts []string
|
|
88
|
-
if node.Text != "" {
|
|
89
|
-
parts = append(parts, node.Text)
|
|
90
|
-
}
|
|
91
|
-
for _, c := range node.Content {
|
|
92
|
-
if t := adfText(c); t != "" {
|
|
93
|
-
parts = append(parts, t)
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return strings.Join(parts, " ")
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Search runs `acli jira workitem search --jql <jql> --fields key --json`
|
|
100
|
-
// to get matching ticket keys only, then fetches full details for each via
|
|
101
|
-
// `view`. In dry-run mode, no subprocess is executed; caller should treat
|
|
102
|
-
// result as empty.
|
|
103
|
-
func (c *Client) Search(jql string) ([]Ticket, error) {
|
|
104
|
-
// No --fields flag: default output always includes top-level "key",
|
|
105
|
-
// which is all we need here. Full details are fetched per-ticket via view.
|
|
106
|
-
cmd := exec.Command("acli", "jira", "workitem", "search",
|
|
107
|
-
"--jql", jql,
|
|
108
|
-
"--json")
|
|
109
|
-
var stderr strings.Builder
|
|
110
|
-
cmd.Stderr = &stderr
|
|
111
|
-
out, err := cmd.Output()
|
|
112
|
-
if err != nil {
|
|
113
|
-
return nil, fmt.Errorf("acli search: %w: %s", err, strings.TrimSpace(stderr.String()))
|
|
114
|
-
}
|
|
115
|
-
var raw []rawSearchResult
|
|
116
|
-
if err := json.Unmarshal(out, &raw); err != nil {
|
|
117
|
-
return nil, fmt.Errorf("acli search: parse json: %w", err)
|
|
118
|
-
}
|
|
119
|
-
tickets := make([]Ticket, 0, len(raw))
|
|
120
|
-
for _, r := range raw {
|
|
121
|
-
t, err := c.View(r.Key)
|
|
122
|
-
if err != nil {
|
|
123
|
-
log.Printf("acli search: could not fetch details for %s: %v", r.Key, err)
|
|
124
|
-
continue
|
|
125
|
-
}
|
|
126
|
-
tickets = append(tickets, t)
|
|
127
|
-
}
|
|
128
|
-
return tickets, nil
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ValidateAssignee checks that the given user (display name or accountId)
|
|
132
|
-
// exists by running `assignee = "<user>"` as a JQL fragment — Jira's JQL
|
|
133
|
-
// parser rejects unknown users with a hard error, like unknown statuses.
|
|
134
|
-
func (c *Client) ValidateAssignee(assignee string) error {
|
|
135
|
-
jql := fmt.Sprintf(`assignee = %q`, assignee)
|
|
136
|
-
out, err := exec.Command("acli", "jira", "workitem", "search",
|
|
137
|
-
"--jql", jql, "--json").CombinedOutput()
|
|
138
|
-
if err != nil {
|
|
139
|
-
return fmt.Errorf("assignee %q is not a valid Jira user: %s", assignee, strings.TrimSpace(string(out)))
|
|
140
|
-
}
|
|
141
|
-
return nil
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ValidateStatus checks that status is a real Jira status in the given
|
|
145
|
-
// project by running `status = "<status>"` as a JQL fragment — Jira's JQL
|
|
146
|
-
// parser rejects unknown status values with a hard error, so a typo in
|
|
147
|
-
// the workflow YAML ("DO Done") fails here instead of silently never
|
|
148
|
-
// matching at runtime. Returns nil if valid, error otherwise.
|
|
149
|
-
func (c *Client) ValidateStatus(projectKey, status string) error {
|
|
150
|
-
jql := fmt.Sprintf(`project = %s AND status = %q`, projectKey, status)
|
|
151
|
-
out, err := exec.Command("acli", "jira", "workitem", "search",
|
|
152
|
-
"--jql", jql, "--json").CombinedOutput()
|
|
153
|
-
if err != nil {
|
|
154
|
-
return fmt.Errorf("status %q is not valid in project %s: %s", status, projectKey, strings.TrimSpace(string(out)))
|
|
155
|
-
}
|
|
156
|
-
return nil
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// view fetches full ticket details via
|
|
160
|
-
// `acli jira workitem view <key> --fields status,assignee,reporter,components --json`.
|
|
161
|
-
func (c *Client) View(key string) (Ticket, error) {
|
|
162
|
-
out, err := exec.Command("acli", "jira", "workitem", "view", key,
|
|
163
|
-
"--fields", "summary,description,status,components,issuetype,parent,labels",
|
|
164
|
-
"--json").Output()
|
|
165
|
-
if err != nil {
|
|
166
|
-
return Ticket{}, fmt.Errorf("acli view %s: %w", key, err)
|
|
167
|
-
}
|
|
168
|
-
var r rawTicket
|
|
169
|
-
if err := json.Unmarshal(out, &r); err != nil {
|
|
170
|
-
return Ticket{}, fmt.Errorf("acli view %s: parse json: %w", key, err)
|
|
171
|
-
}
|
|
172
|
-
var t Ticket
|
|
173
|
-
t.Key = r.Key
|
|
174
|
-
t.Summary = r.Fields.Summary
|
|
175
|
-
t.Description = adfText(r.Fields.Description)
|
|
176
|
-
t.Status = r.Fields.Status.Name
|
|
177
|
-
t.IssueType = r.Fields.IssueType.Name
|
|
178
|
-
if len(r.Fields.Components) > 0 {
|
|
179
|
-
t.Component = r.Fields.Components[0].Name
|
|
180
|
-
}
|
|
181
|
-
t.ParentKey = r.Fields.Parent.Key
|
|
182
|
-
t.Labels = r.Fields.Labels
|
|
183
|
-
return t, nil
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// AddLabel adds label to the ticket's existing labels (acli's --labels
|
|
187
|
-
// flag replaces the set, so the caller/View result must be merged in).
|
|
188
|
-
func (c *Client) AddLabel(key string, existing []string, label string) error {
|
|
189
|
-
for _, l := range existing {
|
|
190
|
-
if l == label {
|
|
191
|
-
return nil // already present
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
all := append(append([]string{}, existing...), label)
|
|
195
|
-
return runAcli("jira", "workitem", "edit", "--key", key, "--labels", strings.Join(all, ","), "--yes", "--json")
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
func (c *Client) Transition(key, status string) error {
|
|
199
|
-
return runAcli("jira", "workitem", "transition", "--key", key, "--status", status, "--yes", "--json")
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
func (c *Client) Comment(key, body string) error {
|
|
203
|
-
return runAcli("jira", "workitem", "comment", "create", "--key", key, "--body", body, "--json")
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
func runAcli(args ...string) error {
|
|
207
|
-
out, err := exec.Command("acli", args...).CombinedOutput()
|
|
208
|
-
if err != nil {
|
|
209
|
-
return fmt.Errorf("acli %v: %w: %s", args, err, string(out))
|
|
210
|
-
}
|
|
211
|
-
// acli exits 0 even when the operation fails server-side — the JSON
|
|
212
|
-
// envelope carries per-item results with status FAILURE.
|
|
213
|
-
var env struct {
|
|
214
|
-
Results []struct {
|
|
215
|
-
Status string `json:"status"`
|
|
216
|
-
Message string `json:"message"`
|
|
217
|
-
ID string `json:"id"`
|
|
218
|
-
} `json:"results"`
|
|
219
|
-
SuccessCount int `json:"successCount"`
|
|
220
|
-
}
|
|
221
|
-
if jsonErr := json.Unmarshal(out, &env); jsonErr == nil && env.Results != nil {
|
|
222
|
-
for _, r := range env.Results {
|
|
223
|
-
if !strings.EqualFold(r.Status, "SUCCESS") {
|
|
224
|
-
return fmt.Errorf("acli %v: %s: %s", args, r.ID, r.Message)
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
return nil
|
|
229
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
package config
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"os"
|
|
5
|
-
"path/filepath"
|
|
6
|
-
"testing"
|
|
7
|
-
)
|
|
8
|
-
|
|
9
|
-
func TestDemoWorkflowYAML(t *testing.T) {
|
|
10
|
-
b, err := os.ReadFile(filepath.Join("..", "..", "..", ".workflow", "workflow.yaml"))
|
|
11
|
-
if err != nil {
|
|
12
|
-
t.Skip("demo yaml not present")
|
|
13
|
-
}
|
|
14
|
-
if _, err := Parse("demo", b); err != nil {
|
|
15
|
-
t.Fatalf("demo .workflow/workflow.yaml must validate: %v", err)
|
|
16
|
-
}
|
|
17
|
-
}
|