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,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'.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|