Skip to content

Commit 35be9af

Browse files
committed
[#74316] add dry-run JSON WP mutations
Add parent-aware create dry-runs and schema-driven update dry-runs for work packages, both with machine-readable JSON output. Keep the legacy typed update flags intact while adding --set, --parent, --dry-run, and --json as the agent workflow surface. https://community.openproject.org/projects/cli/work_packages/74316
1 parent 4af732b commit 35be9af

18 files changed

Lines changed: 1460 additions & 45 deletions

cmd/create/create.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ func init() {
1616
0,
1717
"Project ID to create the work package in",
1818
)
19-
_ = createWorkPackageCmd.MarkFlagRequired("project")
2019

2120
createWorkPackageCmd.Flags().BoolVarP(
2221
&shouldOpenWorkPackageInBrowser,
@@ -34,5 +33,26 @@ func init() {
3433
"Change the work package type",
3534
)
3635

36+
createWorkPackageCmd.Flags().Uint64Var(
37+
&parentWorkPackageID,
38+
"parent",
39+
0,
40+
"Create the work package as a child of an existing work package",
41+
)
42+
43+
createWorkPackageCmd.Flags().BoolVar(
44+
&printCreatedWorkPackageAsJSON,
45+
"json",
46+
false,
47+
"Print machine-readable JSON output.",
48+
)
49+
50+
createWorkPackageCmd.Flags().BoolVar(
51+
&dryRunCreateWorkPackage,
52+
"dry-run",
53+
false,
54+
"Resolve and validate without persisting the work package.",
55+
)
56+
3757
RootCmd.AddCommand(createWorkPackageCmd)
3858
}

cmd/create/work_package.go

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@ import (
66
"github.com/spf13/cobra"
77

88
"github.com/opf/openproject-cli/components/launch"
9+
"github.com/opf/openproject-cli/components/presenter"
910
"github.com/opf/openproject-cli/components/printer"
1011
"github.com/opf/openproject-cli/components/resources/work_packages"
1112
"github.com/opf/openproject-cli/components/routes"
1213
)
1314

1415
var projectId uint64
16+
var parentWorkPackageID uint64
1517
var shouldOpenWorkPackageInBrowser bool
18+
var printCreatedWorkPackageAsJSON bool
19+
var dryRunCreateWorkPackage bool
1620
var typeFlag string
1721

1822
var createWorkPackageCmd = &cobra.Command{
@@ -24,14 +28,55 @@ var createWorkPackageCmd = &cobra.Command{
2428

2529
func createWorkPackage(_ *cobra.Command, args []string) {
2630
if len(args) != 1 {
27-
printer.ErrorText(fmt.Sprintf("Expected 1 argument [subject], but got %d", len(args)))
31+
printCreateError("invalid_argument", fmt.Sprintf("Expected 1 argument [subject], but got %d", len(args)))
32+
return
33+
}
34+
35+
if err := validateCreateWorkPackageFlags(); err != nil {
36+
printCreateError("conflicting_arguments", err.Error())
2837
return
2938
}
3039

3140
subject := args[0]
32-
workPackage, err := work_packages.Create(projectId, createOptions(subject))
41+
options := createOptions(subject)
42+
43+
if dryRunCreateWorkPackage {
44+
plan, err := work_packages.DryRunCreate(projectId, options)
45+
if err != nil {
46+
printCreateError("invalid_argument", err.Error())
47+
return
48+
}
49+
50+
data, err := presenter.MarshalJSON(plan)
51+
if err != nil {
52+
printer.Error(err)
53+
return
54+
}
55+
56+
printer.Info(string(data))
57+
return
58+
}
59+
60+
workPackage, err := work_packages.Create(projectId, options)
3361
if err != nil {
34-
printer.Error(err)
62+
printCreateError("api_error", err.Error())
63+
return
64+
}
65+
66+
if printCreatedWorkPackageAsJSON {
67+
payload, err := work_packages.Inspect(workPackage.Id)
68+
if err != nil {
69+
printCreateError("api_error", err.Error())
70+
return
71+
}
72+
73+
data, err := presenter.MarshalJSON(payload)
74+
if err != nil {
75+
printer.Error(err)
76+
return
77+
}
78+
79+
printer.Info(string(data))
3580
return
3681
}
3782

@@ -54,5 +99,36 @@ func createOptions(subject string) map[work_packages.CreateOption]string {
5499
options[work_packages.CreateType] = typeFlag
55100
}
56101

102+
if parentWorkPackageID > 0 {
103+
options[work_packages.CreateParent] = fmt.Sprintf("%d", parentWorkPackageID)
104+
}
105+
57106
return options
58107
}
108+
109+
func validateCreateWorkPackageFlags() error {
110+
if shouldOpenWorkPackageInBrowser && printCreatedWorkPackageAsJSON {
111+
return fmt.Errorf("cannot use --open together with --json")
112+
}
113+
114+
if dryRunCreateWorkPackage && !printCreatedWorkPackageAsJSON {
115+
return fmt.Errorf("cannot use --dry-run without --json")
116+
}
117+
118+
return nil
119+
}
120+
121+
func printCreateError(code, message string) {
122+
if !printCreatedWorkPackageAsJSON {
123+
printer.ErrorText(message)
124+
return
125+
}
126+
127+
data, err := presenter.MarshalError(code, message)
128+
if err != nil {
129+
printer.Error(err)
130+
return
131+
}
132+
133+
printer.Info(string(data))
134+
}

cmd/create/work_package_test.go

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package create
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/url"
8+
"testing"
9+
10+
"github.com/opf/openproject-cli/components/printer"
11+
"github.com/opf/openproject-cli/components/requests"
12+
"github.com/opf/openproject-cli/components/routes"
13+
)
14+
15+
func TestCreateWorkPackagePrintsDryRunJSONWithParent(t *testing.T) {
16+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
17+
switch r.URL.Path {
18+
case "/api/v3/work_packages/74316":
19+
_, _ = io.WriteString(w, `{
20+
"id": 74316,
21+
"subject": "Expand op CLI to support scripted work package workflows",
22+
"_embedded": {
23+
"project": {
24+
"id": 1482,
25+
"identifier": "cli",
26+
"name": "CLI"
27+
}
28+
},
29+
"_links": {
30+
"self": {"href": "/api/v3/work_packages/74316"},
31+
"project": {"href": "/api/v3/projects/1482", "title": "CLI"},
32+
"status": {"href": "/api/v3/statuses/1", "title": "new"},
33+
"type": {"href": "/api/v3/types/6", "title": "Feature"},
34+
"assignee": {"href": null, "title": ""}
35+
}
36+
}`)
37+
default:
38+
t.Fatalf("unexpected path %s", r.URL.Path)
39+
}
40+
}))
41+
defer server.Close()
42+
43+
host, err := url.Parse(server.URL)
44+
if err != nil {
45+
t.Fatal(err)
46+
}
47+
48+
requests.Init(host, "token", false)
49+
routes.Init(host)
50+
51+
activePrinter := &printer.TestingPrinter{}
52+
printer.Init(activePrinter)
53+
54+
projectId = 0
55+
parentWorkPackageID = 74316
56+
shouldOpenWorkPackageInBrowser = false
57+
printCreatedWorkPackageAsJSON = true
58+
dryRunCreateWorkPackage = true
59+
typeFlag = ""
60+
61+
createWorkPackage(nil, []string{"Build reusable skill"})
62+
63+
expected := "{\"valid\":true,\"operation\":\"create\",\"project_id\":1482,\"parent_id\":74316,\"work_package\":{\"subject\":\"Build reusable skill\",\"type\":\"\"}}\n"
64+
if activePrinter.Result != expected {
65+
t.Fatalf("expected %s, got %s", expected, activePrinter.Result)
66+
}
67+
}
68+
69+
func TestCreateWorkPackagePrintsJSONErrorForFlagConflict(t *testing.T) {
70+
activePrinter := &printer.TestingPrinter{}
71+
printer.Init(activePrinter)
72+
73+
projectId = 1482
74+
parentWorkPackageID = 0
75+
shouldOpenWorkPackageInBrowser = true
76+
printCreatedWorkPackageAsJSON = true
77+
dryRunCreateWorkPackage = false
78+
typeFlag = ""
79+
80+
createWorkPackage(nil, []string{"Build reusable skill"})
81+
82+
expected := "{\"error\":{\"code\":\"conflicting_arguments\",\"message\":\"cannot use --open together with --json\"}}\n"
83+
if activePrinter.Result != expected {
84+
t.Fatalf("expected %s, got %s", expected, activePrinter.Result)
85+
}
86+
}
87+
88+
func TestValidateCreateWorkPackageFlagsRejectsDryRunWithoutJSON(t *testing.T) {
89+
projectId = 1482
90+
parentWorkPackageID = 0
91+
shouldOpenWorkPackageInBrowser = false
92+
printCreatedWorkPackageAsJSON = false
93+
dryRunCreateWorkPackage = true
94+
typeFlag = ""
95+
96+
err := validateCreateWorkPackageFlags()
97+
if err == nil {
98+
t.Fatal("expected validation error")
99+
}
100+
}
101+
102+
func TestCreateWorkPackagePrintsJSONWithoutChildrenQuery(t *testing.T) {
103+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
104+
switch r.URL.Path {
105+
case "/api/v3/projects/1482":
106+
_, _ = io.WriteString(w, `{
107+
"id": 1482,
108+
"identifier": "cli",
109+
"name": "CLI",
110+
"_links": {
111+
"types": {"href": "/api/v3/projects/1482/types/available"}
112+
}
113+
}`)
114+
case "/api/v3/projects/1482/types/available":
115+
_, _ = io.WriteString(w, `{
116+
"_embedded": {
117+
"elements": [
118+
{
119+
"id": 7,
120+
"name": "Implementation",
121+
"_links": {
122+
"self": {"href": "/api/v3/types/7"}
123+
}
124+
}
125+
]
126+
}
127+
}`)
128+
case "/api/v3/projects/1482/work_packages":
129+
_, _ = io.WriteString(w, `{
130+
"id": 74415,
131+
"subject": "Build reusable skill",
132+
"_links": {
133+
"self": {"href": "/api/v3/work_packages/74415"},
134+
"project": {"href": "/api/v3/projects/1482", "title": "CLI"},
135+
"status": {"href": "/api/v3/statuses/1", "title": "new"},
136+
"type": {"href": "/api/v3/types/7", "title": "Implementation"},
137+
"assignee": {"href": null, "title": ""}
138+
},
139+
"_embedded": {
140+
"project": {
141+
"id": 1482,
142+
"identifier": "cli",
143+
"name": "CLI"
144+
}
145+
}
146+
}`)
147+
case "/api/v3/work_packages/74415":
148+
_, _ = io.WriteString(w, `{
149+
"id": 74415,
150+
"subject": "Build reusable skill",
151+
"_links": {
152+
"self": {"href": "/api/v3/work_packages/74415"},
153+
"project": {"href": "/api/v3/projects/1482", "title": "CLI"},
154+
"status": {"href": "/api/v3/statuses/1", "title": "new"},
155+
"type": {"href": "/api/v3/types/7", "title": "Implementation"},
156+
"assignee": {"href": null, "title": ""}
157+
},
158+
"_embedded": {
159+
"project": {
160+
"id": 1482,
161+
"identifier": "cli",
162+
"name": "CLI"
163+
}
164+
}
165+
}`)
166+
case "/api/v3/work_packages":
167+
t.Fatalf("unexpected children query: %s", r.URL.Path)
168+
default:
169+
t.Fatalf("unexpected path %s", r.URL.Path)
170+
}
171+
}))
172+
defer server.Close()
173+
174+
host, err := url.Parse(server.URL)
175+
if err != nil {
176+
t.Fatal(err)
177+
}
178+
179+
requests.Init(host, "token", false)
180+
routes.Init(host)
181+
182+
activePrinter := &printer.TestingPrinter{}
183+
printer.Init(activePrinter)
184+
185+
projectId = 1482
186+
parentWorkPackageID = 0
187+
shouldOpenWorkPackageInBrowser = false
188+
printCreatedWorkPackageAsJSON = true
189+
dryRunCreateWorkPackage = false
190+
typeFlag = "Implementation"
191+
192+
createWorkPackage(nil, []string{"Build reusable skill"})
193+
194+
expected := "{\"work_package\":{\"id\":74415,\"subject\":\"Build reusable skill\",\"type\":\"Implementation\",\"status\":\"new\",\"assignee\":\"\",\"description\":\"\",\"parent_id\":null,\"project\":{\"id\":1482,\"identifier\":\"cli\",\"name\":\"CLI\"},\"fields\":{},\"field_labels\":{}},\"children\":[]}\n"
195+
if activePrinter.Result != expected {
196+
t.Fatalf("expected %s, got %s", expected, activePrinter.Result)
197+
}
198+
}

cmd/update/update.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,22 @@ func addWorkPackageFlags() {
5050
"",
5151
"Change the work package type",
5252
)
53+
workPackageCmd.Flags().StringArrayVar(
54+
&setFlags,
55+
"set",
56+
nil,
57+
"Set a field using label=value or apiField=value",
58+
)
59+
workPackageCmd.Flags().BoolVar(
60+
&printUpdatedWorkPackageAsJSON,
61+
"json",
62+
false,
63+
"Print machine-readable JSON output.",
64+
)
65+
workPackageCmd.Flags().BoolVar(
66+
&dryRunUpdateWorkPackage,
67+
"dry-run",
68+
false,
69+
"Resolve and validate without persisting the update.",
70+
)
5371
}

0 commit comments

Comments
 (0)