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
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
# runner/orca — Orca adapter
|
|
2
|
-
|
|
3
|
-
Implements `runner.Runner` over the Orca CLI: each ticket gets an Orca
|
|
4
|
-
worktree; each node visit gets a fresh terminal titled
|
|
5
|
-
`<key>:<agent>:<node>` running opencode.
|
|
6
|
-
|
|
7
|
-
## Config (runner.config)
|
|
8
|
-
|
|
9
|
-
Empty — the adapter takes no YAML fields. Repo binding arrives at runtime:
|
|
10
|
-
the server resolves the submitter's repo and calls `WithRepo(repoID,
|
|
11
|
-
displayName, dryRun)` after construction.
|
|
12
|
-
|
|
13
|
-
## Behavior
|
|
14
|
-
|
|
15
|
-
- **Spawn** — verifies the opencode agent exists (`opencode agent list`),
|
|
16
|
-
ensures the ticket's worktree (creates off the repo's main worktree branch;
|
|
17
|
-
reuses an existing ticket branch if one exists; verifies the exact name
|
|
18
|
-
landed because Orca silently auto-suffixes on collisions), then creates the
|
|
19
|
-
terminal running `opencode --agent <agent> --prompt <p>` with `RELAY_FLOW_*` env
|
|
20
|
-
markers (`RELAY_FLOW_WORKFLOW/TICKET/NODE/AGENT`) so the plugin can report back.
|
|
21
|
-
- **Find** — exact-title match on the ticket's terminal list. A missing
|
|
22
|
-
worktree (`selector_not_found`) means "no session", not an error — that's
|
|
23
|
-
what lets bounce respawn after a crash.
|
|
24
|
-
- **Nudge** — waits for `tui-idle` (typed text mid-turn corrupts the input
|
|
25
|
-
box), then sends the prompt flattened to one line (keystroke simulation
|
|
26
|
-
submits on newline).
|
|
27
|
-
- **Close** — closes every terminal titled `<key>:*`; scaffolding tabs
|
|
28
|
-
("Terminal 1", "Setup") survive.
|
|
29
|
-
|
|
30
|
-
## Writing a new runner (tmux, ...)
|
|
31
|
-
|
|
32
|
-
1. Create `internal/runner/<name>/` with:
|
|
33
|
-
```go
|
|
34
|
-
func init() {
|
|
35
|
-
runner.Register("<name>", runner.Factory{
|
|
36
|
-
UnmarshalConfig: unmarshalConfig, // strict-decode runner.config (empty is fine)
|
|
37
|
-
New: func(cfg any) (runner.Runner, error) { ... },
|
|
38
|
-
})
|
|
39
|
-
}
|
|
40
|
-
```
|
|
41
|
-
2. Implement the interface:
|
|
42
|
-
```go
|
|
43
|
-
type Runner interface {
|
|
44
|
-
Spawn(t tasks.Ticket, node, agent, prompt string, env map[string]string) error
|
|
45
|
-
Find(t tasks.Ticket, node string) (runner.Session, bool, error)
|
|
46
|
-
Nudge(s runner.Session, prompt string) error
|
|
47
|
-
Close(t tasks.Ticket) error
|
|
48
|
-
}
|
|
49
|
-
```
|
|
50
|
-
Contract highlights:
|
|
51
|
-
- **Titles are identity**: sessions must be findable later by
|
|
52
|
-
`<key>:<agent>:<node>` — bounce depends on it.
|
|
53
|
-
- **env must reach the agent process** — the report plugin keys off
|
|
54
|
-
`RELAY_FLOW_WORKFLOW/TICKET/NODE/AGENT`.
|
|
55
|
-
- **Find must distinguish "gone" from "error"** — a missing session is a
|
|
56
|
-
normal bounce case (respawn), not a failure.
|
|
57
|
-
- The runner must still launch **opencode** inside whatever session it
|
|
58
|
-
creates — the report plugin is opencode-specific. (tmux example:
|
|
59
|
-
`tmux new-session -s key-agent-node` + `send-keys "RELAY_FLOW_*=... opencode --agent ..." Enter`.)
|
|
60
|
-
3. If your runner needs the repo binding, implement
|
|
61
|
-
`WithRepo(repoID, repoName string, dryRun bool)` — the server calls it via
|
|
62
|
-
interface assertion when present.
|
|
63
|
-
4. Import for side effects in `internal/server/server.go`.
|
|
64
|
-
5. Tests with a fake CLI seam (see `orca_test.go` / the `orcaCLI` interface).
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
package runner
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"strings"
|
|
5
|
-
"testing"
|
|
6
|
-
|
|
7
|
-
"github.com/rajpopat27/relay-flow/internal/tasks"
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
type fakeRunner struct {
|
|
11
|
-
spawned []string
|
|
12
|
-
nudged []string
|
|
13
|
-
closed []string
|
|
14
|
-
found map[string]Session
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
func (f *fakeRunner) Spawn(t tasks.Ticket, node, agent, prompt string, env map[string]string) error {
|
|
18
|
-
f.spawned = append(f.spawned, t.Key+":"+node+":"+agent+":"+env["RELAY_WORKFLOW"])
|
|
19
|
-
return nil
|
|
20
|
-
}
|
|
21
|
-
func (f *fakeRunner) Find(t tasks.Ticket, node string) (Session, bool, error) {
|
|
22
|
-
s, ok := f.found[t.Key+":"+node]
|
|
23
|
-
return s, ok, nil
|
|
24
|
-
}
|
|
25
|
-
func (f *fakeRunner) Nudge(s Session, prompt string) error {
|
|
26
|
-
f.nudged = append(f.nudged, s.Title+":"+prompt)
|
|
27
|
-
return nil
|
|
28
|
-
}
|
|
29
|
-
func (f *fakeRunner) Close(t tasks.Ticket) error {
|
|
30
|
-
f.closed = append(f.closed, t.Key)
|
|
31
|
-
return nil
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
func TestRegistryNew(t *testing.T) {
|
|
35
|
-
fake := &fakeRunner{}
|
|
36
|
-
Register("fakerun", Factory{
|
|
37
|
-
UnmarshalConfig: func(m map[string]any) (any, error) { return m, nil },
|
|
38
|
-
New: func(cfg any) (Runner, error) { return fake, nil },
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
r, err := New("fakerun", nil)
|
|
42
|
-
if err != nil {
|
|
43
|
-
t.Fatalf("%v", err)
|
|
44
|
-
}
|
|
45
|
-
tk := tasks.Ticket{Key: "XYZ-2"}
|
|
46
|
-
r.Spawn(tk, "coding", "build", "go", map[string]string{"RELAY_WORKFLOW": "w"})
|
|
47
|
-
if len(fake.spawned) != 1 || fake.spawned[0] != "XYZ-2:coding:build:w" {
|
|
48
|
-
t.Errorf("spawned = %v", fake.spawned)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if _, err := New("nope", nil); err == nil || !strings.Contains(err.Error(), "unknown runner type") {
|
|
52
|
-
t.Errorf("unknown runner error = %v", err)
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
func TestDuplicateRegisterPanics(t *testing.T) {
|
|
57
|
-
defer func() {
|
|
58
|
-
if recover() == nil {
|
|
59
|
-
t.Fatal("expected panic on duplicate registration")
|
|
60
|
-
}
|
|
61
|
-
}()
|
|
62
|
-
Register("duprun", Factory{})
|
|
63
|
-
Register("duprun", Factory{})
|
|
64
|
-
}
|
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
package server
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"encoding/json"
|
|
5
|
-
"fmt"
|
|
6
|
-
"net"
|
|
7
|
-
"net/http/httptest"
|
|
8
|
-
"strings"
|
|
9
|
-
"testing"
|
|
10
|
-
|
|
11
|
-
"github.com/rajpopat27/relay-flow/internal/config"
|
|
12
|
-
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
13
|
-
"github.com/rajpopat27/relay-flow/internal/tasks"
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
const goodYAML = `
|
|
17
|
-
name: testFlow
|
|
18
|
-
tasks:
|
|
19
|
-
type: faketasks
|
|
20
|
-
config: {}
|
|
21
|
-
runner:
|
|
22
|
-
type: fakerunner
|
|
23
|
-
closeOn: [done]
|
|
24
|
-
nodes:
|
|
25
|
-
coding:
|
|
26
|
-
agent: build
|
|
27
|
-
when: "In Progress"
|
|
28
|
-
onSuccess: done
|
|
29
|
-
onFailure: coding
|
|
30
|
-
done:
|
|
31
|
-
when: "Done"
|
|
32
|
-
`
|
|
33
|
-
|
|
34
|
-
// registerFakes installs test adapters (unique names per process via init
|
|
35
|
-
// in server package tests would collide across files — register once here).
|
|
36
|
-
var fakesOnce = registerFakes()
|
|
37
|
-
|
|
38
|
-
var (
|
|
39
|
-
lastFakeTasks *fakeTasks
|
|
40
|
-
lastFakeRunner *fakeRunner
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
func registerFakes() bool {
|
|
44
|
-
tasks.Register("faketasks", tasks.Factory{
|
|
45
|
-
UnmarshalConfig: func(m map[string]any) (any, error) { return m, nil },
|
|
46
|
-
New: func(cfg any, wfName string, nodes map[string]config.Node, assignee, repoName string) (tasks.Tasks, error) {
|
|
47
|
-
lastFakeTasks = &fakeTasks{}
|
|
48
|
-
return lastFakeTasks, nil
|
|
49
|
-
},
|
|
50
|
-
})
|
|
51
|
-
runner.Register("fakerunner", runner.Factory{
|
|
52
|
-
UnmarshalConfig: func(m map[string]any) (any, error) { return m, nil },
|
|
53
|
-
New: func(cfg any) (runner.Runner, error) {
|
|
54
|
-
lastFakeRunner = &fakeRunner{}
|
|
55
|
-
return lastFakeRunner, nil
|
|
56
|
-
},
|
|
57
|
-
})
|
|
58
|
-
return true
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
type fakeTasks struct{ reports []string }
|
|
62
|
-
|
|
63
|
-
func (f *fakeTasks) List() ([]tasks.Ticket, error) { return nil, nil }
|
|
64
|
-
func (f *fakeTasks) Claim(t tasks.Ticket) error { return nil }
|
|
65
|
-
func (f *fakeTasks) Report(t tasks.Ticket, outcome, targetNode, summary string) error {
|
|
66
|
-
f.reports = append(f.reports, fmt.Sprintf("%s:%s:%s:%s", t.Key, outcome, targetNode, summary))
|
|
67
|
-
return nil
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
type fakeRunner struct{}
|
|
71
|
-
|
|
72
|
-
func (f *fakeRunner) Spawn(t tasks.Ticket, node, agent, prompt string, env map[string]string) error {
|
|
73
|
-
return nil
|
|
74
|
-
}
|
|
75
|
-
func (f *fakeRunner) Find(t tasks.Ticket, node string) (runner.Session, bool, error) {
|
|
76
|
-
return runner.Session{}, false, nil
|
|
77
|
-
}
|
|
78
|
-
func (f *fakeRunner) Nudge(s runner.Session, prompt string) error { return nil }
|
|
79
|
-
func (f *fakeRunner) Close(t tasks.Ticket) error { return nil }
|
|
80
|
-
|
|
81
|
-
func testServer(t *testing.T) *Server {
|
|
82
|
-
t.Helper()
|
|
83
|
-
_ = fakesOnce
|
|
84
|
-
s := New(true, Deps{
|
|
85
|
-
ResolveRepo: func(path string) (string, string, error) { return "repo-1", "repo:xyz", nil },
|
|
86
|
-
ValidateConfig: func(y []byte) ([]string, error) { return nil, nil },
|
|
87
|
-
})
|
|
88
|
-
return s
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
func post(t *testing.T, s *Server, path string, body string) (int, map[string]any) {
|
|
92
|
-
t.Helper()
|
|
93
|
-
req := httptest.NewRequest("POST", path, strings.NewReader(body))
|
|
94
|
-
rec := httptest.NewRecorder()
|
|
95
|
-
s.handler().ServeHTTP(rec, req)
|
|
96
|
-
var out map[string]any
|
|
97
|
-
json.NewDecoder(rec.Body).Decode(&out)
|
|
98
|
-
return rec.Code, out
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
func TestSubmitStartsWorkflow(t *testing.T) {
|
|
102
|
-
s := testServer(t)
|
|
103
|
-
code, out := post(t, s, "/submit", `{"repoPath":"/x","yaml":`+jsonStr(goodYAML)+`}`)
|
|
104
|
-
if code != 200 || out["ok"] != true {
|
|
105
|
-
t.Fatalf("code=%d out=%v", code, out)
|
|
106
|
-
}
|
|
107
|
-
if s.entries["testFlow"] == nil {
|
|
108
|
-
t.Fatal("entry not registered")
|
|
109
|
-
}
|
|
110
|
-
if lastFakeTasks == nil || lastFakeRunner == nil {
|
|
111
|
-
t.Fatal("adapters not constructed")
|
|
112
|
-
}
|
|
113
|
-
// Duplicate name → 409.
|
|
114
|
-
code, _ = post(t, s, "/submit", `{"repoPath":"/x","yaml":`+jsonStr(goodYAML)+`}`)
|
|
115
|
-
if code != 409 {
|
|
116
|
-
t.Errorf("dup submit code=%d, want 409", code)
|
|
117
|
-
}
|
|
118
|
-
s.Shutdown()
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
func TestSubmitRejectsBadYAML(t *testing.T) {
|
|
122
|
-
s := testServer(t)
|
|
123
|
-
defer s.Shutdown()
|
|
124
|
-
code, _ := post(t, s, "/submit", `{"repoPath":"/x","yaml":"name: \"\""}`)
|
|
125
|
-
if code != 400 {
|
|
126
|
-
t.Errorf("code=%d", code)
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
func TestSubmitRejectsInvalidStatuses(t *testing.T) {
|
|
131
|
-
s := New(true, Deps{
|
|
132
|
-
ResolveRepo: func(path string) (string, string, error) { return "r", "n", nil },
|
|
133
|
-
ValidateConfig: func(y []byte) ([]string, error) { return []string{"DO Done"}, nil },
|
|
134
|
-
})
|
|
135
|
-
defer s.Shutdown()
|
|
136
|
-
code, out := post(t, s, "/submit", `{"repoPath":"/x","yaml":`+jsonStr(goodYAML)+`}`)
|
|
137
|
-
if code != 400 || !strings.Contains(fmt.Sprint(out["error"]), "DO Done") {
|
|
138
|
-
t.Errorf("code=%d out=%v", code, out)
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
func TestReportSuccessTransitions(t *testing.T) {
|
|
143
|
-
s := testServer(t)
|
|
144
|
-
post(t, s, "/submit", `{"repoPath":"/x","yaml":`+jsonStr(goodYAML)+`}`)
|
|
145
|
-
code, out := post(t, s, "/report",
|
|
146
|
-
`{"workflow":"testFlow","ticket":"XYZ-1","node":"coding","outcome":"success","summary":"did it"}`)
|
|
147
|
-
s.Shutdown()
|
|
148
|
-
if code != 200 || out["action"] != "transitioned" {
|
|
149
|
-
t.Fatalf("code=%d out=%v", code, out)
|
|
150
|
-
}
|
|
151
|
-
if len(lastFakeTasks.reports) != 1 || lastFakeTasks.reports[0] != "XYZ-1:success:done:did it" {
|
|
152
|
-
t.Errorf("reports = %v", lastFakeTasks.reports)
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
func TestReportSelfLoopActionCommented(t *testing.T) {
|
|
157
|
-
s := testServer(t)
|
|
158
|
-
post(t, s, "/submit", `{"repoPath":"/x","yaml":`+jsonStr(goodYAML)+`}`)
|
|
159
|
-
code, out := post(t, s, "/report",
|
|
160
|
-
`{"workflow":"testFlow","ticket":"XYZ-1","node":"coding","outcome":"failure","summary":"broke"}`)
|
|
161
|
-
s.Shutdown()
|
|
162
|
-
if code != 200 || out["action"] != "commented" {
|
|
163
|
-
t.Fatalf("self-loop must be commented: code=%d out=%v", code, out)
|
|
164
|
-
}
|
|
165
|
-
if lastFakeTasks.reports[0] != "XYZ-1:failure:coding:broke" {
|
|
166
|
-
t.Errorf("reports = %v", lastFakeTasks.reports)
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
func TestReportValidation(t *testing.T) {
|
|
171
|
-
s := testServer(t)
|
|
172
|
-
post(t, s, "/submit", `{"repoPath":"/x","yaml":`+jsonStr(goodYAML)+`}`)
|
|
173
|
-
defer s.Shutdown()
|
|
174
|
-
cases := []struct{ name, body string }{
|
|
175
|
-
{"bad outcome", `{"workflow":"testFlow","ticket":"XYZ-1","node":"coding","outcome":"done","summary":"x"}`},
|
|
176
|
-
{"unknown node", `{"workflow":"testFlow","ticket":"XYZ-1","node":"nope","outcome":"success","summary":"x"}`},
|
|
177
|
-
{"unknown workflow", `{"workflow":"nope","ticket":"XYZ-1","node":"coding","outcome":"success","summary":"x"}`},
|
|
178
|
-
{"missing fields", `{"workflow":"testFlow"}`},
|
|
179
|
-
}
|
|
180
|
-
for _, tc := range cases {
|
|
181
|
-
t.Run(tc.name, func(t *testing.T) {
|
|
182
|
-
code, _ := post(t, s, "/report", tc.body)
|
|
183
|
-
if code != 400 && code != 404 {
|
|
184
|
-
t.Errorf("code=%d", code)
|
|
185
|
-
}
|
|
186
|
-
})
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
func jsonStr(s string) string {
|
|
191
|
-
b, _ := json.Marshal(s)
|
|
192
|
-
return string(b)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
var _ = net.Dial // keep net import if unused later
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
# tasks/jira — Jira adapter
|
|
2
|
-
|
|
3
|
-
Implements `tasks.Tasks` over the [acli](https://github.com/acli) CLI. Jira
|
|
4
|
-
statuses are node states (`when`); claim labels `wf:<workflow>` are Jira
|
|
5
|
-
labels.
|
|
6
|
-
|
|
7
|
-
## Config (tasks.config)
|
|
8
|
-
|
|
9
|
-
| Field | Required | Meaning |
|
|
10
|
-
|---|---|---|
|
|
11
|
-
| `query` | yes | JQL fragment, e.g. `project = ABCD`. Must not contain `issuetype`, `assignee`, or `ORDER BY` — the adapter appends those. |
|
|
12
|
-
| `issueTypes` | yes | Scalar or list, e.g. `[Task, Story]`. Rendered as `AND issuetype IN (...)`. |
|
|
13
|
-
| `assigneeIsAgent` | no | Centralized mode: no assignee clause (org server owns the queue). Default false → `AND assignee = "<machine config>"` is appended. |
|
|
14
|
-
|
|
15
|
-
Built JQL: `(query) AND issuetype IN (...) AND component = "<repo displayName>" [AND assignee = "..."] ORDER BY updated`.
|
|
16
|
-
The repo displayName (resolved by the server from the submitter's cwd) scopes
|
|
17
|
-
the poll to one repo's tickets.
|
|
18
|
-
|
|
19
|
-
## Behavior
|
|
20
|
-
|
|
21
|
-
- **List** — one search per poll; maps each ticket's status → node via `when`
|
|
22
|
-
(unmapped → `Node: ""`, daemon skips); first `wf:*` label → `ClaimedBy`.
|
|
23
|
-
- **Claim** — adds `wf:<name>` label (labels are never removed; they're the
|
|
24
|
-
cross-restart mutex).
|
|
25
|
-
- **Report** — transitions to the target node's `when` status + posts the
|
|
26
|
-
summary comment (`[wf] KEY (agent: x, node: y) reported outcome → target`).
|
|
27
|
-
Self-loop (target status == current) → comment only; acli FAILURE envelopes
|
|
28
|
-
(exit-0 failures) are detected and returned as errors.
|
|
29
|
-
|
|
30
|
-
## Submit-time validation
|
|
31
|
-
|
|
32
|
-
`ProjectKeyFromQuery` + `ValidateStates` probe every `when` status against the
|
|
33
|
-
project — Jira's JQL parser rejects unknown statuses, so typos fail at submit
|
|
34
|
-
instead of silently matching nothing.
|
|
35
|
-
|
|
36
|
-
## Writing a new tasks adapter (beads, Linear, GitHub, ...)
|
|
37
|
-
|
|
38
|
-
1. Create `internal/tasks/<name>/` with:
|
|
39
|
-
```go
|
|
40
|
-
func init() {
|
|
41
|
-
tasks.Register("<name>", tasks.Factory{
|
|
42
|
-
UnmarshalConfig: unmarshalConfig, // strict-decode tasks.config into your struct
|
|
43
|
-
New: func(cfg any, wfName string, nodes map[string]config.Node, assignee, repoName string) (tasks.Tasks, error) {
|
|
44
|
-
// build your adapter; use nodes[..].When as the tracker-state map,
|
|
45
|
-
// wfName for claim labels, assignee/repoName if your tracker scopes by them
|
|
46
|
-
},
|
|
47
|
-
})
|
|
48
|
-
}
|
|
49
|
-
```
|
|
50
|
-
2. Implement the interface:
|
|
51
|
-
```go
|
|
52
|
-
type Tasks interface {
|
|
53
|
-
List() ([]tasks.Ticket, error) // Node + ClaimedBy filled
|
|
54
|
-
Claim(t tasks.Ticket) error // attach wf:<name> marker
|
|
55
|
-
Report(t tasks.Ticket, outcome, targetNode, summary string) error
|
|
56
|
-
}
|
|
57
|
-
```
|
|
58
|
-
`Ticket{Key, Summary, Node, ClaimedBy}` — you fill `Node` by reverse-mapping
|
|
59
|
-
the ticket's tracker state through `nodes[*].When`, and `ClaimedBy` from
|
|
60
|
-
your tracker's equivalent of claim markers (labels, assignments, ...).
|
|
61
|
-
3. Strictly validate your `tasks.config` in `UnmarshalConfig` (unknown fields
|
|
62
|
-
must error — core can't see inside).
|
|
63
|
-
4. Import your package for side effects (`_ "relay-flow/internal/tasks/<name>"`)
|
|
64
|
-
in `internal/server/server.go`, next to the jira import.
|
|
65
|
-
5. Table-driven tests with a fake for your tracker's client seam (see
|
|
66
|
-
`jira_test.go` and the `aclier` interface for the pattern).
|
|
67
|
-
|
|
68
|
-
External adapters (out of tree): fork the repo, add your package + import —
|
|
69
|
-
the registry is deliberately in-process (no .so loading, YAGNI).
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
package jira
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"strings"
|
|
5
|
-
"testing"
|
|
6
|
-
)
|
|
7
|
-
|
|
8
|
-
func TestJQLIncludesComponent(t *testing.T) {
|
|
9
|
-
j, err := newJira(JiraConfig{Query: "project = XYZ", IssueTypes: []string{"Task"}}, "wf", testNodes, "Jane Doe", "xyz-repo", nil)
|
|
10
|
-
if err != nil {
|
|
11
|
-
t.Fatalf("%v", err)
|
|
12
|
-
}
|
|
13
|
-
if !strings.Contains(j.jql, `component = "xyz-repo"`) {
|
|
14
|
-
t.Errorf("JQL missing component filter: %q", j.jql)
|
|
15
|
-
}
|
|
16
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
package jira
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"bytes"
|
|
5
|
-
"fmt"
|
|
6
|
-
|
|
7
|
-
"gopkg.in/yaml.v3"
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
// strictDecode re-marshals the opaque config map and decodes it into v
|
|
11
|
-
// with KnownFields(true), so unknown YAML keys in tasks.config are
|
|
12
|
-
// rejected just like top-level ones.
|
|
13
|
-
func strictDecode(m map[string]any, v any) error {
|
|
14
|
-
if m == nil {
|
|
15
|
-
m = map[string]any{}
|
|
16
|
-
}
|
|
17
|
-
b, err := yaml.Marshal(m)
|
|
18
|
-
if err != nil {
|
|
19
|
-
return fmt.Errorf("re-marshal config: %w", err)
|
|
20
|
-
}
|
|
21
|
-
dec := yaml.NewDecoder(bytes.NewReader(b))
|
|
22
|
-
dec.KnownFields(true)
|
|
23
|
-
return dec.Decode(v)
|
|
24
|
-
}
|
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
// Package jira is the built-in Tasks adapter for Jira. It talks to Jira
|
|
2
|
-
// via the acli CLI and knows nothing about runners, terminals, or agents.
|
|
3
|
-
// States are Jira status names; claims are Jira labels (wf:<workflow>).
|
|
4
|
-
package jira
|
|
5
|
-
|
|
6
|
-
import (
|
|
7
|
-
"fmt"
|
|
8
|
-
"regexp"
|
|
9
|
-
"strings"
|
|
10
|
-
|
|
11
|
-
"github.com/rajpopat27/relay-flow/internal/acli"
|
|
12
|
-
"github.com/rajpopat27/relay-flow/internal/config"
|
|
13
|
-
"github.com/rajpopat27/relay-flow/internal/tasks"
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
func init() {
|
|
17
|
-
tasks.Register("jira", tasks.Factory{
|
|
18
|
-
UnmarshalConfig: unmarshalConfig,
|
|
19
|
-
New: func(cfg any, wfName string, nodes map[string]config.Node, assignee, repoName string) (tasks.Tasks, error) {
|
|
20
|
-
c, ok := cfg.(JiraConfig)
|
|
21
|
-
if !ok {
|
|
22
|
-
return nil, fmt.Errorf("internal: jira factory received %T", cfg)
|
|
23
|
-
}
|
|
24
|
-
if repoName == "" {
|
|
25
|
-
return nil, fmt.Errorf("jira adapter requires a repo name (used as the JQL component filter)")
|
|
26
|
-
}
|
|
27
|
-
return newJira(c, wfName, nodes, assignee, repoName, nil)
|
|
28
|
-
},
|
|
29
|
-
})
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// JiraConfig is the strictly-unmarshalled tasks.config for type jira.
|
|
33
|
-
type JiraConfig struct {
|
|
34
|
-
// Query is a JQL fragment (no issuetype / assignee / ORDER BY — those
|
|
35
|
-
// are appended by the adapter or rejected).
|
|
36
|
-
Query string `yaml:"query"`
|
|
37
|
-
// IssueTypes restricts the workflow to these Jira issue types.
|
|
38
|
-
IssueTypes []string `yaml:"issueTypes"`
|
|
39
|
-
// AssigneeIsAgent marks centralized mode (org server owns the queue,
|
|
40
|
-
// tickets assigned upstream to bot accounts): no assignee clause.
|
|
41
|
-
AssigneeIsAgent bool `yaml:"assigneeIsAgent"`
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
var (
|
|
45
|
-
issueTypeRe = regexp.MustCompile(`(?i)\bissuetype\b`)
|
|
46
|
-
assigneeRe = regexp.MustCompile(`(?i)\bassignee\b`)
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
func unmarshalConfig(m map[string]any) (any, error) {
|
|
50
|
-
var c JiraConfig
|
|
51
|
-
if err := strictDecode(m, &c); err != nil {
|
|
52
|
-
return nil, err
|
|
53
|
-
}
|
|
54
|
-
if strings.TrimSpace(c.Query) == "" {
|
|
55
|
-
return nil, fmt.Errorf("query must not be empty")
|
|
56
|
-
}
|
|
57
|
-
if strings.Contains(strings.ToUpper(c.Query), "ORDER BY") {
|
|
58
|
-
return nil, fmt.Errorf("query must not contain ORDER BY (always ordered by updated)")
|
|
59
|
-
}
|
|
60
|
-
if issueTypeRe.MatchString(c.Query) {
|
|
61
|
-
return nil, fmt.Errorf("query must not contain issuetype; use the issueTypes field")
|
|
62
|
-
}
|
|
63
|
-
if assigneeRe.MatchString(c.Query) {
|
|
64
|
-
return nil, fmt.Errorf("query must not contain assignee; identity comes from machine config or assigneeIsAgent")
|
|
65
|
-
}
|
|
66
|
-
if len(c.IssueTypes) == 0 {
|
|
67
|
-
return nil, fmt.Errorf("issueTypes must not be empty")
|
|
68
|
-
}
|
|
69
|
-
for _, it := range c.IssueTypes {
|
|
70
|
-
if strings.TrimSpace(it) == "" {
|
|
71
|
-
return nil, fmt.Errorf("issueTypes must not contain empty values")
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return c, nil
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// aclier is the seam to Jira. *acli.Client satisfies it; tests fake it.
|
|
78
|
-
type aclier interface {
|
|
79
|
-
Search(jql string) ([]acli.Ticket, error)
|
|
80
|
-
View(key string) (acli.Ticket, error)
|
|
81
|
-
AddLabel(key string, existing []string, label string) error
|
|
82
|
-
Transition(key, status string) error
|
|
83
|
-
Comment(key, body string) error
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
type jiraTasks struct {
|
|
87
|
-
cfg JiraConfig
|
|
88
|
-
wfName string
|
|
89
|
-
nodes map[string]config.Node
|
|
90
|
-
assignee string
|
|
91
|
-
repoComponent string
|
|
92
|
-
jql string
|
|
93
|
-
ac aclier
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// newJira builds the adapter. repoComponent is the Jira component name
|
|
97
|
-
// (the Orca repo displayName); empty in unit tests → clause omitted.
|
|
98
|
-
// ac nil → real acli client.
|
|
99
|
-
func newJira(cfg JiraConfig, wfName string, nodes map[string]config.Node, assignee, repoComponent string, ac aclier) (*jiraTasks, error) {
|
|
100
|
-
if ac == nil {
|
|
101
|
-
ac = acli.New()
|
|
102
|
-
}
|
|
103
|
-
j := &jiraTasks{cfg: cfg, wfName: wfName, nodes: nodes, assignee: assignee, repoComponent: repoComponent, ac: ac}
|
|
104
|
-
j.jql = j.buildJQL()
|
|
105
|
-
return j, nil
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
func (j *jiraTasks) buildJQL() string {
|
|
109
|
-
quoted := make([]string, 0, len(j.cfg.IssueTypes))
|
|
110
|
-
for _, it := range j.cfg.IssueTypes {
|
|
111
|
-
quoted = append(quoted, fmt.Sprintf("%q", it))
|
|
112
|
-
}
|
|
113
|
-
q := fmt.Sprintf("(%s) AND issuetype IN (%s)", j.cfg.Query, strings.Join(quoted, ", "))
|
|
114
|
-
if j.repoComponent != "" {
|
|
115
|
-
q += fmt.Sprintf(" AND component = %q", j.repoComponent)
|
|
116
|
-
}
|
|
117
|
-
// Assignee comes from the machine config (per-person, uncommitted),
|
|
118
|
-
// never the shared workflow YAML. Centralized mode skips the clause.
|
|
119
|
-
if j.assignee != "" && !j.cfg.AssigneeIsAgent {
|
|
120
|
-
q += fmt.Sprintf(" AND assignee = %q", j.assignee)
|
|
121
|
-
}
|
|
122
|
-
return q + " ORDER BY updated"
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
func claimLabel(wfName string) string { return "wf:" + wfName }
|
|
126
|
-
|
|
127
|
-
// List runs the workflow's one JQL query and maps each ticket's Jira
|
|
128
|
-
// status back to a node via the `when` values ("" = unmapped). ClaimedBy
|
|
129
|
-
// is read off the wf:* labels.
|
|
130
|
-
func (j *jiraTasks) List() ([]tasks.Ticket, error) {
|
|
131
|
-
found, err := j.ac.Search(j.jql)
|
|
132
|
-
if err != nil {
|
|
133
|
-
return nil, err
|
|
134
|
-
}
|
|
135
|
-
out := make([]tasks.Ticket, 0, len(found))
|
|
136
|
-
for _, t := range found {
|
|
137
|
-
tk := tasks.Ticket{Key: t.Key, Summary: t.Summary}
|
|
138
|
-
for name, n := range j.nodes {
|
|
139
|
-
if strings.EqualFold(n.When, t.Status) {
|
|
140
|
-
tk.Node = name
|
|
141
|
-
break
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
for _, l := range t.Labels {
|
|
145
|
-
if strings.HasPrefix(l, "wf:") {
|
|
146
|
-
tk.ClaimedBy = strings.TrimPrefix(l, "wf:")
|
|
147
|
-
break
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
out = append(out, tk)
|
|
151
|
-
}
|
|
152
|
-
return out, nil
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Claim attaches this workflow's claim label. Labels are never removed:
|
|
156
|
-
// they are the cross-restart mutex.
|
|
157
|
-
func (j *jiraTasks) Claim(t tasks.Ticket) error {
|
|
158
|
-
cur, err := j.ac.View(t.Key)
|
|
159
|
-
if err != nil {
|
|
160
|
-
return fmt.Errorf("claim %s: view: %w", t.Key, err)
|
|
161
|
-
}
|
|
162
|
-
return j.ac.AddLabel(t.Key, cur.Labels, claimLabel(j.wfName))
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Report transitions the ticket to the target node's Jira status and
|
|
166
|
-
// posts the summary as a comment. Self-loop (target status == current
|
|
167
|
-
// status) → comment only (Jira has no self-transitions).
|
|
168
|
-
func (j *jiraTasks) Report(t tasks.Ticket, outcome, targetNode, summary string) error {
|
|
169
|
-
target, ok := j.nodes[targetNode]
|
|
170
|
-
if !ok {
|
|
171
|
-
return fmt.Errorf("unknown node %q", targetNode)
|
|
172
|
-
}
|
|
173
|
-
cur, err := j.ac.View(t.Key)
|
|
174
|
-
if err != nil {
|
|
175
|
-
return fmt.Errorf("view %s: %w", t.Key, err)
|
|
176
|
-
}
|
|
177
|
-
agent := j.nodes[t.Node].Agent
|
|
178
|
-
body := fmt.Sprintf("[%s] %s (agent: %s, node: %s) reported %s → %s\n\n%s", j.wfName, t.Key, agent, t.Node, outcome, targetNode, summary)
|
|
179
|
-
if strings.EqualFold(cur.Status, target.When) {
|
|
180
|
-
return j.ac.Comment(t.Key, body)
|
|
181
|
-
}
|
|
182
|
-
if err := j.ac.Transition(t.Key, target.When); err != nil {
|
|
183
|
-
return fmt.Errorf("transition %s → %q: %w", t.Key, target.When, err)
|
|
184
|
-
}
|
|
185
|
-
return j.ac.Comment(t.Key, body)
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// ProjectKeyFromQuery extracts the project key from a JQL fragment (used
|
|
189
|
-
// by submit-time status validation).
|
|
190
|
-
func ProjectKeyFromQuery(query string) (string, error) {
|
|
191
|
-
re := regexp.MustCompile(`(?i)\bproject\s*=\s*("?[A-Za-z][A-Za-z0-9]*"?)`)
|
|
192
|
-
m := re.FindStringSubmatch(query)
|
|
193
|
-
if m == nil {
|
|
194
|
-
return "", fmt.Errorf("could not find 'project = <KEY>' in query %q; it is required for status validation", query)
|
|
195
|
-
}
|
|
196
|
-
return strings.Trim(m[1], `"`), nil
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// UnmarshalConfigForValidation exposes the strict config decode for
|
|
200
|
-
// submit-time validation (server needs the query/assigneeIsAgent fields).
|
|
201
|
-
func UnmarshalConfigForValidation(m map[string]any) (JiraConfig, error) {
|
|
202
|
-
c, err := unmarshalConfig(m)
|
|
203
|
-
if err != nil {
|
|
204
|
-
return JiraConfig{}, err
|
|
205
|
-
}
|
|
206
|
-
return c.(JiraConfig), nil
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// StatusValidator is the seam ValidateStates uses. *acli.Client fits.
|
|
210
|
-
type StatusValidator interface {
|
|
211
|
-
ValidateStatus(projectKey, status string) error
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// ValidateStates probes every node's `when` status against the Jira
|
|
215
|
-
// project; Jira's JQL parser rejects unknown statuses with a hard error,
|
|
216
|
-
// so a typo fails at submit instead of silently matching zero tickets.
|
|
217
|
-
func ValidateStates(v StatusValidator, nodes map[string]config.Node, projectKey string) ([]string, error) {
|
|
218
|
-
seen := map[string]bool{}
|
|
219
|
-
var bad []string
|
|
220
|
-
for _, n := range nodes {
|
|
221
|
-
key := strings.ToLower(strings.TrimSpace(n.When))
|
|
222
|
-
if key == "" || seen[key] {
|
|
223
|
-
continue
|
|
224
|
-
}
|
|
225
|
-
seen[key] = true
|
|
226
|
-
if err := v.ValidateStatus(projectKey, n.When); err != nil {
|
|
227
|
-
bad = append(bad, n.When)
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
return bad, nil
|
|
231
|
-
}
|