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,507 @@
|
|
|
1
|
+
// Package jira is the Jira task-system adapter. It owns one typed Config
|
|
2
|
+
// spanning root, repo, workflow, and node scopes; core never imports it.
|
|
3
|
+
package jira
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"fmt"
|
|
8
|
+
"log/slog"
|
|
9
|
+
"sort"
|
|
10
|
+
"strings"
|
|
11
|
+
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/retry"
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/task/jira/acli"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// Config is the adapter-owned typed config for every scope.
|
|
19
|
+
type Config struct {
|
|
20
|
+
Assignee string `yaml:"assignee,omitempty"`
|
|
21
|
+
Project string `yaml:"project,omitempty"`
|
|
22
|
+
Component string `yaml:"component,omitempty"`
|
|
23
|
+
Site string `yaml:"site,omitempty"`
|
|
24
|
+
Filters Filters `yaml:"filters,omitempty"`
|
|
25
|
+
Transition TransitionTo `yaml:"transitionTo,omitempty"`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Filters are the structured, locally evaluable workflow ticket matchers.
|
|
29
|
+
type Filters struct {
|
|
30
|
+
ParentStatuses []string `yaml:"parentStatuses,omitempty"`
|
|
31
|
+
IssueTypes []string `yaml:"issueTypes,omitempty"`
|
|
32
|
+
Labels []string `yaml:"labels,omitempty"`
|
|
33
|
+
Assignees []string `yaml:"assignees,omitempty"`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// TransitionTo carries parent/mailbox status transitions for a node.
|
|
37
|
+
type TransitionTo struct {
|
|
38
|
+
ParentStatus string `yaml:"parentStatus,omitempty"`
|
|
39
|
+
TaskStatus string `yaml:"taskStatus,omitempty"`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Deterministic transition defaults for omitted values.
|
|
43
|
+
const (
|
|
44
|
+
defaultStartParentStatus = "In Progress"
|
|
45
|
+
defaultWorkTaskStatus = "In Progress"
|
|
46
|
+
defaultEndParentStatus = "Done"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
// claimLabel is the permanent workflow claim label.
|
|
50
|
+
func claimLabel(workflow string) string { return "wf:" + workflow }
|
|
51
|
+
|
|
52
|
+
func init() {
|
|
53
|
+
task.Register("jira", task.Factory{
|
|
54
|
+
RequiredRepoKeys: func() []string { return []string{"project", "component"} },
|
|
55
|
+
TaskScopeKey: func(rootConfig, repoConfig config.RawValues) (string, error) {
|
|
56
|
+
var root, repoCfg Config
|
|
57
|
+
if err := config.DecodeStrict(rootConfig, &root); err != nil {
|
|
58
|
+
return "", fmt.Errorf("root task config: %w", err)
|
|
59
|
+
}
|
|
60
|
+
if err := config.DecodeStrict(repoConfig, &repoCfg); err != nil {
|
|
61
|
+
return "", fmt.Errorf("repo task config: %w", err)
|
|
62
|
+
}
|
|
63
|
+
proj := repoCfg.Project
|
|
64
|
+
comp := repoCfg.Component
|
|
65
|
+
if proj == "" || comp == "" {
|
|
66
|
+
return "", fmt.Errorf("jira task scope requires repo project and component")
|
|
67
|
+
}
|
|
68
|
+
return strings.Join([]string{root.Site, proj, comp}, "/"), nil
|
|
69
|
+
},
|
|
70
|
+
New: func(ctx context.Context, spec task.RepoSpec) (task.System, error) {
|
|
71
|
+
return newSystem(ctx, acli.New(), spec)
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// system is the repo-bound Jira task.System. It is safe for concurrent use;
|
|
77
|
+
// the ACLI client owns any subprocess serialization.
|
|
78
|
+
type system struct {
|
|
79
|
+
cli acli.Client
|
|
80
|
+
repoName string
|
|
81
|
+
base config.RawValues
|
|
82
|
+
effective Config // root+repo merged
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func newSystem(ctx context.Context, cli acli.Client, spec task.RepoSpec) (*system, error) {
|
|
86
|
+
if spec.Name == "" {
|
|
87
|
+
return nil, fmt.Errorf("jira: repo name is required")
|
|
88
|
+
}
|
|
89
|
+
merged := config.Merge(spec.RootConfig, spec.RepoConfig)
|
|
90
|
+
var cfg Config
|
|
91
|
+
if err := config.DecodeStrict(merged, &cfg); err != nil {
|
|
92
|
+
return nil, fmt.Errorf("jira repo %q config: %w", spec.Name, err)
|
|
93
|
+
}
|
|
94
|
+
if cfg.Assignee != "" {
|
|
95
|
+
if err := cli.ValidateAssignee(ctx, cfg.Assignee); err != nil {
|
|
96
|
+
return nil, fmt.Errorf("jira repo %q assignee %q: %w", spec.Name, cfg.Assignee, err)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
s := &system{cli: cli, repoName: spec.Name, base: merged, effective: cfg}
|
|
100
|
+
if err := s.validateTransition(ctx, "repo config", cfg.Project, cfg.Transition); err != nil {
|
|
101
|
+
return nil, fmt.Errorf("jira repo %q: %w", spec.Name, err)
|
|
102
|
+
}
|
|
103
|
+
for _, status := range []string{defaultStartParentStatus, defaultEndParentStatus, "To Do"} {
|
|
104
|
+
if err := cli.ValidateStatus(ctx, cfg.Project, status); err != nil {
|
|
105
|
+
return nil, fmt.Errorf("jira repo %q default status %q: %w", spec.Name, status, err)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// project/component are required repo keys enforced at registration
|
|
109
|
+
// (RequiredRepoKeys); construction also probes external Jira names.
|
|
110
|
+
return s, nil
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// newSystemForCLI constructs a system around an explicit CLI seam (tests).
|
|
114
|
+
func newSystemForCLI(cli acli.Client) (task.System, error) {
|
|
115
|
+
return newSystem(context.Background(), cli, task.RepoSpec{
|
|
116
|
+
Name: "payments",
|
|
117
|
+
RootConfig: config.RawValues{},
|
|
118
|
+
RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// decodeConfig strictly decodes one operation's effective raw values.
|
|
123
|
+
func decodeConfig(raw config.RawValues) (Config, error) {
|
|
124
|
+
var cfg Config
|
|
125
|
+
if err := config.DecodeStrict(raw, &cfg); err != nil {
|
|
126
|
+
return Config{}, err
|
|
127
|
+
}
|
|
128
|
+
return cfg, nil
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- Poll / filters ---
|
|
132
|
+
|
|
133
|
+
// buildJQL scopes the parent poll by project/component and active statuses.
|
|
134
|
+
func (s *system) buildJQL() string {
|
|
135
|
+
parts := []string{
|
|
136
|
+
fmt.Sprintf("project = %s", s.effective.Project),
|
|
137
|
+
fmt.Sprintf("component = %q", s.effective.Component),
|
|
138
|
+
"issuetype != Subtask",
|
|
139
|
+
"statusCategory != Done",
|
|
140
|
+
}
|
|
141
|
+
return strings.Join(parts, " AND ")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Poll returns active parent tickets only; mailbox subtasks are never
|
|
145
|
+
// returned as candidates.
|
|
146
|
+
func (s *system) Poll(ctx context.Context) ([]task.Ticket, error) {
|
|
147
|
+
if s.effective.Project == "" || s.effective.Component == "" {
|
|
148
|
+
return nil, fmt.Errorf("jira repo %q: project and component are required to poll", s.repoName)
|
|
149
|
+
}
|
|
150
|
+
jql := s.buildJQL()
|
|
151
|
+
slog.Debug("jira poll", "repo", s.repoName, "jql", jql)
|
|
152
|
+
raw, err := s.cli.Search(ctx, jql)
|
|
153
|
+
if err != nil {
|
|
154
|
+
return nil, err
|
|
155
|
+
}
|
|
156
|
+
tickets, err := normalizeSearchResponse(raw)
|
|
157
|
+
if err != nil {
|
|
158
|
+
return nil, err
|
|
159
|
+
}
|
|
160
|
+
for _, t := range tickets {
|
|
161
|
+
slog.Debug("jira ticket",
|
|
162
|
+
"repo", s.repoName, "ticket", t.Key, "id", t.ID,
|
|
163
|
+
"title", t.Title, "claims", strings.Join(t.WorkflowClaims, ","),
|
|
164
|
+
"fields", fmt.Sprint(t.Fields))
|
|
165
|
+
}
|
|
166
|
+
return tickets, nil
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// CompileFilter compiles workflow taskConfig.filters into an in-memory
|
|
170
|
+
// matcher over normalized ticket fields. Unknown filter fields are rejected.
|
|
171
|
+
func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.Ticket) bool, error) {
|
|
172
|
+
cfg, err := decodeConfig(workflowTaskConfig)
|
|
173
|
+
if err != nil {
|
|
174
|
+
return nil, err
|
|
175
|
+
}
|
|
176
|
+
f := cfg.Filters
|
|
177
|
+
return func(t task.Ticket) bool {
|
|
178
|
+
if len(f.ParentStatuses) > 0 && !contains(f.ParentStatuses, strField(t.Fields, "status")) {
|
|
179
|
+
return false
|
|
180
|
+
}
|
|
181
|
+
if len(f.IssueTypes) > 0 && !contains(f.IssueTypes, strField(t.Fields, "issueType")) {
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
if len(f.Labels) > 0 {
|
|
185
|
+
ticketLabels := strSliceField(t.Fields, "labels")
|
|
186
|
+
for _, want := range f.Labels {
|
|
187
|
+
if !contains(ticketLabels, want) {
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if len(f.Assignees) > 0 && !contains(f.Assignees, strField(t.Fields, "assignee")) {
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
return true
|
|
196
|
+
}, nil
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// --- Claim ---
|
|
200
|
+
|
|
201
|
+
// Claim adds wf:<workflow> to the parent. It re-reads the parent's claims,
|
|
202
|
+
// treats the same claim as idempotent, and rejects a conflicting claim.
|
|
203
|
+
func (s *system) Claim(ctx context.Context, ticket task.TicketRef, workflow string) error {
|
|
204
|
+
raw, err := s.cli.View(ctx, ticket.Key)
|
|
205
|
+
if err != nil {
|
|
206
|
+
return err
|
|
207
|
+
}
|
|
208
|
+
labels, err := labelsOf(raw)
|
|
209
|
+
if err != nil {
|
|
210
|
+
return err
|
|
211
|
+
}
|
|
212
|
+
want := claimLabel(workflow)
|
|
213
|
+
for _, l := range labels {
|
|
214
|
+
if l == want {
|
|
215
|
+
return nil // idempotent
|
|
216
|
+
}
|
|
217
|
+
if strings.HasPrefix(l, "wf:") {
|
|
218
|
+
return fmt.Errorf("ticket %s already claimed by %s", ticket.Key, l)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return s.cli.EnsureLabel(ctx, ticket.Key, want)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// --- Config validation ---
|
|
225
|
+
|
|
226
|
+
// ValidateConfig strictly validates the workflow and every node task config
|
|
227
|
+
// against the adapter-owned schema for this repo. It never mutates the
|
|
228
|
+
// caller's maps.
|
|
229
|
+
func (s *system) ValidateConfig(ctx context.Context, workflowTaskConfig config.RawValues, nodeTaskConfigs map[string]config.RawValues) error {
|
|
230
|
+
workflowCfg, err := decodeConfig(config.Merge(s.base, workflowTaskConfig))
|
|
231
|
+
if err != nil {
|
|
232
|
+
return fmt.Errorf("workflow taskConfig: %w", err)
|
|
233
|
+
}
|
|
234
|
+
if err := s.validateAssignee(ctx, "workflow", workflowCfg.Assignee); err != nil {
|
|
235
|
+
return err
|
|
236
|
+
}
|
|
237
|
+
if err := s.validateTransition(ctx, "workflow", workflowCfg.Project, workflowCfg.Transition); err != nil {
|
|
238
|
+
return err
|
|
239
|
+
}
|
|
240
|
+
nodes := make([]string, 0, len(nodeTaskConfigs))
|
|
241
|
+
for n := range nodeTaskConfigs {
|
|
242
|
+
nodes = append(nodes, n)
|
|
243
|
+
}
|
|
244
|
+
sort.Strings(nodes)
|
|
245
|
+
for _, n := range nodes {
|
|
246
|
+
cfg, err := decodeConfig(config.Merge(s.base, workflowTaskConfig, nodeTaskConfigs[n]))
|
|
247
|
+
if err != nil {
|
|
248
|
+
return fmt.Errorf("node %q taskConfig: %w", n, err)
|
|
249
|
+
}
|
|
250
|
+
if err := s.validateAssignee(ctx, fmt.Sprintf("node %q", n), cfg.Assignee); err != nil {
|
|
251
|
+
return err
|
|
252
|
+
}
|
|
253
|
+
if err := s.validateTransition(ctx, fmt.Sprintf("node %q", n), cfg.Project, cfg.Transition); err != nil {
|
|
254
|
+
return err
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return nil
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
func (s *system) validateAssignee(ctx context.Context, scope, assignee string) error {
|
|
261
|
+
if assignee == "" {
|
|
262
|
+
return nil
|
|
263
|
+
}
|
|
264
|
+
if err := s.cli.ValidateAssignee(ctx, assignee); err != nil {
|
|
265
|
+
return fmt.Errorf("%s assignee %q: %w", scope, assignee, err)
|
|
266
|
+
}
|
|
267
|
+
return nil
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
func (s *system) validateTransition(ctx context.Context, scope, project string, transition TransitionTo) error {
|
|
271
|
+
for _, candidate := range []struct {
|
|
272
|
+
field string
|
|
273
|
+
status string
|
|
274
|
+
}{
|
|
275
|
+
{field: "parentStatus", status: transition.ParentStatus},
|
|
276
|
+
{field: "taskStatus", status: transition.TaskStatus},
|
|
277
|
+
} {
|
|
278
|
+
field, status := candidate.field, candidate.status
|
|
279
|
+
if status == "" {
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
if err := s.cli.ValidateStatus(ctx, project, status); err != nil {
|
|
283
|
+
return fmt.Errorf("%s %s %q: %w", scope, field, status, err)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return nil
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// LifecycleDefaults exposure: the deterministic Jira transition defaults per
|
|
290
|
+
// lifecycle point (spec: Jira transition defaults are deterministic). Run
|
|
291
|
+
// orchestration merges these raw values under the effective node config
|
|
292
|
+
// before ApplyTaskConfig; omitted values inherit the default, explicit
|
|
293
|
+
// values win (map merge with defaults as the lower layer).
|
|
294
|
+
|
|
295
|
+
// StartDefaults defaults the parent to In Progress.
|
|
296
|
+
func (s *system) StartDefaults() config.RawValues {
|
|
297
|
+
return config.RawValues{"transitionTo": map[string]any{"parentStatus": defaultStartParentStatus}}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// WorkDefaults defaults the mailbox task status to In Progress; the parent
|
|
301
|
+
// is left unchanged when parentStatus is omitted.
|
|
302
|
+
func (s *system) WorkDefaults() config.RawValues {
|
|
303
|
+
return config.RawValues{"transitionTo": map[string]any{"taskStatus": defaultWorkTaskStatus}}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// EndDefaults defaults the parent to Done.
|
|
307
|
+
func (s *system) EndDefaults() config.RawValues {
|
|
308
|
+
return config.RawValues{"transitionTo": map[string]any{"parentStatus": defaultEndParentStatus}}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// endConfig applies the deterministic end default: omitted parentStatus
|
|
312
|
+
// becomes Done.
|
|
313
|
+
func endConfig(cfg config.RawValues) config.RawValues {
|
|
314
|
+
return withTransitionDefault(cfg, "parentStatus", defaultEndParentStatus)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
func withTransitionDefault(cfg config.RawValues, key, value string) config.RawValues {
|
|
318
|
+
out := config.RawValues{}
|
|
319
|
+
for k, v := range cfg {
|
|
320
|
+
out[k] = v
|
|
321
|
+
}
|
|
322
|
+
merged := map[string]any{}
|
|
323
|
+
if tr, ok := out["transitionTo"].(map[string]any); ok {
|
|
324
|
+
for k, v := range tr {
|
|
325
|
+
merged[k] = v
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if _, ok := merged[key]; !ok {
|
|
329
|
+
merged[key] = value
|
|
330
|
+
}
|
|
331
|
+
out["transitionTo"] = merged
|
|
332
|
+
return out
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- Mailboxes ---
|
|
336
|
+
|
|
337
|
+
// EnsureMailboxes finds existing child mailboxes by parent and title
|
|
338
|
+
// (<ticket>:<node>), creates only missing ones with the workflow label, and
|
|
339
|
+
// returns the complete node-to-mailbox map.
|
|
340
|
+
func (s *system) EnsureMailboxes(ctx context.Context, parent task.TicketRef, workflow string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
|
|
341
|
+
raw, err := s.cli.View(ctx, parent.Key)
|
|
342
|
+
if err != nil {
|
|
343
|
+
return nil, err
|
|
344
|
+
}
|
|
345
|
+
existing, err := subtasksOf(raw)
|
|
346
|
+
if err != nil {
|
|
347
|
+
return nil, err
|
|
348
|
+
}
|
|
349
|
+
out := map[string]task.Mailbox{}
|
|
350
|
+
for _, spec := range specs {
|
|
351
|
+
if mb, ok := existing[spec.Title]; ok {
|
|
352
|
+
// Found existing: reconcile the workflow label and description,
|
|
353
|
+
// and return the complete node-identified value.
|
|
354
|
+
if err := s.cli.EnsureLabel(ctx, mb.Key, claimLabel(workflow)); err != nil {
|
|
355
|
+
return nil, fmt.Errorf("label mailbox %q: %w", mb.Key, err)
|
|
356
|
+
}
|
|
357
|
+
if err := s.cli.UpdateDescription(ctx, mb.Key, spec.Description); err != nil {
|
|
358
|
+
return nil, fmt.Errorf("describe mailbox %q: %w", mb.Key, err)
|
|
359
|
+
}
|
|
360
|
+
mb.Node = spec.Node
|
|
361
|
+
out[spec.Node] = mb
|
|
362
|
+
continue
|
|
363
|
+
}
|
|
364
|
+
id, key, err := s.cli.CreateSubtask(ctx, parent.Key, spec.Title, spec.Description)
|
|
365
|
+
if err != nil {
|
|
366
|
+
return nil, fmt.Errorf("create mailbox %q: %w", spec.Title, err)
|
|
367
|
+
}
|
|
368
|
+
if err := s.cli.EnsureLabel(ctx, key, claimLabel(workflow)); err != nil {
|
|
369
|
+
return nil, fmt.Errorf("label mailbox %q: %w", key, err)
|
|
370
|
+
}
|
|
371
|
+
out[spec.Node] = task.Mailbox{ID: id, Key: key, Node: spec.Node}
|
|
372
|
+
}
|
|
373
|
+
return out, nil
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// --- Transitions ---
|
|
377
|
+
|
|
378
|
+
// ApplyTaskConfig applies the adapter-owned taskConfig to the parent and
|
|
379
|
+
// optional mailbox. Deterministic defaults: an omitted work-node taskStatus
|
|
380
|
+
// defaults the mailbox to In Progress and leaves the parent unchanged; an
|
|
381
|
+
// omitted parent-only parentStatus defaults to In Progress. Run
|
|
382
|
+
// orchestration merges EndDefaults into the end node's config before this
|
|
383
|
+
// call, so end processing transitions the parent to Done when omitted.
|
|
384
|
+
func (s *system) ApplyTaskConfig(ctx context.Context, target task.Target, taskConfig config.RawValues) error {
|
|
385
|
+
cfg, err := decodeConfig(taskConfig)
|
|
386
|
+
if err != nil {
|
|
387
|
+
return err
|
|
388
|
+
}
|
|
389
|
+
tr := cfg.Transition
|
|
390
|
+
if target.Mailbox != nil {
|
|
391
|
+
if cfg.Assignee != "" {
|
|
392
|
+
if err := s.cli.Assign(ctx, target.Mailbox.Key, cfg.Assignee); err != nil {
|
|
393
|
+
return err
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
status := tr.TaskStatus
|
|
397
|
+
if status == "" {
|
|
398
|
+
status = defaultWorkTaskStatus
|
|
399
|
+
}
|
|
400
|
+
if err := s.transition(ctx, target.Mailbox.Key, status); err != nil {
|
|
401
|
+
return err
|
|
402
|
+
}
|
|
403
|
+
if tr.ParentStatus != "" {
|
|
404
|
+
return s.transition(ctx, target.Parent.Key, tr.ParentStatus)
|
|
405
|
+
}
|
|
406
|
+
return nil
|
|
407
|
+
}
|
|
408
|
+
// Parent-only target (start/end lifecycle processing).
|
|
409
|
+
status := tr.ParentStatus
|
|
410
|
+
if status == "" {
|
|
411
|
+
status = defaultStartParentStatus
|
|
412
|
+
}
|
|
413
|
+
return s.transition(ctx, target.Parent.Key, status)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// transition applies a Jira status transition, mapping a human-incompatible
|
|
417
|
+
// current state to a conflict.
|
|
418
|
+
func (s *system) transition(ctx context.Context, key, status string) error {
|
|
419
|
+
err := s.cli.Transition(ctx, key, status)
|
|
420
|
+
if err != nil && isConflict(err) {
|
|
421
|
+
return retry.ConflictError(err)
|
|
422
|
+
}
|
|
423
|
+
return err
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
func isConflict(err error) bool {
|
|
427
|
+
msg := strings.ToLower(err.Error())
|
|
428
|
+
return strings.Contains(msg, "transition") && (strings.Contains(msg, "not available") ||
|
|
429
|
+
strings.Contains(msg, "cannot") || strings.Contains(msg, "invalid"))
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// CompleteMailbox marks the mailbox Done using task-system semantics.
|
|
433
|
+
func (s *system) CompleteMailbox(ctx context.Context, mailbox task.Mailbox) error {
|
|
434
|
+
return s.transition(ctx, mailbox.Key, "Done")
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// --- Comments ---
|
|
438
|
+
|
|
439
|
+
// HasComment reports whether a comment carrying the stable relay-flow
|
|
440
|
+
// marker exists on the target.
|
|
441
|
+
func (s *system) HasComment(ctx context.Context, target task.Target, marker string) (bool, error) {
|
|
442
|
+
key := target.Parent.Key
|
|
443
|
+
if target.Mailbox != nil {
|
|
444
|
+
key = target.Mailbox.Key
|
|
445
|
+
}
|
|
446
|
+
bodies, err := s.cli.ListComments(ctx, key)
|
|
447
|
+
if err != nil {
|
|
448
|
+
return false, err
|
|
449
|
+
}
|
|
450
|
+
for _, b := range bodies {
|
|
451
|
+
if strings.Contains(b, marker) {
|
|
452
|
+
return true, nil
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return false, nil
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Comment writes a human-readable comment carrying the stable marker. It is
|
|
459
|
+
// idempotent: when a comment with the marker already exists on the target it
|
|
460
|
+
// returns success without posting, so retried summary/feedback/cancellation
|
|
461
|
+
// comments are not duplicated.
|
|
462
|
+
func (s *system) Comment(ctx context.Context, target task.Target, body, marker string) error {
|
|
463
|
+
key := target.Parent.Key
|
|
464
|
+
if target.Mailbox != nil {
|
|
465
|
+
key = target.Mailbox.Key
|
|
466
|
+
}
|
|
467
|
+
exists, err := s.HasComment(ctx, target, marker)
|
|
468
|
+
if err != nil {
|
|
469
|
+
return err
|
|
470
|
+
}
|
|
471
|
+
if exists {
|
|
472
|
+
return nil
|
|
473
|
+
}
|
|
474
|
+
return s.cli.AddComment(ctx, key, body+"\n\n<!-- "+marker+" -->")
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// --- Recovery ---
|
|
478
|
+
|
|
479
|
+
// ResetForRecovery resets mailbox subtasks to To Do while preserving
|
|
480
|
+
// comments, labels, and history. No parent rollback runs.
|
|
481
|
+
func (s *system) ResetForRecovery(ctx context.Context, _ task.TicketRef, mailboxes []task.Mailbox, _ config.RawValues) error {
|
|
482
|
+
for _, mb := range mailboxes {
|
|
483
|
+
if err := s.transition(ctx, mb.Key, "To Do"); err != nil {
|
|
484
|
+
return fmt.Errorf("reset mailbox %s: %w", mb.Key, err)
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return nil
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
func contains(xs []string, want string) bool {
|
|
491
|
+
for _, x := range xs {
|
|
492
|
+
if x == want {
|
|
493
|
+
return true
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return false
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
func strField(fields map[string]any, key string) string {
|
|
500
|
+
s, _ := fields[key].(string)
|
|
501
|
+
return s
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
func strSliceField(fields map[string]any, key string) []string {
|
|
505
|
+
out, _ := fields[key].([]string)
|
|
506
|
+
return out
|
|
507
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// rawIssue matches one entry in acli's search output. acli emits a BARE
|
|
11
|
+
// ARRAY of these (no {"issues":[...]} REST envelope) — the adapter owns
|
|
12
|
+
// the acli wire contract, not the REST API's.
|
|
13
|
+
type rawIssue struct {
|
|
14
|
+
ID string `json:"id"`
|
|
15
|
+
Key string `json:"key"`
|
|
16
|
+
Fields struct {
|
|
17
|
+
Summary string `json:"summary"`
|
|
18
|
+
Status struct {
|
|
19
|
+
Name string `json:"name"`
|
|
20
|
+
} `json:"status"`
|
|
21
|
+
IssueType struct {
|
|
22
|
+
Name string `json:"name"`
|
|
23
|
+
} `json:"issuetype"`
|
|
24
|
+
Labels []string `json:"labels"`
|
|
25
|
+
Assignee *struct {
|
|
26
|
+
DisplayName string `json:"displayName"`
|
|
27
|
+
EmailAddress string `json:"emailAddress"`
|
|
28
|
+
} `json:"assignee"`
|
|
29
|
+
Subtasks []struct {
|
|
30
|
+
ID string `json:"id"`
|
|
31
|
+
Key string `json:"key"`
|
|
32
|
+
Fields struct {
|
|
33
|
+
Summary string `json:"summary"`
|
|
34
|
+
} `json:"fields"`
|
|
35
|
+
} `json:"subtasks"`
|
|
36
|
+
} `json:"fields"`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// normalizeSearchResponse converts raw acli search JSON (a bare array of
|
|
40
|
+
// issue objects) into normalized parent tickets: status, issueType, labels,
|
|
41
|
+
// and assignee become plain Fields entries. Assignee is normalized to the
|
|
42
|
+
// user's email address — the stable, machine-comparable identity workflow
|
|
43
|
+
// filters match against (displayName is human-readable, not an identifier).
|
|
44
|
+
// Subtasks are never returned as parents.
|
|
45
|
+
func normalizeSearchResponse(raw []byte) ([]task.Ticket, error) {
|
|
46
|
+
var issues []rawIssue
|
|
47
|
+
if err := json.Unmarshal(raw, &issues); err != nil {
|
|
48
|
+
return nil, fmt.Errorf("jira search: parse json: %w", err)
|
|
49
|
+
}
|
|
50
|
+
out := make([]task.Ticket, 0, len(issues))
|
|
51
|
+
for _, issue := range issues {
|
|
52
|
+
fields := map[string]any{
|
|
53
|
+
"status": issue.Fields.Status.Name,
|
|
54
|
+
"issueType": issue.Fields.IssueType.Name,
|
|
55
|
+
"labels": append([]string{}, issue.Fields.Labels...),
|
|
56
|
+
}
|
|
57
|
+
if issue.Fields.Assignee != nil {
|
|
58
|
+
fields["assignee"] = issue.Fields.Assignee.EmailAddress
|
|
59
|
+
}
|
|
60
|
+
out = append(out, task.Ticket{
|
|
61
|
+
ID: issue.ID,
|
|
62
|
+
Key: issue.Key,
|
|
63
|
+
Title: issue.Fields.Summary,
|
|
64
|
+
WorkflowClaims: claimLabels(issue.Fields.Labels),
|
|
65
|
+
Fields: fields,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
return out, nil
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func claimLabels(labels []string) []string {
|
|
72
|
+
var out []string
|
|
73
|
+
for _, l := range labels {
|
|
74
|
+
if len(l) > 3 && l[:3] == "wf:" {
|
|
75
|
+
out = append(out, l)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// labelsOf extracts label strings from a raw Jira issue view.
|
|
82
|
+
func labelsOf(raw []byte) ([]string, error) {
|
|
83
|
+
var issue rawIssue
|
|
84
|
+
if err := json.Unmarshal(raw, &issue); err != nil {
|
|
85
|
+
return nil, fmt.Errorf("jira view: parse json: %w", err)
|
|
86
|
+
}
|
|
87
|
+
return issue.Fields.Labels, nil
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// subtasksOf maps existing subtask titles (<ticket>:<node>) to mailboxes.
|
|
91
|
+
func subtasksOf(raw []byte) (map[string]task.Mailbox, error) {
|
|
92
|
+
var issue rawIssue
|
|
93
|
+
if err := json.Unmarshal(raw, &issue); err != nil {
|
|
94
|
+
return nil, fmt.Errorf("jira view: parse json: %w", err)
|
|
95
|
+
}
|
|
96
|
+
out := map[string]task.Mailbox{}
|
|
97
|
+
for _, st := range issue.Fields.Subtasks {
|
|
98
|
+
out[st.Fields.Summary] = task.Mailbox{ID: st.ID, Key: st.Key}
|
|
99
|
+
}
|
|
100
|
+
return out, nil
|
|
101
|
+
}
|