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,120 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"changelog": null,
|
|
4
|
+
"editmeta": null,
|
|
5
|
+
"expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations",
|
|
6
|
+
"fields": {
|
|
7
|
+
"assignee": {
|
|
8
|
+
"accountId": "712020:dad5df85-a7bd-4337-b858-0faf9012a009",
|
|
9
|
+
"accountType": "atlassian",
|
|
10
|
+
"active": true,
|
|
11
|
+
"avatarUrls": {
|
|
12
|
+
"16x16": "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",
|
|
13
|
+
"24x24": "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",
|
|
14
|
+
"32x32": "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",
|
|
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
|
+
},
|
|
17
|
+
"displayName": "Raj Popat",
|
|
18
|
+
"emailAddress": "raj.popat@wolterskluwer.com",
|
|
19
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/user?accountId=712020%3Adad5df85-a7bd-4337-b858-0faf9012a009",
|
|
20
|
+
"timeZone": "America/New_York"
|
|
21
|
+
},
|
|
22
|
+
"issuetype": {
|
|
23
|
+
"avatarId": 10318,
|
|
24
|
+
"description": "A small, distinct piece of work.",
|
|
25
|
+
"hierarchyLevel": 0,
|
|
26
|
+
"iconUrl": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium",
|
|
27
|
+
"id": "10017",
|
|
28
|
+
"name": "Task",
|
|
29
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/issuetype/10017",
|
|
30
|
+
"subtask": false
|
|
31
|
+
},
|
|
32
|
+
"labels": [],
|
|
33
|
+
"status": {
|
|
34
|
+
"description": "",
|
|
35
|
+
"iconUrl": "https://jira-prod-us-26-3.prod.atl-paas.net/",
|
|
36
|
+
"id": "10007",
|
|
37
|
+
"name": "To Do",
|
|
38
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/status/10007",
|
|
39
|
+
"statusCategory": {
|
|
40
|
+
"colorName": "blue-gray",
|
|
41
|
+
"id": 2,
|
|
42
|
+
"key": "new",
|
|
43
|
+
"name": "To Do",
|
|
44
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/statuscategory/2"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"summary": "e2e: hello world program in python"
|
|
48
|
+
},
|
|
49
|
+
"fieldsToInclude": null,
|
|
50
|
+
"id": "157320",
|
|
51
|
+
"key": "GHCOS-41242",
|
|
52
|
+
"names": null,
|
|
53
|
+
"operations": null,
|
|
54
|
+
"properties": null,
|
|
55
|
+
"renderedFields": null,
|
|
56
|
+
"schema": null,
|
|
57
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/issue/157320",
|
|
58
|
+
"transitions": null,
|
|
59
|
+
"versionedRepresentations": null
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"changelog": null,
|
|
63
|
+
"editmeta": null,
|
|
64
|
+
"expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations",
|
|
65
|
+
"fields": {
|
|
66
|
+
"assignee": {
|
|
67
|
+
"accountId": "712020:dad5df85-a7bd-4337-b858-0faf9012a009",
|
|
68
|
+
"accountType": "atlassian",
|
|
69
|
+
"active": true,
|
|
70
|
+
"avatarUrls": {
|
|
71
|
+
"16x16": "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",
|
|
72
|
+
"24x24": "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",
|
|
73
|
+
"32x32": "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",
|
|
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
|
+
},
|
|
76
|
+
"displayName": "Raj Popat",
|
|
77
|
+
"emailAddress": "raj.popat@wolterskluwer.com",
|
|
78
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/user?accountId=712020%3Adad5df85-a7bd-4337-b858-0faf9012a009",
|
|
79
|
+
"timeZone": "America/New_York"
|
|
80
|
+
},
|
|
81
|
+
"issuetype": {
|
|
82
|
+
"avatarId": 10303,
|
|
83
|
+
"description": "A problem or error.",
|
|
84
|
+
"hierarchyLevel": 0,
|
|
85
|
+
"iconUrl": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10303?size=medium",
|
|
86
|
+
"id": "10019",
|
|
87
|
+
"name": "Bug",
|
|
88
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/issuetype/10019",
|
|
89
|
+
"subtask": false
|
|
90
|
+
},
|
|
91
|
+
"labels": [],
|
|
92
|
+
"status": {
|
|
93
|
+
"description": "",
|
|
94
|
+
"iconUrl": "https://jira-prod-us-26-3.prod.atl-paas.net/",
|
|
95
|
+
"id": "10007",
|
|
96
|
+
"name": "To Do",
|
|
97
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/status/10007",
|
|
98
|
+
"statusCategory": {
|
|
99
|
+
"colorName": "blue-gray",
|
|
100
|
+
"id": 2,
|
|
101
|
+
"key": "new",
|
|
102
|
+
"name": "To Do",
|
|
103
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/statuscategory/2"
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
"summary": "OpsDB Sync Validation Bug"
|
|
107
|
+
},
|
|
108
|
+
"fieldsToInclude": null,
|
|
109
|
+
"id": "155110",
|
|
110
|
+
"key": "GHCOS-41175",
|
|
111
|
+
"names": null,
|
|
112
|
+
"operations": null,
|
|
113
|
+
"properties": null,
|
|
114
|
+
"renderedFields": null,
|
|
115
|
+
"schema": null,
|
|
116
|
+
"self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/issue/155110",
|
|
117
|
+
"transitions": null,
|
|
118
|
+
"versionedRepresentations": null
|
|
119
|
+
}
|
|
120
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// 3.5: Jira transition defaults per specs/workflow-definition "Jira
|
|
13
|
+
// transition defaults are deterministic": omitted transitions default to
|
|
14
|
+
// parent In Progress at start, mailbox In Progress at agent/HITL work
|
|
15
|
+
// nodes, and parent Done at end; an omitted work-node parent transition
|
|
16
|
+
// leaves the parent unchanged.
|
|
17
|
+
//
|
|
18
|
+
// This test is package-local (package jira) so it can drive the adapter's
|
|
19
|
+
// ApplyTaskConfig against a test-local fake ACLI without inventing an
|
|
20
|
+
// exported production constructor seam. The adapter resolves defaults
|
|
21
|
+
// internally; the fake records the transitions the adapter issues.
|
|
22
|
+
|
|
23
|
+
// fakeACLI is a test-local fakeable ACLI boundary recording transitions and
|
|
24
|
+
// serving a fixed search batch for Poll.
|
|
25
|
+
type fakeACLI struct {
|
|
26
|
+
parentTransitions []string
|
|
27
|
+
taskTransitions []string
|
|
28
|
+
assignments []string
|
|
29
|
+
assignErr error
|
|
30
|
+
events []string
|
|
31
|
+
// searchJSON is the raw Jira search response Poll serves.
|
|
32
|
+
searchJSON []byte
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func (f *fakeACLI) transition(key, status string) {
|
|
36
|
+
if key == "PAY-101" {
|
|
37
|
+
f.parentTransitions = append(f.parentTransitions, status)
|
|
38
|
+
} else {
|
|
39
|
+
f.taskTransitions = append(f.taskTransitions, status)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// newSystemWithFake builds the adapter around the test-local fake ACLI. The
|
|
44
|
+
// adapter owns its ACLI wiring; the test injects only the CLI seam.
|
|
45
|
+
func newSystemWithFake(t *testing.T, fake *fakeACLI) task.System {
|
|
46
|
+
t.Helper()
|
|
47
|
+
sys, err := newSystemForTest(fake)
|
|
48
|
+
if err != nil {
|
|
49
|
+
t.Fatalf("adapter construction failed: %v", err)
|
|
50
|
+
}
|
|
51
|
+
return sys
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func TestStartDefaultParentInProgress(t *testing.T) {
|
|
55
|
+
fake := &fakeACLI{}
|
|
56
|
+
sys := newSystemWithFake(t, fake)
|
|
57
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
58
|
+
|
|
59
|
+
// start: omitted transitionTo, parent-only target.
|
|
60
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, config.RawValues{}); err != nil {
|
|
61
|
+
t.Fatalf("ApplyTaskConfig start failed: %v", err)
|
|
62
|
+
}
|
|
63
|
+
if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "In Progress" {
|
|
64
|
+
t.Fatalf("start parent transitions = %v, want [In Progress]", fake.parentTransitions)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func TestWorkNodeDefaultMailboxInProgressParentUnchanged(t *testing.T) {
|
|
69
|
+
fake := &fakeACLI{}
|
|
70
|
+
sys := newSystemWithFake(t, fake)
|
|
71
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
72
|
+
mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
|
|
73
|
+
|
|
74
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, config.RawValues{}); err != nil {
|
|
75
|
+
t.Fatalf("ApplyTaskConfig failed: %v", err)
|
|
76
|
+
}
|
|
77
|
+
if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != "In Progress" {
|
|
78
|
+
t.Fatalf("mailbox transitions = %v, want [In Progress]", fake.taskTransitions)
|
|
79
|
+
}
|
|
80
|
+
if len(fake.parentTransitions) != 0 {
|
|
81
|
+
t.Fatalf("parent transitions = %v, want none (parent unchanged when omitted)", fake.parentTransitions)
|
|
82
|
+
}
|
|
83
|
+
if len(fake.assignments) != 0 {
|
|
84
|
+
t.Fatalf("mailbox assignments = %v, want none when assignee omitted", fake.assignments)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func TestWorkNodeAssignsMailboxBeforeTransition(t *testing.T) {
|
|
89
|
+
fake := &fakeACLI{}
|
|
90
|
+
sys := newSystemWithFake(t, fake)
|
|
91
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
92
|
+
mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
|
|
93
|
+
|
|
94
|
+
err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, config.RawValues{
|
|
95
|
+
"assignee": "reviewer@example.com",
|
|
96
|
+
})
|
|
97
|
+
if err != nil {
|
|
98
|
+
t.Fatalf("ApplyTaskConfig failed: %v", err)
|
|
99
|
+
}
|
|
100
|
+
if len(fake.assignments) != 1 || fake.assignments[0] != "PAY-102:reviewer@example.com" {
|
|
101
|
+
t.Fatalf("mailbox assignments = %v, want [PAY-102:reviewer@example.com]", fake.assignments)
|
|
102
|
+
}
|
|
103
|
+
if len(fake.events) != 2 || fake.events[0] != "assign" || fake.events[1] != "transition" {
|
|
104
|
+
t.Fatalf("mailbox events = %v, want [assign transition]", fake.events)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func TestWorkNodeAssignmentFailurePreventsTransition(t *testing.T) {
|
|
109
|
+
fake := &fakeACLI{assignErr: errors.New("assignment failed")}
|
|
110
|
+
sys := newSystemWithFake(t, fake)
|
|
111
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
112
|
+
mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
|
|
113
|
+
|
|
114
|
+
err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, config.RawValues{
|
|
115
|
+
"assignee": "reviewer@example.com",
|
|
116
|
+
})
|
|
117
|
+
if err == nil || len(fake.taskTransitions) != 0 {
|
|
118
|
+
t.Fatalf("ApplyTaskConfig error = %v, transitions = %v", err, fake.taskTransitions)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func TestEndDefaultParentDone(t *testing.T) {
|
|
123
|
+
fake := &fakeACLI{}
|
|
124
|
+
sys := newSystemWithFake(t, fake)
|
|
125
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
126
|
+
|
|
127
|
+
// end: omitted transitionTo. The end effective config carries no
|
|
128
|
+
// transition; the adapter must still default the parent to Done. Run
|
|
129
|
+
// orchestration applies the end node config to the parent target.
|
|
130
|
+
if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, endConfig(config.RawValues{})); err != nil {
|
|
131
|
+
t.Fatalf("ApplyTaskConfig end failed: %v", err)
|
|
132
|
+
}
|
|
133
|
+
if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "Done" {
|
|
134
|
+
t.Fatalf("end parent transitions = %v, want [Done]", fake.parentTransitions)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func TestExplicitTransitionsWin(t *testing.T) {
|
|
139
|
+
fake := &fakeACLI{}
|
|
140
|
+
sys := newSystemWithFake(t, fake)
|
|
141
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
142
|
+
mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "review"}
|
|
143
|
+
|
|
144
|
+
err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, config.RawValues{
|
|
145
|
+
"transitionTo": map[string]any{"parentStatus": "In Review", "taskStatus": "In Review"},
|
|
146
|
+
})
|
|
147
|
+
if err != nil {
|
|
148
|
+
t.Fatalf("ApplyTaskConfig failed: %v", err)
|
|
149
|
+
}
|
|
150
|
+
if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "In Review" {
|
|
151
|
+
t.Fatalf("parent transitions = %v, want [In Review]", fake.parentTransitions)
|
|
152
|
+
}
|
|
153
|
+
if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != "In Review" {
|
|
154
|
+
t.Fatalf("mailbox transitions = %v, want [In Review]", fake.taskTransitions)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
type validationClient struct {
|
|
14
|
+
fakeClient
|
|
15
|
+
assignees []string
|
|
16
|
+
statuses []string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func (c *validationClient) ValidateAssignee(_ context.Context, assignee string) error {
|
|
20
|
+
c.assignees = append(c.assignees, assignee)
|
|
21
|
+
if assignee == "missing" {
|
|
22
|
+
return errors.New("invalid assignee")
|
|
23
|
+
}
|
|
24
|
+
return nil
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func (c *validationClient) ValidateStatus(_ context.Context, project, status string) error {
|
|
28
|
+
c.statuses = append(c.statuses, project+":"+status)
|
|
29
|
+
if status == "DO Done" {
|
|
30
|
+
return errors.New("invalid status")
|
|
31
|
+
}
|
|
32
|
+
return nil
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func TestNewSystemRejectsInvalidAssignee(t *testing.T) {
|
|
36
|
+
client := &validationClient{}
|
|
37
|
+
_, err := newSystem(context.Background(), client, taskSpec(config.RawValues{"assignee": "missing"}, nil))
|
|
38
|
+
if err == nil || !strings.Contains(err.Error(), `assignee "missing"`) {
|
|
39
|
+
t.Fatalf("newSystem error = %v", err)
|
|
40
|
+
}
|
|
41
|
+
if len(client.assignees) != 1 || client.assignees[0] != "missing" {
|
|
42
|
+
t.Fatalf("validated assignees = %v", client.assignees)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func TestValidateConfigProbesMergedTransitionStatusesWithoutMutation(t *testing.T) {
|
|
47
|
+
client := &validationClient{}
|
|
48
|
+
sys, err := newSystem(context.Background(), client, taskSpec(config.RawValues{"assignee": "valid"}, nil))
|
|
49
|
+
if err != nil {
|
|
50
|
+
t.Fatal(err)
|
|
51
|
+
}
|
|
52
|
+
wf := config.RawValues{"transitionTo": map[string]any{"parentStatus": "In Review"}}
|
|
53
|
+
node := config.RawValues{"transitionTo": map[string]any{"taskStatus": "DO Done"}}
|
|
54
|
+
err = sys.ValidateConfig(context.Background(), wf, map[string]config.RawValues{"review": node})
|
|
55
|
+
if err == nil || !strings.Contains(err.Error(), `node "review" taskStatus "DO Done"`) {
|
|
56
|
+
t.Fatalf("ValidateConfig error = %v", err)
|
|
57
|
+
}
|
|
58
|
+
if got := wf["transitionTo"].(map[string]any); len(got) != 1 || got["parentStatus"] != "In Review" {
|
|
59
|
+
t.Fatalf("workflow config mutated: %v", wf)
|
|
60
|
+
}
|
|
61
|
+
if got := node["transitionTo"].(map[string]any); len(got) != 1 || got["taskStatus"] != "DO Done" {
|
|
62
|
+
t.Fatalf("node config mutated: %v", node)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func TestNewSystemRejectsInvalidRepoTransitionStatus(t *testing.T) {
|
|
67
|
+
client := &validationClient{}
|
|
68
|
+
_, err := newSystem(context.Background(), client, taskSpec(nil, config.RawValues{
|
|
69
|
+
"project": "PAY", "component": "api",
|
|
70
|
+
"transitionTo": map[string]any{"parentStatus": "DO Done"},
|
|
71
|
+
}))
|
|
72
|
+
if err == nil || !strings.Contains(err.Error(), `repo config parentStatus "DO Done"`) {
|
|
73
|
+
t.Fatalf("newSystem error = %v", err)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func TestValidateConfigProbesWorkflowAssignee(t *testing.T) {
|
|
78
|
+
client := &validationClient{}
|
|
79
|
+
sys, err := newSystem(context.Background(), client, taskSpec(nil, nil))
|
|
80
|
+
if err != nil {
|
|
81
|
+
t.Fatal(err)
|
|
82
|
+
}
|
|
83
|
+
err = sys.ValidateConfig(context.Background(), config.RawValues{"assignee": "missing"}, nil)
|
|
84
|
+
if err == nil || !strings.Contains(err.Error(), `workflow assignee "missing"`) {
|
|
85
|
+
t.Fatalf("ValidateConfig error = %v", err)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func taskSpec(root, repo config.RawValues) task.RepoSpec {
|
|
90
|
+
if repo == nil {
|
|
91
|
+
repo = config.RawValues{"project": "PAY", "component": "api"}
|
|
92
|
+
}
|
|
93
|
+
return task.RepoSpec{Name: "payments", RootConfig: root, RepoConfig: repo}
|
|
94
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Package task defines task-system values and the System contract. Run IDs,
|
|
2
|
+
// node visits, reports, and orchestration inputs belong to run, not here.
|
|
3
|
+
package task
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
type Ticket struct {
|
|
12
|
+
ID string
|
|
13
|
+
Key string
|
|
14
|
+
Title string
|
|
15
|
+
WorkflowClaims []string
|
|
16
|
+
Fields map[string]any
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type TicketRef struct {
|
|
20
|
+
ID string `json:"id"`
|
|
21
|
+
Key string `json:"key"`
|
|
22
|
+
Title string `json:"title"`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func (t Ticket) Ref() TicketRef {
|
|
26
|
+
return TicketRef{ID: t.ID, Key: t.Key, Title: t.Title}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Mailbox struct {
|
|
30
|
+
ID string `json:"id"`
|
|
31
|
+
Key string `json:"key"`
|
|
32
|
+
Node string `json:"node"`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type MailboxSpec struct {
|
|
36
|
+
Node string `json:"node"`
|
|
37
|
+
Title string `json:"title"`
|
|
38
|
+
Description string `json:"description"`
|
|
39
|
+
TaskConfig config.RawValues `json:"taskConfig,omitempty"`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Target struct {
|
|
43
|
+
Parent TicketRef
|
|
44
|
+
Mailbox *Mailbox
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// System is the task-system contract. One instance is created per
|
|
48
|
+
// registered repo and is safe for concurrent use.
|
|
49
|
+
//
|
|
50
|
+
// Poll returns active parent tickets only; mailbox subtasks are never
|
|
51
|
+
// routed as workflow runs. EnsureMailboxes is the sole mailbox discovery
|
|
52
|
+
// operation: it finds existing child mailboxes, creates only missing ones,
|
|
53
|
+
// and returns the complete node-to-mailbox map. Each method is idempotent
|
|
54
|
+
// where the task system permits; adapters own reconciliation when one
|
|
55
|
+
// method requires multiple remote calls.
|
|
56
|
+
type System interface {
|
|
57
|
+
Poll(ctx context.Context) ([]Ticket, error)
|
|
58
|
+
CompileFilter(workflowTaskConfig config.RawValues) (func(Ticket) bool, error)
|
|
59
|
+
Claim(ctx context.Context, ticket TicketRef, workflow string) error
|
|
60
|
+
|
|
61
|
+
ValidateConfig(ctx context.Context, workflowTaskConfig config.RawValues, nodeTaskConfigs map[string]config.RawValues) error
|
|
62
|
+
EnsureMailboxes(ctx context.Context, parent TicketRef, workflow string, specs []MailboxSpec) (map[string]Mailbox, error)
|
|
63
|
+
ApplyTaskConfig(ctx context.Context, target Target, taskConfig config.RawValues) error
|
|
64
|
+
CompleteMailbox(ctx context.Context, mailbox Mailbox) error
|
|
65
|
+
HasComment(ctx context.Context, target Target, marker string) (bool, error)
|
|
66
|
+
Comment(ctx context.Context, target Target, body, marker string) error
|
|
67
|
+
ResetForRecovery(ctx context.Context, parent TicketRef, mailboxes []Mailbox, taskConfig config.RawValues) error
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// LifecycleDefaults is an optional adapter capability: a System whose
|
|
71
|
+
// taskConfig carries lifecycle-dependent defaults (e.g. Jira's deterministic
|
|
72
|
+
// transitionTo defaults) exposes them here. The lifecycle-aware caller
|
|
73
|
+
// (run orchestration) merges the returned raw values into the effective node
|
|
74
|
+
// config before ApplyTaskConfig based on whether the node is start, a work
|
|
75
|
+
// node, or end. Core never learns adapter vocabulary; the adapter never
|
|
76
|
+
// learns lifecycle steps. The System interface is unchanged.
|
|
77
|
+
type LifecycleDefaults interface {
|
|
78
|
+
// StartDefaults are merged under the start node's taskConfig.
|
|
79
|
+
StartDefaults() config.RawValues
|
|
80
|
+
// WorkDefaults are merged under every work node's taskConfig.
|
|
81
|
+
WorkDefaults() config.RawValues
|
|
82
|
+
// EndDefaults are merged under the end node's taskConfig.
|
|
83
|
+
EndDefaults() config.RawValues
|
|
84
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
package workflow
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"strings"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// Structured report contract. All strings are required; the literal "None"
|
|
9
|
+
// represents an intentionally empty section.
|
|
10
|
+
const None = "None"
|
|
11
|
+
|
|
12
|
+
type Summary struct {
|
|
13
|
+
Completed string `json:"completed"`
|
|
14
|
+
Commits string `json:"commits"`
|
|
15
|
+
NotCompleted string `json:"notCompleted"`
|
|
16
|
+
IssuesDiscovered string `json:"issuesDiscovered"`
|
|
17
|
+
Verification string `json:"verification"`
|
|
18
|
+
Notes string `json:"notes"`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Feedback struct {
|
|
22
|
+
ReasonForNextStep string `json:"reasonForNextStep"`
|
|
23
|
+
RequiredActions string `json:"requiredActions"`
|
|
24
|
+
RelevantContext string `json:"relevantContext"`
|
|
25
|
+
ExpectedResult string `json:"expectedResult"`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Report struct {
|
|
29
|
+
Status Outcome `json:"status"`
|
|
30
|
+
NextStep string `json:"nextStep"`
|
|
31
|
+
Summary Summary `json:"summary"`
|
|
32
|
+
Feedback Feedback `json:"feedback"`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ValidateReport verifies every section, the outcome, and that NextStep
|
|
36
|
+
// names exactly one configured target for that outcome. When NextStep is
|
|
37
|
+
// end, every feedback field must be None. It is pure and is called both at
|
|
38
|
+
// the server boundary and inside durable execution.
|
|
39
|
+
func (w *Workflow) ValidateReport(node string, report Report) error {
|
|
40
|
+
if _, ok := w.Nodes[node]; !ok {
|
|
41
|
+
return fmt.Errorf("workflow %q has no node %q", w.Name, node)
|
|
42
|
+
}
|
|
43
|
+
if report.Status != OutcomeSuccess && report.Status != OutcomeFailure {
|
|
44
|
+
return fmt.Errorf("report status %q must be %q or %q", report.Status, OutcomeSuccess, OutcomeFailure)
|
|
45
|
+
}
|
|
46
|
+
for _, field := range []struct{ name, value string }{
|
|
47
|
+
{"summary.completed", report.Summary.Completed},
|
|
48
|
+
{"summary.commits", report.Summary.Commits},
|
|
49
|
+
{"summary.notCompleted", report.Summary.NotCompleted},
|
|
50
|
+
{"summary.issuesDiscovered", report.Summary.IssuesDiscovered},
|
|
51
|
+
{"summary.verification", report.Summary.Verification},
|
|
52
|
+
{"summary.notes", report.Summary.Notes},
|
|
53
|
+
{"feedback.reasonForNextStep", report.Feedback.ReasonForNextStep},
|
|
54
|
+
{"feedback.requiredActions", report.Feedback.RequiredActions},
|
|
55
|
+
{"feedback.relevantContext", report.Feedback.RelevantContext},
|
|
56
|
+
{"feedback.expectedResult", report.Feedback.ExpectedResult},
|
|
57
|
+
{"nextStep", report.NextStep},
|
|
58
|
+
} {
|
|
59
|
+
if field.value == "" {
|
|
60
|
+
return fmt.Errorf("report section %s is required (use %q for an intentionally empty section)", field.name, None)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
routes, err := w.Routes(node, report.Status)
|
|
64
|
+
if err != nil {
|
|
65
|
+
return err
|
|
66
|
+
}
|
|
67
|
+
legal := false
|
|
68
|
+
for _, r := range routes {
|
|
69
|
+
if r.Target == report.NextStep {
|
|
70
|
+
legal = true
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if !legal {
|
|
75
|
+
return fmt.Errorf("report nextStep %q is not a configured %s target for node %q; valid targets: %s",
|
|
76
|
+
report.NextStep, report.Status, node, strings.Join(sortedTargets(routes), ", "))
|
|
77
|
+
}
|
|
78
|
+
if report.NextStep == EndNode {
|
|
79
|
+
f := report.Feedback
|
|
80
|
+
if f.ReasonForNextStep != None || f.RequiredActions != None || f.RelevantContext != None || f.ExpectedResult != None {
|
|
81
|
+
return fmt.Errorf("report selects %q: every feedback field must be %q because end has no mailbox", EndNode, None)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return nil
|
|
85
|
+
}
|