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.
Files changed (116) hide show
  1. package/README.md +148 -143
  2. package/cmd/relay-flow/commands_test.go +464 -0
  3. package/cmd/relay-flow/main.go +670 -180
  4. package/cmd/relay-flow/scenario_test.go +1135 -0
  5. package/cmd/relay-flow/serve.go +609 -0
  6. package/go.mod +69 -2
  7. package/go.sum +185 -0
  8. package/internal/config/config.go +88 -0
  9. package/internal/config/machine.go +99 -48
  10. package/internal/config/machine_test.go +248 -0
  11. package/internal/config/merge_test.go +118 -0
  12. package/internal/config/writeatomic.go +36 -0
  13. package/internal/config/writeatomic_test.go +98 -0
  14. package/internal/execution/goworkflows/activities.go +490 -0
  15. package/internal/execution/goworkflows/engine.go +487 -0
  16. package/internal/execution/goworkflows/engine_test.go +600 -0
  17. package/internal/execution/goworkflows/fakes_test.go +517 -0
  18. package/internal/execution/goworkflows/interpreter.go +605 -0
  19. package/internal/execution/goworkflows/logging_test.go +154 -0
  20. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  21. package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
  22. package/internal/execution/goworkflows/node_runtime_test.go +486 -0
  23. package/internal/execution/goworkflows/projection.go +504 -0
  24. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  25. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  26. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  27. package/internal/harness/contract_test.go +169 -0
  28. package/internal/harness/factory.go +63 -0
  29. package/internal/harness/harness.go +41 -0
  30. package/internal/harness/opencode/opencode.go +166 -0
  31. package/internal/harness/opencode/opencode_test.go +50 -0
  32. package/internal/harness/plugin_selection_test.go +126 -0
  33. package/internal/identity/identity.go +37 -0
  34. package/internal/logging/logging.go +56 -0
  35. package/internal/logging/logging_test.go +116 -0
  36. package/internal/paths/paths.go +67 -0
  37. package/internal/recover/recover.go +115 -0
  38. package/internal/repo/poller.go +186 -0
  39. package/internal/repo/poller_test.go +327 -0
  40. package/internal/repo/repo.go +119 -0
  41. package/internal/repo/service.go +216 -0
  42. package/internal/repo/service_test.go +298 -0
  43. package/internal/retry/retry.go +118 -0
  44. package/internal/router/router.go +83 -0
  45. package/internal/router/router_test.go +144 -0
  46. package/internal/run/manager.go +108 -0
  47. package/internal/run/run.go +140 -0
  48. package/internal/run/run_identity_test.go +52 -0
  49. package/internal/run/run_manager_test.go +266 -0
  50. package/internal/runner/contract_test.go +221 -0
  51. package/internal/runner/factory.go +65 -0
  52. package/internal/runner/orca/orca.go +363 -170
  53. package/internal/runner/orca/orca_test.go +134 -160
  54. package/internal/runner/orca/orcacli/orcacli.go +215 -0
  55. package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
  56. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  57. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
  58. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  66. package/internal/runner/runner.go +47 -64
  67. package/internal/server/api_test.go +300 -0
  68. package/internal/server/client.go +192 -74
  69. package/internal/server/fixture_test.go +248 -0
  70. package/internal/server/server.go +425 -248
  71. package/internal/server/shutdown_test.go +116 -0
  72. package/internal/task/contract_test.go +223 -0
  73. package/internal/task/factory.go +103 -0
  74. package/internal/task/jira/acli/acli.go +306 -0
  75. package/internal/task/jira/acli/acli_test.go +208 -0
  76. package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
  77. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
  78. package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
  79. package/internal/task/jira/acli/testdata/search_success.json +1 -0
  80. package/internal/task/jira/filters_test.go +234 -0
  81. package/internal/task/jira/helpers_test.go +60 -0
  82. package/internal/task/jira/jira.go +507 -0
  83. package/internal/task/jira/normalize.go +101 -0
  84. package/internal/task/jira/testdata/acli_search.json +120 -0
  85. package/internal/task/jira/transition_defaults_test.go +156 -0
  86. package/internal/task/jira/validation_test.go +94 -0
  87. package/internal/task/task.go +84 -0
  88. package/internal/workflow/report.go +85 -0
  89. package/internal/workflow/report_test.go +259 -0
  90. package/internal/workflow/service.go +142 -0
  91. package/internal/workflow/store.go +136 -0
  92. package/internal/workflow/store_test.go +282 -0
  93. package/internal/workflow/workflow.go +342 -0
  94. package/internal/workflow/workflow_test.go +410 -0
  95. package/package.json +1 -1
  96. package/internal/acli/acli.go +0 -229
  97. package/internal/config/demo_test.go +0 -17
  98. package/internal/config/schema.go +0 -193
  99. package/internal/config/schema_test.go +0 -162
  100. package/internal/daemon/daemon.go +0 -218
  101. package/internal/daemon/daemon_test.go +0 -204
  102. package/internal/discovery/discovery.go +0 -122
  103. package/internal/discovery/discovery_test.go +0 -62
  104. package/internal/opencode/opencode.go +0 -26
  105. package/internal/orcacli/orcacli.go +0 -264
  106. package/internal/runner/orca/README.md +0 -64
  107. package/internal/runner/runner_test.go +0 -64
  108. package/internal/server/server_test.go +0 -195
  109. package/internal/tasks/jira/README.md +0 -69
  110. package/internal/tasks/jira/component_test.go +0 -16
  111. package/internal/tasks/jira/decode.go +0 -24
  112. package/internal/tasks/jira/jira.go +0 -231
  113. package/internal/tasks/jira/jira_test.go +0 -259
  114. package/internal/tasks/jira/jql_test.go +0 -16
  115. package/internal/tasks/tasks.go +0 -90
  116. 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
- }