Skip to content

Commit 14fc599

Browse files
committed
[#74316] add JSON work package inspect
Implement the first agent-facing CLI slice for work package inspection. Add machine-readable JSON output, include direct children on demand, preserve custom field data and schema labels, and return structured JSON errors for the inspect command. https://community.openproject.org/projects/cli/work_packages/74316
1 parent 7b2b164 commit 14fc599

14 files changed

Lines changed: 925 additions & 13 deletions

File tree

cmd/inspect/inspect.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,19 @@ func init() {
3232
"List the available types on the work package.",
3333
)
3434

35+
inspectWorkPackageCmd.Flags().BoolVar(
36+
&includeChildrenInJson,
37+
"children",
38+
false,
39+
"Include direct child work packages in the output.",
40+
)
41+
42+
inspectWorkPackageCmd.Flags().BoolVar(
43+
&printWorkPackageAsJSON,
44+
"json",
45+
false,
46+
"Print machine-readable JSON output.",
47+
)
48+
3549
RootCmd.AddCommand(inspectProjectCmd, inspectWorkPackageCmd)
3650
}

cmd/inspect/work_package.go

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@ import (
77
"github.com/spf13/cobra"
88

99
"github.com/opf/openproject-cli/components/launch"
10+
"github.com/opf/openproject-cli/components/presenter"
1011
"github.com/opf/openproject-cli/components/printer"
1112
"github.com/opf/openproject-cli/components/resources/work_packages"
1213
"github.com/opf/openproject-cli/components/routes"
14+
"github.com/opf/openproject-cli/models"
1315
)
1416

1517
var shouldOpenWorkPackageInBrowser bool
1618
var listAvailableTypes bool
19+
var includeChildrenInJson bool
20+
var printWorkPackageAsJSON bool
1721

