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,306 @@
1
+ // Package acli wraps the Atlassian `acli` CLI behind a small fakeable
2
+ // interface. Every call is real; there is no dry-run mode.
3
+ //
4
+ // 9.5 external-call logging: each CLI method emits one debug line BEFORE
5
+ // the call (operation, key, transition target / jql as applicable) and one
6
+ // info line AFTER with only the outcome (ok/error), never the payload.
7
+ package acli
8
+
9
+ import (
10
+ "context"
11
+ "encoding/json"
12
+ "fmt"
13
+ "log/slog"
14
+ "os/exec"
15
+ "strings"
16
+ )
17
+
18
+ // Client is the fakeable Jira CLI seam used by the Jira task adapter.
19
+ type Client interface {
20
+ // Search runs a JQL query and returns the raw acli search JSON — a
21
+ // BARE ARRAY of issue objects (acli's wire shape, not the Jira REST
22
+ // {"issues":[...]} envelope).
23
+ Search(ctx context.Context, jql string) ([]byte, error)
24
+ // ValidateAssignee probes whether Jira recognizes the configured user.
25
+ ValidateAssignee(ctx context.Context, assignee string) error
26
+ // ValidateStatus probes whether a project exposes the configured status.
27
+ ValidateStatus(ctx context.Context, project, status string) error
28
+ // View returns the raw Jira issue JSON for one key.
29
+ View(ctx context.Context, key string) ([]byte, error)
30
+ // CreateSubtask creates a child work item and returns its id and key.
31
+ CreateSubtask(ctx context.Context, parentKey, title, description string) (id, key string, err error)
32
+ // Assign sets a work item's assignee by email or account ID.
33
+ Assign(ctx context.Context, key, assignee string) error
34
+ // Transition moves a work item to a status.
35
+ Transition(ctx context.Context, key, status string) error
36
+ // EnsureLabel adds the label when absent.
37
+ EnsureLabel(ctx context.Context, key, label string) error
38
+ // UpdateDescription replaces a work item's description.
39
+ UpdateDescription(ctx context.Context, key, description string) error
40
+ // ListComments returns the work item's comment bodies.
41
+ ListComments(ctx context.Context, key string) ([]string, error)
42
+ // AddComment posts a comment.
43
+ AddComment(ctx context.Context, key, body string) error
44
+ }
45
+
46
+ // CLI is the production Client backed by the acli binary.
47
+ type CLI struct{}
48
+
49
+ func New() *CLI { return &CLI{} }
50
+
51
+ func (CLI) Search(ctx context.Context, jql string) ([]byte, error) {
52
+ slog.Debug("jira call", "op", "search", "jql", jql)
53
+ // acli's default search fields omit labels; request every field the
54
+ // normalizer consumes (subtasks are not searchable via --fields).
55
+ out, err := runOut(ctx, "jira", "workitem", "search",
56
+ "--jql", jql,
57
+ "--fields", "key,summary,status,issuetype,labels,assignee",
58
+ "--json")
59
+ logOutcome("search", "", err)
60
+ return out, err
61
+ }
62
+
63
+ func (CLI) ValidateAssignee(ctx context.Context, assignee string) error {
64
+ slog.Debug("jira call", "op", "validate-assignee", "assignee", assignee)
65
+ jql := fmt.Sprintf("assignee = %q", assignee)
66
+ _, err := runOut(ctx, "jira", "workitem", "search", "--jql", jql, "--limit", "1", "--json")
67
+ logOutcome("validate-assignee", "", err)
68
+ if err != nil {
69
+ return fmt.Errorf("assignee %q is not a valid Jira user: %w", assignee, err)
70
+ }
71
+ return nil
72
+ }
73
+
74
+ func (CLI) ValidateStatus(ctx context.Context, project, status string) error {
75
+ slog.Debug("jira call", "op", "validate-status", "project", project, "status", status)
76
+ jql := fmt.Sprintf("project = %s AND status = %q", project, status)
77
+ _, err := runOut(ctx, "jira", "workitem", "search", "--jql", jql, "--limit", "1", "--json")
78
+ logOutcome("validate-status", "", err, "project", project, "status", status)
79
+ if err != nil {
80
+ return fmt.Errorf("status %q is not valid in project %s: %w", status, project, err)
81
+ }
82
+ return nil
83
+ }
84
+
85
+ func (CLI) View(ctx context.Context, key string) ([]byte, error) {
86
+ slog.Debug("jira call", "op", "view", "key", key)
87
+ out, err := runOut(ctx, "jira", "workitem", "view", key,
88
+ "--fields", "summary,description,status,components,issuetype,parent,labels,comment,assignee,subtasks",
89
+ "--json")
90
+ logOutcome("view", key, err)
91
+ return out, err
92
+ }
93
+
94
+ func (CLI) CreateSubtask(ctx context.Context, parentKey, title, description string) (string, string, error) {
95
+ slog.Debug("jira call", "op", "create-subtask", "key", parentKey, "title", title)
96
+ // acli requires --project alongside --summary/--type; derive it from the
97
+ // parent key's project prefix (e.g. PAY-1 -> PAY).
98
+ project := parentKey
99
+ if i := strings.Index(parentKey, "-"); i > 0 {
100
+ project = parentKey[:i]
101
+ }
102
+ out, err := runOut(ctx, "jira", "workitem", "create",
103
+ "--type", "Sub-task", "--parent", parentKey,
104
+ "--project", project,
105
+ "--summary", title, "--description", description, "--json")
106
+ if err != nil {
107
+ logOutcome("create-subtask", parentKey, err)
108
+ return "", "", err
109
+ }
110
+ var env struct {
111
+ ID string `json:"id"`
112
+ Key string `json:"key"`
113
+ }
114
+ if err := json.Unmarshal(out, &env); err != nil {
115
+ logOutcome("create-subtask", parentKey, err)
116
+ return "", "", fmt.Errorf("acli create subtask: parse json: %w", err)
117
+ }
118
+ slog.Info("jira outcome", "op", "create-subtask", "key", parentKey, "child", env.Key)
119
+ return env.ID, env.Key, nil
120
+ }
121
+
122
+ func (CLI) Assign(ctx context.Context, key, assignee string) error {
123
+ slog.Debug("jira call", "op", "assign", "key", key, "assignee", assignee)
124
+ err := runChecked(ctx, "jira", "workitem", "edit", "--key", key, "--assignee", assignee, "--yes", "--json")
125
+ logOutcome("assign", key, err)
126
+ return err
127
+ }
128
+
129
+ func (CLI) Transition(ctx context.Context, key, status string) error {
130
+ slog.Debug("jira call", "op", "transition", "key", key, "target", status)
131
+ err := runChecked(ctx, "jira", "workitem", "transition", "--key", key, "--status", status, "--yes", "--json")
132
+ logOutcome("transition", key, err, "target", status)
133
+ return err
134
+ }
135
+
136
+ func (c CLI) EnsureLabel(ctx context.Context, key, label string) error {
137
+ slog.Debug("jira call", "op", "ensure-label", "key", key, "label", label)
138
+ raw, err := c.View(ctx, key)
139
+ if err != nil {
140
+ logOutcome("ensure-label", key, err, "label", label)
141
+ return err
142
+ }
143
+ var v struct {
144
+ Fields struct {
145
+ Labels []string `json:"labels"`
146
+ } `json:"fields"`
147
+ }
148
+ if err := json.Unmarshal(raw, &v); err != nil {
149
+ logOutcome("ensure-label", key, err, "label", label)
150
+ return fmt.Errorf("acli view %s: parse labels: %w", key, err)
151
+ }
152
+ for _, l := range v.Fields.Labels {
153
+ if l == label {
154
+ slog.Info("jira outcome", "op", "ensure-label", "key", key, "label", label, "result", "already-present")
155
+ return nil
156
+ }
157
+ }
158
+ all := append(append([]string{}, v.Fields.Labels...), label)
159
+ err = runChecked(ctx, "jira", "workitem", "edit", "--key", key, "--labels", strings.Join(all, ","), "--yes", "--json")
160
+ logOutcome("ensure-label", key, err, "label", label)
161
+ return err
162
+ }
163
+
164
+ func (CLI) UpdateDescription(ctx context.Context, key, description string) error {
165
+ slog.Debug("jira call", "op", "update-description", "key", key)
166
+ err := runChecked(ctx, "jira", "workitem", "edit", "--key", key, "--description", description, "--yes", "--json")
167
+ logOutcome("update-description", key, err)
168
+ return err
169
+ }
170
+
171
+ // adfText extracts plain text from an Atlassian Document Format tree by
172
+ // concatenating every text node in document order.
173
+ func adfText(raw json.RawMessage) string {
174
+ var node struct {
175
+ Text string `json:"text"`
176
+ Content []json.RawMessage `json:"content"`
177
+ }
178
+ if err := json.Unmarshal(raw, &node); err != nil {
179
+ return ""
180
+ }
181
+ var parts []string
182
+ if node.Text != "" {
183
+ parts = append(parts, node.Text)
184
+ }
185
+ for _, child := range node.Content {
186
+ if text := adfText(child); text != "" {
187
+ parts = append(parts, text)
188
+ }
189
+ }
190
+ return strings.Join(parts, " ")
191
+ }
192
+
193
+ func (CLI) ListComments(ctx context.Context, key string) ([]string, error) {
194
+ slog.Debug("jira call", "op", "list-comments", "key", key)
195
+ raw, err := runOut(ctx, "jira", "workitem", "view", key, "--fields", "comment", "--json")
196
+ if err != nil {
197
+ logOutcome("list-comments", key, err)
198
+ return nil, err
199
+ }
200
+ var v struct {
201
+ Fields struct {
202
+ Comment struct {
203
+ Comments []struct {
204
+ Body json.RawMessage `json:"body"`
205
+ } `json:"comments"`
206
+ } `json:"comment"`
207
+ } `json:"fields"`
208
+ }
209
+ if err := json.Unmarshal(raw, &v); err != nil {
210
+ logOutcome("list-comments", key, err)
211
+ return nil, fmt.Errorf("acli comments %s: parse json: %w", key, err)
212
+ }
213
+ var out []string
214
+ for _, c := range v.Fields.Comment.Comments {
215
+ out = append(out, adfText(c.Body))
216
+ }
217
+ slog.Info("jira outcome", "op", "list-comments", "key", key, "count", len(out))
218
+ return out, nil
219
+ }
220
+
221
+ func (CLI) AddComment(ctx context.Context, key, body string) error {
222
+ slog.Debug("jira call", "op", "add-comment", "key", key)
223
+ err := runChecked(ctx, "jira", "workitem", "comment", "create", "--key", key, "--body", body, "--json")
224
+ logOutcome("add-comment", key, err)
225
+ return err
226
+ }
227
+
228
+ // logOutcome writes the one-line info record for a jira call's outcome.
229
+ // Payloads are never logged: the raw error embeds the full acli argv
230
+ // (including comment bodies, descriptions, JQL), so only the trailing
231
+ // stderr/stdout fragment after the last colon is kept. extra carries
232
+ // structured extras (e.g. the transition target).
233
+ func logOutcome(op, key string, err error, extra ...any) {
234
+ attrs := []any{"op", op}
235
+ if key != "" {
236
+ attrs = append(attrs, "key", key)
237
+ }
238
+ attrs = append(attrs, extra...)
239
+ if err != nil {
240
+ attrs = append(attrs, "result", "error", "error", sanitizeErr(err))
241
+ } else {
242
+ attrs = append(attrs, "result", "ok")
243
+ }
244
+ slog.Info("jira outcome", attrs...)
245
+ }
246
+
247
+ // sanitizeErr strips the leading "acli [args...]:" prefix from acli error
248
+ // strings so info-level outcome lines never leak argv payloads (comment
249
+ // bodies, descriptions, JQL). Keeps the wrapped exit error + trailing
250
+ // stderr fragment, which carry the actual failure reason. Handles both
251
+ // "acli [args]: ..." (runOut/runChecked) and "acli <op>: ..." (local
252
+ // wrapping) shapes.
253
+ func sanitizeErr(err error) string {
254
+ if err == nil {
255
+ return ""
256
+ }
257
+ s := err.Error()
258
+ // Strip "acli [args...]: " (runOut shape: "acli %v: %w: %s").
259
+ if strings.HasPrefix(s, "acli [") {
260
+ if i := strings.Index(s, "]: "); i >= 0 {
261
+ return s[i+3:]
262
+ }
263
+ }
264
+ // Strip "acli <op>: " (parse-error wraps).
265
+ if strings.HasPrefix(s, "acli ") {
266
+ if i := strings.Index(s, ": "); i >= 0 {
267
+ return s[i+2:]
268
+ }
269
+ }
270
+ return s
271
+ }
272
+
273
+ func runOut(ctx context.Context, args ...string) ([]byte, error) {
274
+ cmd := exec.CommandContext(ctx, "acli", args...)
275
+ var stderr strings.Builder
276
+ cmd.Stderr = &stderr
277
+ out, err := cmd.Output()
278
+ if err != nil {
279
+ return nil, fmt.Errorf("acli %v: %w: %s", args, err, strings.TrimSpace(stderr.String()))
280
+ }
281
+ return out, nil
282
+ }
283
+
284
+ // runChecked runs a mutating acli command. acli exits 0 even when the
285
+ // operation fails server-side, so the JSON envelope is inspected.
286
+ func runChecked(ctx context.Context, args ...string) error {
287
+ out, err := runOut(ctx, args...)
288
+ if err != nil {
289
+ return err
290
+ }
291
+ var env struct {
292
+ Results []struct {
293
+ Status string `json:"status"`
294
+ Message string `json:"message"`
295
+ ID string `json:"id"`
296
+ } `json:"results"`
297
+ }
298
+ if jsonErr := json.Unmarshal(out, &env); jsonErr == nil && env.Results != nil {
299
+ for _, r := range env.Results {
300
+ if !strings.EqualFold(r.Status, "SUCCESS") {
301
+ return fmt.Errorf("acli %v: %s: %s", args, r.ID, r.Message)
302
+ }
303
+ }
304
+ }
305
+ return nil
306
+ }
@@ -0,0 +1,208 @@
1
+ package acli
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ // 9.5: info-level outcome logs must never embed argv payloads. sanitizeErr
13
+ // strips the leading "acli [args...]:" prefix while keeping the wrapped
14
+ // exit error and trailing stderr fragment that carry the failure reason.
15
+ func TestSanitizeErr(t *testing.T) {
16
+ argvShape := errors.New(`acli [jira workitem comment create --key PAY-1 --body SECRET-BODY --json]: exit status 1: permission denied`)
17
+ got := sanitizeErr(argvShape)
18
+ if strings.Contains(got, "SECRET-BODY") || strings.Contains(got, "--body") {
19
+ t.Fatalf("sanitizeErr leaked argv payload: %q", got)
20
+ }
21
+ if !strings.Contains(got, "exit status 1") || !strings.Contains(got, "permission denied") {
22
+ t.Fatalf("sanitizeErr dropped failure reason: %q", got)
23
+ }
24
+
25
+ parseShape := errors.New(`acli create subtask: parse json: invalid character 'x'`)
26
+ got = sanitizeErr(parseShape)
27
+ if !strings.Contains(got, "parse json") {
28
+ t.Fatalf("sanitizeErr dropped parse context: %q", got)
29
+ }
30
+
31
+ if got := sanitizeErr(nil); got != "" {
32
+ t.Fatalf("sanitizeErr(nil) = %q, want empty", got)
33
+ }
34
+
35
+ plain := errors.New("some other error")
36
+ if got := sanitizeErr(plain); got != "some other error" {
37
+ t.Fatalf("sanitizeErr(plain) = %q, got %q", "some other error", got)
38
+ }
39
+ }
40
+
41
+ // 9.12 command-shape regression: acli's workitem create/edit take the
42
+ // description via --description (there is no --body flag on those
43
+ // subcommands); only comment create takes --body. A fake `acli` binary on
44
+ // PATH captures argv so the exact wire shape is asserted.
45
+ func TestCommandShape(t *testing.T) {
46
+ dir := t.TempDir()
47
+ argvFile := filepath.Join(dir, "argv.json")
48
+ fake := "#!/bin/sh\n" +
49
+ "for a in \"$@\"; do printf '<%s>' \"$a\"; done > " + argvFile + "\n" +
50
+ "printf '{\"id\":\"1\",\"key\":\"PAY-2\"}'\n"
51
+ bin := filepath.Join(dir, "acli")
52
+ if err := os.WriteFile(bin, []byte(fake), 0o755); err != nil {
53
+ t.Fatal(err)
54
+ }
55
+ t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
56
+
57
+ readArgv := func() string {
58
+ raw, err := os.ReadFile(argvFile)
59
+ if err != nil {
60
+ t.Fatal(err)
61
+ }
62
+ return string(raw)
63
+ }
64
+ assertShape := func(argv, wantFlag, forbidFlag string) {
65
+ if !strings.Contains(argv, wantFlag) {
66
+ t.Fatalf("argv %q missing %s", argv, wantFlag)
67
+ }
68
+ if strings.Contains(argv, forbidFlag) {
69
+ t.Fatalf("argv %q contains unsupported %s", argv, forbidFlag)
70
+ }
71
+ }
72
+
73
+ ctx := context.Background()
74
+ c := New()
75
+
76
+ if _, _, err := c.CreateSubtask(ctx, "PAY-1", "t", "d"); err != nil {
77
+ t.Fatal(err)
78
+ }
79
+ argv := readArgv()
80
+ wantCreate := "<jira><workitem><create><--type><Sub-task><--parent><PAY-1><--project><PAY><--summary><t><--description><d><--json>"
81
+ if argv != wantCreate {
82
+ t.Fatalf("create argv = %q, want %q", argv, wantCreate)
83
+ }
84
+ assertShape(argv, "<--description><d>", "--body")
85
+ // 9.14: acli rejects workitem create when --summary/--type are set
86
+ // without --project; the project is derived from the parent key prefix.
87
+ if !strings.Contains(argv, "<--project><PAY>") {
88
+ t.Fatalf("argv %q missing <--project><PAY> derived from parent key PAY-1", argv)
89
+ }
90
+ // 9.15: live GHCOS acli accepts the issue-type name "Sub-task" and
91
+ // rejects "Subtask" with its allowed issue-type list.
92
+ for _, want := range []string{"<--summary><t>", "<--type><Sub-task>", "<--parent><PAY-1>"} {
93
+ if !strings.Contains(argv, want) {
94
+ t.Fatalf("argv %q missing %s", argv, want)
95
+ }
96
+ }
97
+
98
+ if err := c.UpdateDescription(ctx, "PAY-1", "d"); err != nil {
99
+ t.Fatal(err)
100
+ }
101
+ argv = readArgv()
102
+ if want := "<jira><workitem><edit><--key><PAY-1><--description><d><--yes><--json>"; argv != want {
103
+ t.Fatalf("description argv = %q, want %q", argv, want)
104
+ }
105
+ assertShape(argv, "<--description><d>", "--body")
106
+
107
+ if err := c.Assign(ctx, "PAY-1", "reviewer@example.com"); err != nil {
108
+ t.Fatal(err)
109
+ }
110
+ argv = readArgv()
111
+ if want := "<jira><workitem><edit><--key><PAY-1><--assignee><reviewer@example.com><--yes><--json>"; argv != want {
112
+ t.Fatalf("assignment argv = %q, want %q", argv, want)
113
+ }
114
+
115
+ if err := c.AddComment(ctx, "PAY-1", "b"); err != nil {
116
+ t.Fatal(err)
117
+ }
118
+ argv = readArgv()
119
+ if want := "<jira><workitem><comment><create><--key><PAY-1><--body><b><--json>"; argv != want {
120
+ t.Fatalf("comment argv = %q, want %q", argv, want)
121
+ }
122
+ assertShape(argv, "<--body><b>", "--description")
123
+ }
124
+
125
+ func TestListCommentsParsesADF(t *testing.T) {
126
+ dir := t.TempDir()
127
+ fixture, err := filepath.Abs("testdata/acli_comments.json")
128
+ if err != nil {
129
+ t.Fatal(err)
130
+ }
131
+ fake := "#!/bin/sh\ncat " + fixture + "\n"
132
+ bin := filepath.Join(dir, "acli")
133
+ if err := os.WriteFile(bin, []byte(fake), 0o755); err != nil {
134
+ t.Fatal(err)
135
+ }
136
+ t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
137
+
138
+ comments, err := New().ListComments(context.Background(), "GHCOS-40357")
139
+ if err != nil {
140
+ t.Fatal(err)
141
+ }
142
+ if len(comments) != 1 {
143
+ t.Fatalf("comments = %v, want one", comments)
144
+ }
145
+ if want := "visit-123:summary"; !strings.Contains(comments[0], want) {
146
+ t.Fatalf("comment %q does not contain marker %q", comments[0], want)
147
+ }
148
+ }
149
+
150
+ func TestValidationUsesNonMutatingSearchAndRealOutput(t *testing.T) {
151
+ dir := t.TempDir()
152
+ argvFile := filepath.Join(dir, "argv")
153
+ success, err := filepath.Abs("testdata/search_success.json")
154
+ if err != nil {
155
+ t.Fatal(err)
156
+ }
157
+ invalidStatus, err := filepath.Abs("testdata/search_invalid_status.txt")
158
+ if err != nil {
159
+ t.Fatal(err)
160
+ }
161
+ invalidAssignee, err := filepath.Abs("testdata/search_invalid_assignee.txt")
162
+ if err != nil {
163
+ t.Fatal(err)
164
+ }
165
+ fake := "#!/bin/sh\n" +
166
+ "for a in \"$@\"; do printf '<%s>' \"$a\"; done > " + argvFile + "\n" +
167
+ "case \"$*\" in\n" +
168
+ " *missing-user*) cat " + invalidAssignee + " >&2; exit 1;;\n" +
169
+ " *DO\\ Done*) cat " + invalidStatus + " >&2; exit 1;;\n" +
170
+ " *) cat " + success + ";;\n" +
171
+ "esac\n"
172
+ bin := filepath.Join(dir, "acli")
173
+ if err := os.WriteFile(bin, []byte(fake), 0o755); err != nil {
174
+ t.Fatal(err)
175
+ }
176
+ t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
177
+
178
+ c := New()
179
+ if err := c.ValidateAssignee(context.Background(), "Raj Popat"); err != nil {
180
+ t.Fatal(err)
181
+ }
182
+ argv, err := os.ReadFile(argvFile)
183
+ if err != nil {
184
+ t.Fatal(err)
185
+ }
186
+ if got, want := string(argv), `<jira><workitem><search><--jql><assignee = "Raj Popat"><--limit><1><--json>`; got != want {
187
+ t.Fatalf("assignee argv = %q, want %q", got, want)
188
+ }
189
+ if err := c.ValidateAssignee(context.Background(), "missing-user"); err == nil || !strings.Contains(err.Error(), "user does not exist") {
190
+ t.Fatalf("invalid assignee error = %v", err)
191
+ }
192
+
193
+ if err := c.ValidateStatus(context.Background(), "PAY", "In Progress"); err != nil {
194
+ t.Fatal(err)
195
+ }
196
+ argv, err = os.ReadFile(argvFile)
197
+ if err != nil {
198
+ t.Fatal(err)
199
+ }
200
+ if got, want := string(argv), `<jira><workitem><search><--jql><project = PAY AND status = "In Progress"><--limit><1><--json>`; got != want {
201
+ t.Fatalf("status argv = %q, want %q", got, want)
202
+ }
203
+
204
+ err = c.ValidateStatus(context.Background(), "PAY", "DO Done")
205
+ if err == nil || !strings.Contains(err.Error(), "does not exist for the field 'status'") {
206
+ t.Fatalf("invalid status error = %v", err)
207
+ }
208
+ }
@@ -0,0 +1,55 @@
1
+ {
2
+ "changelog": null,
3
+ "editmeta": null,
4
+ "expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations",
5
+ "fields": {
6
+ "comment": {
7
+ "comments": [
8
+ {
9
+ "body": {
10
+ "content": [
11
+ {
12
+ "content": [
13
+ {
14
+ "text": "Summary complete.",
15
+ "type": "text"
16
+ },
17
+ {
18
+ "type": "hardBreak"
19
+ },
20
+ {
21
+ "text": "<!-- visit-123:summary -->",
22
+ "type": "text"
23
+ }
24
+ ],
25
+ "type": "paragraph"
26
+ }
27
+ ],
28
+ "type": "doc",
29
+ "version": 1
30
+ },
31
+ "created": "2026-08-25T12:04:04.133-0400",
32
+ "id": "71668",
33
+ "jsdPublic": true,
34
+ "self": "https://jira.example/rest/api/3/issue/137388/comment/71668",
35
+ "updated": "2026-08-25T12:04:04.133-0400"
36
+ }
37
+ ],
38
+ "maxResults": 1,
39
+ "self": "https://jira.example/rest/api/3/issue/137388/comment",
40
+ "startAt": 0,
41
+ "total": 1
42
+ }
43
+ },
44
+ "fieldsToInclude": null,
45
+ "id": "137388",
46
+ "key": "GHCOS-40357",
47
+ "names": null,
48
+ "operations": null,
49
+ "properties": null,
50
+ "renderedFields": null,
51
+ "schema": null,
52
+ "self": "https://jira.example/rest/api/3/issue/137388",
53
+ "transitions": null,
54
+ "versionedRepresentations": null
55
+ }
@@ -0,0 +1 @@
1
+ ✗ Error: failed to parse JQL query: user does not exist or cannot be viewed.
@@ -0,0 +1 @@
1
+ ✗ Error: failed to parse JQL query: the value 'DO Done' does not exist for the field 'status'.