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.
- package/README.md +171 -26
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +593 -15
- package/cmd/relay-flow/main.go +356 -119
- package/cmd/relay-flow/scenario_test.go +246 -35
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +136 -87
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +100 -22
- package/internal/execution/goworkflows/fakes_test.go +63 -25
- package/internal/execution/goworkflows/interpreter.go +45 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +72 -23
- package/internal/execution/goworkflows/recovery_test.go +7 -7
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +15 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +183 -0
- package/internal/harness/opencode/repo_setup.go +361 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/paths/paths.go +18 -16
- package/internal/recover/recover.go +11 -6
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +55 -6
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run.go +6 -4
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -0
- package/internal/task/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +840 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +233 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +12 -0
- package/internal/task/factory.go +52 -3
- package/internal/task/jira/auth.go +209 -0
- package/internal/task/jira/auth_test.go +160 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +96 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +263 -90
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +165 -0
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +22 -18
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
- /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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
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.
|
|
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
|
-
}
|