1822
var inspectWorkPackageCmd = &cobra.Command{
1923
Use: "workpackage [id]",
@@ -25,13 +29,18 @@ var inspectWorkPackageCmd = &cobra.Command{
2529

2630
func inspectWorkPackage(_ *cobra.Command, args []string) {
2731
if len(args) != 1 {
28-
printer.ErrorText(fmt.Sprintf("Expected 1 argument [id], but got %d", len(args)))
32+
printInspectError("invalid_argument", fmt.Sprintf("Expected 1 argument [id], but got %d", len(args)))
2933
return
3034
}
3135

3236
id, err := strconv.ParseUint(args[0], 10, 64)
3337
if err != nil {
34-
printer.ErrorText(fmt.Sprintf("'%s' is an invalid work package id. Must be a number.", args[0]))
38+
printInspectError("invalid_argument", fmt.Sprintf("'%s' is an invalid work package id. Must be a number.", args[0]))
39+
return
40+
}
41+
42+
if err := validateInspectWorkPackageFlags(); err != nil {
43+
printInspectError("conflicting_arguments", err.Error())
3544
return
3645
}
3746

@@ -43,6 +52,29 @@ func inspectWorkPackage(_ *cobra.Command, args []string) {
4352
return
4453
}
4554

55+
if printWorkPackageAsJSON {
56+
var payload *models.WorkPackageInspectPayload
57+
var err error
58+
if includeChildrenInJson {
59+
payload, err = work_packages.InspectWithChildren(id)
60+
} else {
61+
payload, err = work_packages.Inspect(id)
62+
}
63+
if err != nil {
64+
printInspectError("api_error", err.Error())
65+
return
66+
}
67+
68+
data, err := presenter.MarshalJSON(payload)
69+
if err != nil {
70+
printer.Error(err)
71+
return
72+
}
73+
74+
printer.Info(string(data))
75+
return
76+
}
77+
4678
workPackage, err := work_packages.Lookup(id)
4779
if err != nil {
4880
printer.Error(err)
@@ -72,3 +104,30 @@ func listTypes(id uint64) {
72104
func hasListingFlag() bool {
73105
return listAvailableTypes
74106
}
107+
108+
func validateInspectWorkPackageFlags() error {
109+
if shouldOpenWorkPackageInBrowser && printWorkPackageAsJSON {
110+
return fmt.Errorf("cannot use --open together with --json")
111+
}
112+
113+
if listAvailableTypes && (printWorkPackageAsJSON || includeChildrenInJson) {
114+
return fmt.Errorf("cannot use --types together with --json or --children")
115+
}
116+
117+
return nil
118+
}
119+
120+
func printInspectError(code, message string) {
121+
if !printWorkPackageAsJSON {
122+
printer.ErrorText(message)
123+
return
124+
}
125+
126+
data, err := presenter.MarshalError(code, message)
127+
if err != nil {
128+
printer.Error(err)
129+
return
130+
}
131+
132+
printer.Info(string(data))
133+
}

cmd/inspect/work_package_test.go

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package inspect
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 TestInspectWorkPackagePrintsJSONWithChildren(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+
"description": {"raw": "Body"},
23+
"customField130": 3,
24+
"_embedded": {
25+
"project": {
26+
"id": 1482,
27+
"identifier": "cli",
28+
"name": "CLI"
29+
}
30+
},
31+
"_links": {
32+
"self": {"href": "/api/v3/work_packages/74316"},
33+
"project": {"href": "/api/v3/projects/1482", "title": "CLI"},
34+
"schema": {"href": "/api/v3/work_packages/schemas/1482-6"},
35+
"status": {"href": "/api/v3/statuses/1", "title": "new"},
36+
"type": {"href": "/api/v3/types/6", "title": "Feature"},
37+
"assignee": {"href": null, "title": ""}
38+
}
39+
}`)
40+
case "/api/v3/work_packages/schemas/1482-6":
41+
_, _ = io.WriteString(w, `{"customField130": {"name": "Votes", "type": "Integer", "writable": true}}`)
42+
case "/api/v3/work_packages":
43+
_, _ = io.WriteString(w, `{
44+
"_embedded": {
45+
"elements": [
46+
{
47+
"id": 74413,
48+
"subject": "Build a reusable SKILL.md based on OpenProject CLI",
49+
"_links": {
50+
"type": {"title": "Implementation"},
51+
"status": {"title": "new"},
52+
"project": {"href": "/api/v3/projects/1482", "title": "CLI"},
53+
"parent": {"href": "/api/v3/work_packages/74316", "title": "Expand op CLI to support scripted work package workflows"}
54+
}
55+
}
56+
]
57+
},
58+
"_type": "Collection",
59+
"total": 1,
60+
"count": 1,
61+
"pageSize": -1,
62+
"offset": 1
63+
}`)
64+
default:
65+
t.Fatalf("unexpected path %s", r.URL.Path)
66+
}
67+
}))
68+
defer server.Close()
69+
70+
host, err := url.Parse(server.URL)
71+
if err != nil {
72+
t.Fatal(err)
73+
}
74+
75+
requests.Init(host, "token", false)
76+
routes.Init(host)
77+
78+
activePrinter := &printer.TestingPrinter{}
79+
printer.Init(activePrinter)
80+
81+
shouldOpenWorkPackageInBrowser = false
82+
listAvailableTypes = false
83+
includeChildrenInJson = true
84+
printWorkPackageAsJSON = true
85+
86+
inspectWorkPackage(nil, []string{"74316"})
87+
88+
expected := "{\"work_package\":{\"id\":74316,\"subject\":\"Expand op CLI to support scripted work package workflows\",\"type\":\"Feature\",\"status\":\"new\",\"assignee\":\"\",\"description\":\"Body\",\"parent_id\":null,\"project\":{\"id\":1482,\"identifier\":\"cli\",\"name\":\"CLI\"},\"fields\":{\"customField130\":3},\"field_labels\":{\"Votes\":\"customField130\"}},\"children\":[{\"id\":74413,\"subject\":\"Build a reusable SKILL.md based on OpenProject CLI\",\"type\":\"Implementation\",\"status\":\"new\",\"parent_id\":74316}]}\n"
89+
if activePrinter.Result != expected {
90+
t.Fatalf("expected %s, got %s", expected, activePrinter.Result)
91+
}
92+
}
93+
94+
func TestInspectWorkPackagePrintsJSONWithoutChildrenQuery(t *testing.T) {
95+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
96+
switch r.URL.Path {
97+
case "/api/v3/work_packages/74316":
98+
_, _ = io.WriteString(w, `{
99+
"id": 74316,
100+
"subject": "Expand op CLI to support scripted work package workflows",
101+
"description": {"raw": "Body"},
102+
"customField130": 3,
103+
"_embedded": {
104+
"project": {
105+
"id": 1482,
106+
"identifier": "cli",
107+
"name": "CLI"
108+
}
109+
},
110+
"_links": {
111+
"self": {"href": "/api/v3/work_packages/74316"},
112+
"project": {"href": "/api/v3/projects/1482", "title": "CLI"},
113+
"schema": {"href": "/api/v3/work_packages/schemas/1482-6"},
114+
"status": {"href": "/api/v3/statuses/1", "title": "new"},
115+
"type": {"href": "/api/v3/types/6", "title": "Feature"},
116+
"assignee": {"href": null, "title": ""}
117+
}
118+
}`)
119+
case "/api/v3/work_packages/schemas/1482-6":
120+
_, _ = io.WriteString(w, `{"customField130": {"name": "Votes", "type": "Integer", "writable": true}}`)
121+
case "/api/v3/work_packages":
122+
t.Fatalf("unexpected children query: %s", r.URL.Path)
123+
default:
124+
t.Fatalf("unexpected path %s", r.URL.Path)
125+
}
126+
}))
127+
defer server.Close()
128+
129+
host, err := url.Parse(server.URL)
130+
if err != nil {
131+
t.Fatal(err)
132+
}
133+
134+
requests.Init(host, "token", false)
135+
routes.Init(host)
136+
137+
activePrinter := &printer.TestingPrinter{}
138+
printer.Init(activePrinter)
139+
140+
shouldOpenWorkPackageInBrowser = false
141+
listAvailableTypes = false
142+
includeChildrenInJson = false
143+
printWorkPackageAsJSON = true
144+
145+
inspectWorkPackage(nil, []string{"74316"})
146+
147+
expected := "{\"work_package\":{\"id\":74316,\"subject\":\"Expand op CLI to support scripted work package workflows\",\"type\":\"Feature\",\"status\":\"new\",\"assignee\":\"\",\"description\":\"Body\",\"parent_id\":null,\"project\":{\"id\":1482,\"identifier\":\"cli\",\"name\":\"CLI\"},\"fields\":{\"customField130\":3},\"field_labels\":{\"Votes\":\"customField130\"}},\"children\":[]}\n"
148+
if activePrinter.Result != expected {
149+
t.Fatalf("expected %s, got %s", expected, activePrinter.Result)
150+
}
151+
}
152+
153+
func TestValidateInspectWorkPackageFlagsRejectsOpenAndJSON(t *testing.T) {
154+
shouldOpenWorkPackageInBrowser = true
155+
listAvailableTypes = false
156+
includeChildrenInJson = false
157+
printWorkPackageAsJSON = true
158+
159+
err := validateInspectWorkPackageFlags()
160+
if err == nil {
161+
t.Fatal("expected validation error")
162+
}
163+
}
164+
165+
func TestValidateInspectWorkPackageFlagsRejectsTypesAndJSON(t *testing.T) {
166+
shouldOpenWorkPackageInBrowser = false
167+
listAvailableTypes = true
168+
includeChildrenInJson = false
169+
printWorkPackageAsJSON = true
170+
171+
err := validateInspectWorkPackageFlags()
172+
if err == nil {
173+
t.Fatal("expected validation error")
174+
}
175+
}
176+
177+
func TestInspectWorkPackagePrintsJSONErrorForFlagConflict(t *testing.T) {
178+
activePrinter := &printer.TestingPrinter{}
179+
printer.Init(activePrinter)
180+
181+
shouldOpenWorkPackageInBrowser = true
182+
listAvailableTypes = false
183+
includeChildrenInJson = false
184+
printWorkPackageAsJSON = true
185+
186+
inspectWorkPackage(nil, []string{"74316"})
187+
188+
expected := "{\"error\":{\"code\":\"conflicting_arguments\",\"message\":\"cannot use --open together with --json\"}}\n"
189+
if activePrinter.Result != expected {
190+
t.Fatalf("expected %s, got %s", expected, activePrinter.Result)
191+
}
192+
}
193+
194+
func TestInspectWorkPackagePrintsJSONErrorForTypesAndJSON(t *testing.T) {
195+
activePrinter := &printer.TestingPrinter{}
196+
printer.Init(activePrinter)
197+
198+
shouldOpenWorkPackageInBrowser = false
199+
listAvailableTypes = true
200+
includeChildrenInJson = false
201+
printWorkPackageAsJSON = true
202+
203+
inspectWorkPackage(nil, []string{"74316"})
204+
205+
expected := "{\"error\":{\"code\":\"conflicting_arguments\",\"message\":\"cannot use --types together with --json or --children\"}}\n"
206+
if activePrinter.Result != expected {
207+
t.Fatalf("expected %s, got %s", expected, activePrinter.Result)
208+
}
209+
}

components/presenter/json.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package presenter
2+
3+
import "encoding/json"
4+
5+
func MarshalJSON(value any) ([]byte, error) {
6+
return json.Marshal(value)
7+
}
8+
9+
func MarshalError(code, message string) ([]byte, error) {
10+
return MarshalJSON(map[string]any{
11+
"error": map[string]string{
12+
"code": code,
13+
"message": message,
14+
},
15+
})
16+
}

0 commit comments

Comments
 (0)