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
@@ -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
+ }