relay-flow 0.2.0-alpha → 0.2.2-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 (102) hide show
  1. package/README.md +171 -26
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +593 -15
  4. package/cmd/relay-flow/main.go +356 -119
  5. package/cmd/relay-flow/scenario_test.go +246 -35
  6. package/cmd/relay-flow/serve.go +5 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +88 -0
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +136 -87
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +41 -8
  16. package/internal/execution/goworkflows/engine_test.go +100 -22
  17. package/internal/execution/goworkflows/fakes_test.go +63 -25
  18. package/internal/execution/goworkflows/interpreter.go +45 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  21. package/internal/execution/goworkflows/node_runtime_test.go +72 -23
  22. package/internal/execution/goworkflows/recovery_test.go +7 -7
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  25. package/internal/harness/contract_test.go +15 -0
  26. package/internal/harness/factory.go +25 -3
  27. package/internal/harness/harness.go +30 -4
  28. package/internal/harness/opencode/opencode.go +125 -10
  29. package/internal/harness/opencode/opencode_test.go +183 -0
  30. package/internal/harness/opencode/repo_setup.go +361 -0
  31. package/internal/harness/plugin_selection_test.go +5 -5
  32. package/internal/paths/paths.go +18 -16
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/repo.go +13 -0
  35. package/internal/repo/service.go +10 -0
  36. package/internal/repo/service_test.go +55 -6
  37. package/internal/router/router.go +3 -2
  38. package/internal/router/router_test.go +87 -0
  39. package/internal/run/manager.go +14 -1
  40. package/internal/run/run.go +6 -4
  41. package/internal/run/run_manager_test.go +21 -1
  42. package/internal/runner/contract_test.go +64 -26
  43. package/internal/runner/orca/orca.go +30 -54
  44. package/internal/runner/orca/orca_test.go +143 -4
  45. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  46. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  47. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  48. package/internal/runner/runner.go +15 -8
  49. package/internal/task/auth_test.go +48 -0
  50. package/internal/task/beads/bdcli/bdcli.go +323 -0
  51. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  52. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  56. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  57. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  58. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  59. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  60. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  61. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  62. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  63. package/internal/task/beads/beads.go +840 -0
  64. package/internal/task/beads/beads_test.go +609 -0
  65. package/internal/task/beads/comments_test.go +242 -0
  66. package/internal/task/beads/config_compatibility_test.go +163 -0
  67. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  68. package/internal/task/beads/repo_composition_test.go +232 -0
  69. package/internal/task/beads/runtime_config_test.go +81 -0
  70. package/internal/task/beads/status_compatibility_test.go +233 -0
  71. package/internal/task/beads/status_test.go +257 -0
  72. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  73. package/internal/task/beads/validation_test.go +110 -0
  74. package/internal/task/contract_test.go +12 -0
  75. package/internal/task/factory.go +52 -3
  76. package/internal/task/jira/auth.go +209 -0
  77. package/internal/task/jira/auth_test.go +160 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +96 -16
  80. package/internal/task/jira/helpers_test.go +29 -19
  81. package/internal/task/jira/jira.go +263 -90
  82. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  83. package/internal/task/jira/normalize.go +32 -14
  84. package/internal/task/jira/rest/adf.go +165 -0
  85. package/internal/task/jira/rest/adf_test.go +60 -0
  86. package/internal/task/jira/rest/client.go +573 -0
  87. package/internal/task/jira/rest/client_test.go +381 -0
  88. package/internal/task/jira/templates_test.go +118 -0
  89. package/internal/task/jira/transition_defaults_test.go +22 -18
  90. package/internal/task/jira/validation_test.go +1 -1
  91. package/internal/task/task.go +32 -0
  92. package/internal/workflow/report_test.go +45 -0
  93. package/internal/workflow/workflow.go +9 -6
  94. package/internal/workflow/workflow_test.go +14 -12
  95. package/package.json +2 -1
  96. package/internal/task/jira/acli/acli.go +0 -306
  97. package/internal/task/jira/acli/acli_test.go +0 -208
  98. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  99. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  100. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  101. /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
  102. /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
