relay-flow 0.2.8-alpha → 0.2.10-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 +76 -35
- package/cmd/relay-flow/commands_test.go +117 -8
- package/cmd/relay-flow/main.go +391 -83
- package/cmd/relay-flow/observability_test.go +204 -0
- package/cmd/relay-flow/onboarding.go +344 -0
- package/cmd/relay-flow/onboarding_test.go +515 -0
- package/cmd/relay-flow/render.go +629 -0
- package/cmd/relay-flow/repo_registration.go +219 -0
- package/cmd/relay-flow/serve.go +35 -1
- package/go.mod +12 -8
- package/go.sum +24 -17
- package/internal/execution/goworkflows/activities.go +9 -0
- package/internal/execution/goworkflows/engine.go +15 -0
- package/internal/execution/goworkflows/engine_test.go +3 -3
- package/internal/execution/goworkflows/interpreter.go +86 -3
- package/internal/execution/goworkflows/projection.go +16 -0
- package/internal/execution/projection/detail_test.go +259 -0
- package/internal/execution/projection/projection.go +290 -6
- package/internal/execution/projection/projection_test.go +3 -2
- package/internal/execution/temporal/activities.go +9 -0
- package/internal/execution/temporal/engine.go +13 -0
- package/internal/execution/temporal/interpreter.go +131 -3
- package/internal/execution/temporal/operations.go +174 -0
- package/internal/execution/temporal/operations_test.go +36 -0
- package/internal/harness/opencode/opencode_test.go +4 -4
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/prompt_test.go +3 -3
- package/internal/repo/service.go +34 -3
- package/internal/repo/service_test.go +22 -0
- package/internal/run/detail.go +277 -0
- package/internal/run/detail_test.go +71 -0
- package/internal/run/run.go +4 -0
- package/internal/runner/herdr/herdr.go +103 -4
- package/internal/runner/herdr/herdr_test.go +72 -2
- package/internal/runner/herdr/herdrcli/contract.go +12 -3
- package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
- package/internal/runner/herdr/herdrcli/operations.go +16 -0
- package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
- package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
- package/internal/runner/orca/orca.go +68 -9
- package/internal/runner/orca/orca_test.go +134 -2
- package/internal/runner/orca/orcacli/orcacli.go +21 -1
- package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
- package/internal/runner/runner.go +9 -0
- package/internal/server/api_test.go +19 -0
- package/internal/server/client.go +49 -0
- package/internal/server/fixture_test.go +7 -0
- package/internal/server/observability.go +109 -0
- package/internal/server/observability_test.go +60 -0
- package/internal/server/server.go +77 -0
- package/internal/task/jira/testdata/jira_search_issues.json +4 -4
- package/internal/workflow/workflow.go +4 -1
- package/internal/workflow/workflow_test.go +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"sort"
|
|
6
|
+
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// WorkflowSummary is a read-only consumer DTO for compact workflow listing.
|
|
12
|
+
// The embedded definition keeps the existing JSON fields available while the
|
|
13
|
+
// additive fields provide the one-request execution summary used by the CLI.
|
|
14
|
+
type WorkflowSummary struct {
|
|
15
|
+
*workflow.Workflow
|
|
16
|
+
Repositories []string `json:"repositories,omitempty"`
|
|
17
|
+
NodeCount int `json:"nodeCount"`
|
|
18
|
+
ActiveRuns int `json:"activeRuns"`
|
|
19
|
+
LatestRun *run.Run `json:"latestRun,omitempty"`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// WorkflowDetail is a read-only query DTO. It deliberately contains domain
|
|
23
|
+
// values only; no executor, database, runner, or task-system implementation
|
|
24
|
+
// type crosses the Unix-socket boundary.
|
|
25
|
+
type WorkflowDetail struct {
|
|
26
|
+
*workflow.Workflow
|
|
27
|
+
Valid bool `json:"valid"`
|
|
28
|
+
ActiveRuns int `json:"activeRuns"`
|
|
29
|
+
RecentRuns []run.Run `json:"recentRuns"`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// WorkflowSummaryQueries is an optional server capability. Keeping it
|
|
33
|
+
// separate from Deps preserves the small existing service contract for test
|
|
34
|
+
// fakes and older composition roots.
|
|
35
|
+
type WorkflowSummaryQueries interface {
|
|
36
|
+
ListWorkflowSummaries(context.Context) ([]WorkflowSummary, error)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type WorkflowDetailQueries interface {
|
|
40
|
+
GetWorkflowDetail(context.Context, string) (WorkflowDetail, error)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type RunDetailQueries interface {
|
|
44
|
+
GetRunDetail(context.Context, string) (run.RunDetail, error)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// BuildWorkflowSummaries computes all summaries from one workflow list and one
|
|
48
|
+
// run list. The helper is used by the composition root and is intentionally
|
|
49
|
+
// independent of a durable executor implementation.
|
|
50
|
+
func BuildWorkflowSummaries(workflows []*workflow.Workflow, runs []run.Run) []WorkflowSummary {
|
|
51
|
+
latest := map[string]run.Run{}
|
|
52
|
+
active := map[string]int{}
|
|
53
|
+
for _, candidate := range runs {
|
|
54
|
+
if current, ok := latest[candidate.Workflow]; !ok || newerRun(candidate, current) {
|
|
55
|
+
latest[candidate.Workflow] = candidate
|
|
56
|
+
}
|
|
57
|
+
if candidate.State != run.StateCompleted && candidate.State != run.StateCanceled {
|
|
58
|
+
active[candidate.Workflow]++
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
out := make([]WorkflowSummary, 0, len(workflows))
|
|
62
|
+
for _, wf := range workflows {
|
|
63
|
+
if wf == nil {
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
repos := append([]string(nil), wf.Repos...)
|
|
67
|
+
sort.Strings(repos)
|
|
68
|
+
var latestRun *run.Run
|
|
69
|
+
if candidate, ok := latest[wf.Name]; ok {
|
|
70
|
+
copy := candidate
|
|
71
|
+
latestRun = ©
|
|
72
|
+
}
|
|
73
|
+
out = append(out, WorkflowSummary{
|
|
74
|
+
Workflow: wf, Repositories: repos, NodeCount: len(wf.Nodes),
|
|
75
|
+
ActiveRuns: active[wf.Name], LatestRun: latestRun,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func newerRun(candidate, current run.Run) bool {
|
|
83
|
+
if candidate.StartedAt.After(current.StartedAt) {
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
return candidate.StartedAt.Equal(current.StartedAt) && candidate.UpdatedAt.After(current.UpdatedAt)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// BuildWorkflowDetail builds the static-definition plus recent execution
|
|
90
|
+
// summary response without changing workflow validation or execution.
|
|
91
|
+
func BuildWorkflowDetail(wf *workflow.Workflow, runs []run.Run) WorkflowDetail {
|
|
92
|
+
recent := append([]run.Run(nil), runs...)
|
|
93
|
+
sort.SliceStable(recent, func(i, j int) bool {
|
|
94
|
+
if recent[i].UpdatedAt.Equal(recent[j].UpdatedAt) {
|
|
95
|
+
return recent[i].StartedAt.After(recent[j].StartedAt)
|
|
96
|
+
}
|
|
97
|
+
return recent[i].UpdatedAt.After(recent[j].UpdatedAt)
|
|
98
|
+
})
|
|
99
|
+
if len(recent) > 5 {
|
|
100
|
+
recent = recent[:5]
|
|
101
|
+
}
|
|
102
|
+
active := 0
|
|
103
|
+
for _, candidate := range runs {
|
|
104
|
+
if candidate.State != run.StateCompleted && candidate.State != run.StateCanceled {
|
|
105
|
+
active++
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return WorkflowDetail{Workflow: wf, Valid: wf != nil, ActiveRuns: active, RecentRuns: recent}
|
|
109
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"testing"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestWorkflowSummaryKeepsDefinitionFieldsOnJSONRoundTrip(t *testing.T) {
|
|
14
|
+
definition := &workflow.Workflow{Name: "basicFlow", Repos: []string{"payments"}, Nodes: map[string]workflow.Node{"start": {}, "end": {}}}
|
|
15
|
+
summary := WorkflowSummary{Workflow: definition, Repositories: []string{"payments"}, NodeCount: 2, ActiveRuns: 1}
|
|
16
|
+
raw, err := json.Marshal(summary)
|
|
17
|
+
if err != nil {
|
|
18
|
+
t.Fatal(err)
|
|
19
|
+
}
|
|
20
|
+
var decoded WorkflowSummary
|
|
21
|
+
if err := json.Unmarshal(raw, &decoded); err != nil {
|
|
22
|
+
t.Fatal(err)
|
|
23
|
+
}
|
|
24
|
+
if decoded.Workflow == nil || decoded.Name != definition.Name || decoded.NodeCount != 2 || decoded.ActiveRuns != 1 {
|
|
25
|
+
t.Fatalf("decoded summary = %#v", decoded)
|
|
26
|
+
}
|
|
27
|
+
arrayRaw, err := json.Marshal([]WorkflowSummary{summary})
|
|
28
|
+
if err != nil {
|
|
29
|
+
t.Fatal(err)
|
|
30
|
+
}
|
|
31
|
+
var legacy []*workflow.Workflow
|
|
32
|
+
if err := json.Unmarshal(arrayRaw, &legacy); err != nil || len(legacy) != 1 || legacy[0].Name != definition.Name {
|
|
33
|
+
t.Fatalf("legacy workflow decode = %#v, err=%v", legacy, err)
|
|
34
|
+
}
|
|
35
|
+
baseRaw, err := json.Marshal([]*workflow.Workflow{definition})
|
|
36
|
+
if err != nil {
|
|
37
|
+
t.Fatal(err)
|
|
38
|
+
}
|
|
39
|
+
var fallback []WorkflowSummary
|
|
40
|
+
if err := json.Unmarshal(baseRaw, &fallback); err != nil || len(fallback) != 1 || fallback[0].Workflow == nil || fallback[0].Name != definition.Name {
|
|
41
|
+
t.Fatalf("fallback summary decode = %#v, err=%v", fallback, err)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func TestBuildWorkflowSummariesUsesLatestRunAndActiveCount(t *testing.T) {
|
|
46
|
+
start := time.Now().UTC().Add(-time.Hour)
|
|
47
|
+
workflows := []*workflow.Workflow{
|
|
48
|
+
{Name: "basicFlow", Repos: []string{"payments"}, Nodes: map[string]workflow.Node{
|
|
49
|
+
"start": {}, "end": {},
|
|
50
|
+
}},
|
|
51
|
+
}
|
|
52
|
+
runs := []run.Run{
|
|
53
|
+
{Workflow: "basicFlow", State: run.StateCompleted, StartedAt: start, UpdatedAt: start.Add(10 * time.Minute), Ticket: task.TicketRef{Key: "PAY-1"}},
|
|
54
|
+
{Workflow: "basicFlow", State: run.StateWaiting, StartedAt: start.Add(time.Minute), UpdatedAt: start.Add(20 * time.Minute), Ticket: task.TicketRef{Key: "PAY-2"}},
|
|
55
|
+
}
|
|
56
|
+
got := BuildWorkflowSummaries(workflows, runs)
|
|
57
|
+
if len(got) != 1 || got[0].ActiveRuns != 1 || got[0].LatestRun == nil || got[0].LatestRun.Ticket.Key != "PAY-2" {
|
|
58
|
+
t.Fatalf("summaries = %#v", got)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -85,6 +85,7 @@ func New(deps Deps) http.Handler {
|
|
|
85
85
|
mux.HandleFunc("/workflows", s.handleWorkflows)
|
|
86
86
|
mux.HandleFunc("/workflows/", s.handleWorkflowByName)
|
|
87
87
|
mux.HandleFunc("/repos/discover", s.handleReposDiscover)
|
|
88
|
+
mux.HandleFunc("/repos/ensure", s.handleRepoEnsure)
|
|
88
89
|
mux.HandleFunc("/repos/task-fields", s.handleRepoTaskFields)
|
|
89
90
|
mux.HandleFunc("/repos", s.handleRepos)
|
|
90
91
|
mux.HandleFunc("/repos/", s.handleRepoByName)
|
|
@@ -99,6 +100,14 @@ type server struct {
|
|
|
99
100
|
deps Deps
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
// repoEnsurer is implemented by the composition root when the selected
|
|
104
|
+
// runner can provision a repository resource. It is intentionally separate
|
|
105
|
+
// from Deps so existing task/run service seams do not need a runner-specific
|
|
106
|
+
// method.
|
|
107
|
+
type repoEnsurer interface {
|
|
108
|
+
EnsureRepo(context.Context, runner.RepoCandidate) error
|
|
109
|
+
}
|
|
110
|
+
|
|
102
111
|
// --- envelope helpers ---
|
|
103
112
|
|
|
104
113
|
func writeOK(w http.ResponseWriter, status int, data any) {
|
|
@@ -122,6 +131,8 @@ func mapErr(w http.ResponseWriter, err error) {
|
|
|
122
131
|
switch {
|
|
123
132
|
case errors.Is(err, run.ErrRestartConflict):
|
|
124
133
|
writeErr(w, http.StatusConflict, "conflict", err.Error())
|
|
134
|
+
case errors.Is(err, run.ErrNotFound):
|
|
135
|
+
writeErr(w, http.StatusNotFound, "notFound", err.Error())
|
|
125
136
|
case errors.Is(err, ErrNotFound):
|
|
126
137
|
writeErr(w, http.StatusNotFound, "notFound", err.Error())
|
|
127
138
|
case errors.Is(err, ErrConflict):
|
|
@@ -182,6 +193,18 @@ func (s *server) handleStop(w http.ResponseWriter, r *http.Request) {
|
|
|
182
193
|
func (s *server) handleWorkflows(w http.ResponseWriter, r *http.Request) {
|
|
183
194
|
switch r.Method {
|
|
184
195
|
case http.MethodGet:
|
|
196
|
+
// The summary DTO embeds the original definition, so returning it is
|
|
197
|
+
// additive for clients that still decode []workflow.Workflow. Older
|
|
198
|
+
// composition roots without this optional capability use the base list.
|
|
199
|
+
if queries, ok := s.deps.(WorkflowSummaryQueries); ok {
|
|
200
|
+
summaries, err := queries.ListWorkflowSummaries(r.Context())
|
|
201
|
+
if err != nil {
|
|
202
|
+
mapErr(w, err)
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
writeOK(w, http.StatusOK, summaries)
|
|
206
|
+
return
|
|
207
|
+
}
|
|
185
208
|
wfs, err := s.deps.ListWorkflows(r.Context())
|
|
186
209
|
if err != nil {
|
|
187
210
|
mapErr(w, err)
|
|
@@ -216,6 +239,15 @@ func (s *server) handleWorkflowByName(w http.ResponseWriter, r *http.Request) {
|
|
|
216
239
|
}
|
|
217
240
|
switch r.Method {
|
|
218
241
|
case http.MethodGet:
|
|
242
|
+
if queries, ok := s.deps.(WorkflowDetailQueries); ok {
|
|
243
|
+
detail, err := queries.GetWorkflowDetail(r.Context(), name)
|
|
244
|
+
if err != nil {
|
|
245
|
+
mapErr(w, err)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
writeOK(w, http.StatusOK, detail)
|
|
249
|
+
return
|
|
250
|
+
}
|
|
219
251
|
wf, err := s.deps.GetWorkflow(r.Context(), name)
|
|
220
252
|
if err != nil {
|
|
221
253
|
mapErr(w, err)
|
|
@@ -247,6 +279,38 @@ func (s *server) handleReposDiscover(w http.ResponseWriter, r *http.Request) {
|
|
|
247
279
|
writeOK(w, http.StatusOK, candidates)
|
|
248
280
|
}
|
|
249
281
|
|
|
282
|
+
func (s *server) handleRepoEnsure(w http.ResponseWriter, r *http.Request) {
|
|
283
|
+
if !methodOnly(w, r, http.MethodPost) {
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
ensurer, ok := s.deps.(repoEnsurer)
|
|
287
|
+
if !ok {
|
|
288
|
+
mapErr(w, fmt.Errorf("%w: runner repository provisioning is unavailable", ErrInvalid))
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
body, err := readBody(r)
|
|
292
|
+
if err != nil {
|
|
293
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
var candidate runner.RepoCandidate
|
|
297
|
+
if err := decodeStrict(body, &candidate); err != nil {
|
|
298
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
candidate.Name = strings.TrimSpace(candidate.Name)
|
|
302
|
+
candidate.Path = strings.TrimSpace(candidate.Path)
|
|
303
|
+
if candidate.Name == "" || candidate.Path == "" {
|
|
304
|
+
writeErr(w, http.StatusBadRequest, "invalid", "repository name and path are required")
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
if err := ensurer.EnsureRepo(r.Context(), candidate); err != nil {
|
|
308
|
+
mapErr(w, err)
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
writeOK(w, http.StatusOK, candidate)
|
|
312
|
+
}
|
|
313
|
+
|
|
250
314
|
func (s *server) handleRepoTaskFields(w http.ResponseWriter, r *http.Request) {
|
|
251
315
|
if r.Method == http.MethodGet {
|
|
252
316
|
fields, err := s.deps.TaskRegistrationFields(r.Context(), nil)
|
|
@@ -503,6 +567,10 @@ func (s *server) handleRuns(w http.ResponseWriter, r *http.Request) {
|
|
|
503
567
|
filter.Repo = q.Get("repo")
|
|
504
568
|
filter.Workflow = q.Get("workflow")
|
|
505
569
|
filter.Ticket = q.Get("ticket")
|
|
570
|
+
if active := q.Get("active"); active != "" {
|
|
571
|
+
value := active == "1" || strings.EqualFold(active, "true")
|
|
572
|
+
filter.Active = &value
|
|
573
|
+
}
|
|
506
574
|
runs, err := s.deps.ListRuns(r.Context(), filter)
|
|
507
575
|
if err != nil {
|
|
508
576
|
mapErr(w, err)
|
|
@@ -521,6 +589,15 @@ func (s *server) handleRunByTicket(w http.ResponseWriter, r *http.Request) {
|
|
|
521
589
|
if !methodOnly(w, r, http.MethodGet) {
|
|
522
590
|
return
|
|
523
591
|
}
|
|
592
|
+
if queries, ok := s.deps.(RunDetailQueries); ok {
|
|
593
|
+
detail, err := queries.GetRunDetail(r.Context(), parts[0])
|
|
594
|
+
if err != nil {
|
|
595
|
+
mapErr(w, err)
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
writeOK(w, http.StatusOK, detail)
|
|
599
|
+
return
|
|
600
|
+
}
|
|
524
601
|
rn, err := s.deps.GetRunByTicket(r.Context(), parts[0])
|
|
525
602
|
if err != nil {
|
|
526
603
|
mapErr(w, err)
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"48x48": "https://secure.gravatar.com/avatar/09110b9326675e772c94aa61fc89195b?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FRP-6.png"
|
|
16
16
|
},
|
|
17
17
|
"displayName": "Raj Popat",
|
|
18
|
-
"emailAddress": "
|
|
18
|
+
"emailAddress": "test.user@example.com",
|
|
19
19
|
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/user?accountId=712020%3Adad5df85-a7bd-4337-b858-0faf9012a009",
|
|
20
20
|
"timeZone": "America/New_York"
|
|
21
21
|
},
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"fieldsToInclude": null,
|
|
50
50
|
"id": "157320",
|
|
51
|
-
"key": "
|
|
51
|
+
"key": "DEMO-41242",
|
|
52
52
|
"names": null,
|
|
53
53
|
"operations": null,
|
|
54
54
|
"properties": null,
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"48x48": "https://secure.gravatar.com/avatar/09110b9326675e772c94aa61fc89195b?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FRP-6.png"
|
|
75
75
|
},
|
|
76
76
|
"displayName": "Raj Popat",
|
|
77
|
-
"emailAddress": "
|
|
77
|
+
"emailAddress": "test.user@example.com",
|
|
78
78
|
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/user?accountId=712020%3Adad5df85-a7bd-4337-b858-0faf9012a009",
|
|
79
79
|
"timeZone": "America/New_York"
|
|
80
80
|
},
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
},
|
|
108
108
|
"fieldsToInclude": null,
|
|
109
109
|
"id": "155110",
|
|
110
|
-
"key": "
|
|
110
|
+
"key": "DEMO-41175",
|
|
111
111
|
"names": null,
|
|
112
112
|
"operations": null,
|
|
113
113
|
"properties": null,
|
|
@@ -62,6 +62,7 @@ type NudgeTemplateData struct {
|
|
|
62
62
|
Workflow string
|
|
63
63
|
Repo string
|
|
64
64
|
Node string
|
|
65
|
+
Mailbox string
|
|
65
66
|
NextSteps string
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -70,7 +71,7 @@ var namePattern = regexp.MustCompile(`^[a-z][a-zA-Z0-9]*$`)
|
|
|
70
71
|
var nudgeVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
|
|
71
72
|
|
|
72
73
|
var knownNudgeVars = map[string]bool{
|
|
73
|
-
"taskSystem": true, "ticket": true, "workflow": true, "repo": true, "node": true, "nextSteps": true,
|
|
74
|
+
"taskSystem": true, "ticket": true, "workflow": true, "repo": true, "node": true, "mailbox": true, "nextSteps": true,
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
// Parse strictly decodes a workflow YAML document. Unknown fields and
|
|
@@ -326,6 +327,8 @@ func (w *Workflow) RenderNudge(node string, data NudgeTemplateData) (string, err
|
|
|
326
327
|
return data.Repo
|
|
327
328
|
case "node":
|
|
328
329
|
return data.Node
|
|
330
|
+
case "mailbox":
|
|
331
|
+
return data.Mailbox
|
|
329
332
|
case "nextSteps":
|
|
330
333
|
return data.NextSteps
|
|
331
334
|
}
|
|
@@ -350,7 +350,7 @@ func TestValidateGraphReachability(t *testing.T) {
|
|
|
350
350
|
|
|
351
351
|
func TestValidateNudgeTemplate(t *testing.T) {
|
|
352
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: \"{{taskSystem}} {{ticket}} {{workflow}} {{repo}} {{node}} {{nextSteps}}\"", 1)
|
|
353
|
+
yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"{{taskSystem}} {{ticket}} {{workflow}} {{repo}} {{node}} {{mailbox}} {{nextSteps}}\"", 1)
|
|
354
354
|
wf := parse(t, "basicFlow", yaml)
|
|
355
355
|
if err := wf.Validate(); err != nil {
|
|
356
356
|
t.Fatalf("supported nudge variables rejected: %v", err)
|
|
@@ -389,16 +389,16 @@ func TestCleanupRunnerOnEndDefaultsFalse(t *testing.T) {
|
|
|
389
389
|
}
|
|
390
390
|
|
|
391
391
|
func TestRenderNudge(t *testing.T) {
|
|
392
|
-
yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"task={{taskSystem}} ticket={{ticket}} wf={{workflow}} repo={{repo}} node={{node}} steps={{nextSteps}}\"", 1)
|
|
392
|
+
yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"task={{taskSystem}} ticket={{ticket}} wf={{workflow}} repo={{repo}} node={{node}} mailbox={{mailbox}} steps={{nextSteps}}\"", 1)
|
|
393
393
|
wf := parse(t, "basicFlow", yaml)
|
|
394
394
|
for _, taskSystem := range []string{"jira", "linear"} {
|
|
395
395
|
out, err := wf.RenderNudge("coding", workflow.NudgeTemplateData{
|
|
396
|
-
TaskSystem: taskSystem, Ticket: "PAY-101", Workflow: "basicFlow", Repo: "payments", Node: "coding", NextSteps: "end",
|
|
396
|
+
TaskSystem: taskSystem, Ticket: "PAY-101", Workflow: "basicFlow", Repo: "payments", Node: "coding", Mailbox: "PAY-101.1", NextSteps: "end",
|
|
397
397
|
})
|
|
398
398
|
if err != nil {
|
|
399
399
|
t.Fatalf("RenderNudge(%s) failed: %v", taskSystem, err)
|
|
400
400
|
}
|
|
401
|
-
want := "task=" + taskSystem + " ticket=PAY-101 wf=basicFlow repo=payments node=coding steps=end"
|
|
401
|
+
want := "task=" + taskSystem + " ticket=PAY-101 wf=basicFlow repo=payments node=coding mailbox=PAY-101.1 steps=end"
|
|
402
402
|
if out != want {
|
|
403
403
|
t.Fatalf("RenderNudge(%s) = %q, want %q", taskSystem, out, want)
|
|
404
404
|
}
|