Skip to content

Commit d6ba678

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 7a0f99b commit d6ba678

13 files changed

Lines changed: 822 additions & 25 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: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,18 @@ 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"
13+
"github.com/opf/openproject-cli/models"
1214
)
1315

1416
var projectId uint64
17+
var parentWorkPackageID uint64
1518
var shouldOpenWorkPackageInBrowser bool
19+
var printCreatedWorkPackageAsJSON bool
20+
var dryRunCreateWorkPackage bool
1621
var typeFlag string
1722

1823
var createWorkPackageCmd = &cobra.Command{
@@ -24,14 +29,56 @@ var createWorkPackageCmd = &cobra.Command{
2429

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

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

@@ -54,5 +101,32 @@ func createOptions(subject string) map[work_packages.CreateOption]string {
54101
options[work_packages.CreateType] = typeFlag
55102
}
56103

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

cmd/create/work_package_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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\"}}\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+
}

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
}

cmd/update/work_package.go

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,21 @@ import (
66

77
"github.com/spf13/cobra"
88

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"
12+
"github.com/opf/openproject-cli/models"
1113
)
1214

1315
var (
14-
actionFlag string
15-
assigneeFlag uint64
16-
attachFlag string
17-
subjectFlag string
18-
typeFlag string
16+
actionFlag string
17+
assigneeFlag uint64
18+
attachFlag string
19+
dryRunUpdateWorkPackage bool
20+
printUpdatedWorkPackageAsJSON bool
21+
setFlags []string
22+
subjectFlag string
23+
typeFlag string
1924
)
2025

2126
var workPackageCmd = &cobra.Command{
@@ -34,7 +39,64 @@ func updateWorkPackage(_ *cobra.Command, args []string) {
3439

3540
id, err := strconv.ParseUint(args[0], 10, 64)
3641
if err != nil {
37-
printer.ErrorText(fmt.Sprintf("'%s' is an invalid work package id. Must be a number.", args[0]))
42+
printUpdateError("invalid_argument", fmt.Sprintf("'%s' is an invalid work package id. Must be a number.", args[0]))
43+
return
44+
}
45+
46+
if len(setFlags) > 0 {
47+
if dryRunUpdateWorkPackage {
48+
plan, err := work_packages.DryRunUpdateFields(id, setFlags)
49+
if err != nil {
50+
printUpdateError("invalid_argument", err.Error())
51+
return
52+
}
53+
54+
data, err := presenter.MarshalJSON(plan)
55+
if err != nil {
56+
printer.Error(err)
57+
return
58+
}
59+
60+
printer.Info(string(data))
61+
return
62+
}
63+
64+
if err := work_packages.UpdateFields(id, setFlags); err != nil {
65+
printUpdateError("api_error", err.Error())
66+
return
67+
}
68+
69+
if printUpdatedWorkPackageAsJSON {
70+
payload, err := work_packages.InspectWithChildren(id)
71+
if err != nil {
72+
printUpdateError("api_error", err.Error())
73+
return
74+
}
75+
payload.Children = []models.WorkPackageSummary{}
76+
77+
data, err := presenter.MarshalJSON(payload)
78+
if err != nil {
79+
printer.Error(err)
80+
return
81+
}
82+
83+
printer.Info(string(data))
84+
return
85+
}
86+
87+
workPackage, err := work_packages.Lookup(id)
88+
if err != nil {
89+
printer.Error(err)
90+
return
91+
}
92+
93+
printer.Info("-- ")
94+
printer.WorkPackage(workPackage)
95+
return
96+
}
97+
98+
if printUpdatedWorkPackageAsJSON || dryRunUpdateWorkPackage {
99+
printUpdateError("invalid_argument", "--json and --dry-run currently require at least one --set value")
38100
return
39101
}
40102

@@ -66,3 +128,18 @@ func updateOptions() map[work_packages.UpdateOption]string {
66128

67129
return options
68130
}
131+
132+
func printUpdateError(code, message string) {
133+
if !printUpdatedWorkPackageAsJSON {
134+
printer.ErrorText(message)
135+
return
136+
}
137+
138+
data, err := presenter.MarshalError(code, message)
139+
if err != nil {
140+
printer.Error(err)
141+
return
142+
}
143+
144+
printer.Info(string(data))
145+
}

0 commit comments

Comments
 (0)