@@ -1,9 +1,12 @@
1
1
  package workflow_test
2
2
 
3
3
  import (
4
+ "encoding/json"
5
+ "os"
4
6
  "strings"
5
7
  "testing"
6
8
 
9
+ "github.com/rajpopat27/relay-flow/internal/run"
7
10
  "github.com/rajpopat27/relay-flow/internal/workflow"
8
11
  )
9
12
 
@@ -39,6 +42,48 @@ func noneFeedback() workflow.Feedback {
39
42
  }
40
43
  }
41
44
 
45
+ type reportContractFixture struct {
46
+ AssistantText string `json:"assistantText"`
47
+ Envelope run.ReportRequest `json:"envelope"`
48
+ SummaryReport string `json:"summaryReport"`
49
+ FeedbackReport string `json:"feedbackReport"`
50
+ }
51
+
52
+ func reportContractFixtures(t *testing.T) map[string]reportContractFixture {
53
+ t.Helper()
54
+ b, err := os.ReadFile("../../testdata/report-contract.json")
55
+ if err != nil {
56
+ t.Fatal(err)
57
+ }
58
+ var fixtures map[string]reportContractFixture
59
+ if err := json.Unmarshal(b, &fixtures); err != nil {
60
+ t.Fatal(err)
61
+ }
62
+ return fixtures
63
+ }
64
+
65
+ func TestSharedReportContractFixtureMatchesGoValidation(t *testing.T) {
66
+ wf := parse(t, "basicFlow", minimalValid)
67
+ fixtures := reportContractFixtures(t)
68
+ for _, name := range []string{"work", "end"} {
69
+ t.Run(name, func(t *testing.T) {
70
+ fixture := fixtures[name]
71
+ if fixture.Envelope.Report.Summary.Completed == "" || fixture.Envelope.Report.Feedback.ReasonForNextStep == "" {
72
+ t.Fatal("shared report fixture must contain both summary and feedback")
73
+ }
74
+ if err := wf.ValidateReport(fixture.Envelope.Node, fixture.Envelope.Report); err != nil {
75
+ t.Fatalf("shared report fixture rejected: %v", err)
76
+ }
77
+ })
78
+ }
79
+
80
+ invalidEnd := fixtures["end"].Envelope.Report
81
+ invalidEnd.Feedback.RequiredActions = "do more work"
82
+ if err := wf.ValidateReport("coding", invalidEnd); err == nil {
83
+ t.Fatal("end fixture with feedback content accepted")
84
+ }
85
+ }
86
+
42
87
  func TestValidateReportAcceptsCompleteSuccess(t *testing.T) {
43
88
  wf := parse(t, "basicFlow", minimalValid)
44
89
  r := workflow.Report{
@@ -57,11 +57,12 @@ type Route struct {
57
57
  }
58
58
 
59
59
  type NudgeTemplateData struct {
60
- Ticket string
61
- Workflow string
62
- Repo string
63
- Node string
64
- NextSteps string
60
+ TaskSystem string
61
+ Ticket string
62
+ Workflow string
63
+ Repo string
64
+ Node string
65
+ NextSteps string
65
66
  }
66
67
 
67
68
  var namePattern = regexp.MustCompile(`^[a-z][a-zA-Z0-9]*$`)
@@ -69,7 +70,7 @@ var namePattern = regexp.MustCompile(`^[a-z][a-zA-Z0-9]*$`)
69
70
  var nudgeVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
70
71
 
71
72
  var knownNudgeVars = map[string]bool{
72
- "ticket": true, "workflow": true, "repo": true, "node": true, "nextSteps": true,
73
+ "taskSystem": true, "ticket": true, "workflow": true, "repo": true, "node": true, "nextSteps": true,
73
74
  }
74
75
 
75
76
  // Parse strictly decodes a workflow YAML document. Unknown fields and
@@ -315,6 +316,8 @@ func (w *Workflow) RenderNudge(node string, data NudgeTemplateData) (string, err
315
316
  out := nudgeVarPattern.ReplaceAllStringFunc(tmpl, func(m string) string {
316
317
  varName := nudgeVarPattern.FindStringSubmatch(m)[1]
317
318
  switch varName {
319
+ case "taskSystem":
320
+ return data.TaskSystem
318
321
  case "ticket":
319
322
  return data.Ticket
320
323
  case "workflow":
@@ -350,7 +350,7 @@ func TestValidateGraphReachability(t *testing.T) {
350
350
 
351
351
  func TestValidateNudgeTemplate(t *testing.T) {
352
352
  t.Run("supported variables", func(t *testing.T) {
353
- yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"{{ticket}} {{workflow}} {{repo}} {{node}} {{nextSteps}}\"", 1)
353
+ yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"{{taskSystem}} {{ticket}} {{workflow}} {{repo}} {{node}} {{nextSteps}}\"", 1)
354
354
  wf := parse(t, "basicFlow", yaml)
355
355
  if err := wf.Validate(); err != nil {
356
356
  t.Fatalf("supported nudge variables rejected: %v", err)
@@ -389,21 +389,23 @@ func TestCleanupRunnerOnEndDefaultsFalse(t *testing.T) {
389
389
  }
390
390
 
391
391
  func TestRenderNudge(t *testing.T) {
392
- yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"ticket={{ticket}} wf={{workflow}} repo={{repo}} node={{node}} steps={{nextSteps}}\"", 1)
392
+ yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"task={{taskSystem}} ticket={{ticket}} wf={{workflow}} repo={{repo}} node={{node}} steps={{nextSteps}}\"", 1)
393
393
  wf := parse(t, "basicFlow", yaml)
394
- out, err := wf.RenderNudge("coding", workflow.NudgeTemplateData{
395
- Ticket: "PAY-101", Workflow: "basicFlow", Repo: "payments", Node: "coding", NextSteps: "end",
396
- })
397
- if err != nil {
398
- t.Fatalf("RenderNudge failed: %v", err)
399
- }
400
- want := "ticket=PAY-101 wf=basicFlow repo=payments node=coding steps=end"
401
- if out != want {
402
- t.Fatalf("RenderNudge = %q, want %q", out, want)
394
+ for _, taskSystem := range []string{"jira", "linear"} {
395
+ out, err := wf.RenderNudge("coding", workflow.NudgeTemplateData{
396
+ TaskSystem: taskSystem, Ticket: "PAY-101", Workflow: "basicFlow", Repo: "payments", Node: "coding", NextSteps: "end",
397
+ })
398
+ if err != nil {
399
+ t.Fatalf("RenderNudge(%s) failed: %v", taskSystem, err)
400
+ }
401
+ want := "task=" + taskSystem + " ticket=PAY-101 wf=basicFlow repo=payments node=coding steps=end"
402
+ if out != want {
403
+ t.Fatalf("RenderNudge(%s) = %q, want %q", taskSystem, out, want)
404
+ }
403
405
  }
404
406
 
405
407
  wf.Nodes["coding"] = workflow.Node{Type: workflow.NodeAgent}
406
- out, err = wf.RenderNudge("coding", workflow.NudgeTemplateData{})
408
+ out, err := wf.RenderNudge("coding", workflow.NudgeTemplateData{})
407
409
  if err != nil || out != "" {
408
410
  t.Fatalf("empty RenderNudge = %q, %v; want empty", out, err)
409
411
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.2.0-alpha",
3
+ "version": "0.2.2-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "cmd",
12
+ "examples",
12
13
  "internal",
13
14
  "go.mod",
14
15
  "go.sum"
@@ -1,306 +0,0 @@
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
- }
@@ -1,208 +0,0 @@
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
- }