Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ Open a new shell and completion should work by using the `TAB` key as usual.
The OpenProject CLI commands are structured in a common, human-readable pattern. Every command is built
as `op NOUN VERB [additional information]`. You will see plenty of examples within this section.

Every command also accepts the global `--format` flag (`text`, the default, or `json`), so any command's
output can be consumed by scripts or other tools, e.g. `op work-package inspect 42 --format json`.

### Breaking change: noun-first commands

Earlier releases (up to 0.5.5) used verb-first commands. These have been replaced without aliases;
Expand Down Expand Up @@ -258,6 +261,14 @@ op work-package create --project my-project 'Document new CLI tool'

# Same command with shorthands and directly open it in a browser to continue working on it.
op work-package create -p11 'Document new CLI tool' -o

# Creating a child work package. --project is still required (it determines
# where the work package is created); --parent additionally links it as a child.
op work-package create --project 11 --parent 42 --type Task 'Draft the outline'

# --dry-run validates the input and shows the resulting plan without creating
# anything. Combine with --format json for a machine-readable plan.
op work-package create --project 11 --parent 42 'Draft the outline' --dry-run --format json
```

#### Listing
Expand Down Expand Up @@ -292,6 +303,21 @@ op work-package update 42 --subject 'The new subject' --type Implementation

# Uploading an attachment to a work package
op work-package update 42 --attach ./Downloads/Report.pdf

# Changing the status by name. The name is resolved against the work
# package's available statuses before the request is sent.
Comment on lines +307 to +308
op work-package update 42 --status 'In progress'

# Setting one or more custom fields by label or API name. --set is resolved
# against the work package's schema and cannot be combined with the flags
# above; repeat --set for multiple fields.
op work-package update 42 --set 'Story points=5' --set 'Priority=High'

# --dry-run resolves and validates the update without patching anything.
# Works with both --set and the flags above; pair with --format json for a
# machine-readable plan.
op work-package update 42 --set 'Story points=5' --dry-run --format json
op work-package update 42 --status 'Closed' --dry-run --format json
```

#### Searching
Expand All @@ -315,8 +341,22 @@ op work-package search cascade -p 11
# Accepts either a numeric ID or a project-based identifier (e.g. PROJ-123)
op work-package inspect 42
op work-package inspect PROJ-123

# --children additionally resolves the full schema (so all custom field
# values and labels are included) and lists the work package's direct
# children. Cannot be combined with --open or --types.
op work-package inspect 42 --children

# Combine with the global --format flag for machine-readable output, e.g. to
# script against the resolved custom fields or child work packages.
op work-package inspect 42 --children --format json
```

The global `--format json` flag composes with all of the flags above, so
`inspect --children`, `update --set`/`--status`, and `create --parent` can
all be scripted, and `--dry-run` plans can be validated as JSON before
anything is written.

## AI agent integration (Claude Code)

The repository ships a [`.claude/op.md`](.claude/op.md) reference file that teaches Claude Code how to use `op`. Once set up, the agent will use `op` proactively whenever you discuss work packages, projects, or anything OpenProject-related.
Expand Down
19 changes: 19 additions & 0 deletions cmd/workpackage/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ var createOpenInBrowser bool
var createTypeFlag string
var createAssigneeFlag uint64
var createDescriptionFlag string
var createParentID uint64
var createDryRun bool

var createCmd = &cobra.Command{
Use: "create [subject]",
Expand All @@ -40,6 +42,20 @@ func createWorkPackage(cmd *cobra.Command, args []string) error {
return openerrors.ErrHandled
}

if createDryRun {
if createOpenInBrowser {
printer.ErrorText("cannot use --dry-run together with --open")
return openerrors.ErrHandled
}
plan, err := work_packages.DryRunCreate(createProjectId, createOptions(cmd, subject))
if err != nil {
printer.Error(err)
return openerrors.ErrHandled
}
printer.WorkPackageCreatePlan(plan)
return nil
}

workPackage, err := work_packages.Create(createProjectId, createOptions(cmd, subject))
if err != nil {
if !stderrors.Is(err, openerrors.ErrHandled) {
Expand Down Expand Up @@ -71,5 +87,8 @@ func createOptions(cmd *cobra.Command, subject string) map[work_packages.CreateO
if cmd.Flags().Changed("description") {
options[work_packages.CreateDescription] = createDescriptionFlag
}
if createParentID > 0 {
options[work_packages.CreateParent] = strconv.FormatUint(createParentID, 10)
}
return options
}
133 changes: 133 additions & 0 deletions cmd/workpackage/create_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,144 @@
package workpackage

import (
"encoding/json"
"errors"
"net/http"
"strings"
"testing"

"github.com/spf13/cobra"

openerrors "github.com/opf/openproject-cli/components/errors"
)

// newCreateTestCmd builds a fresh *cobra.Command with the same flags
// registered on the real createCmd, bound to the shared package-level
// vars. Using a fresh command per test keeps pflag's Changed() state
// isolated between test cases.
func newCreateTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "create"}
cmd.Flags().StringVarP(&createProjectId, "project", "p", "", "")
cmd.Flags().BoolVarP(&createOpenInBrowser, "open", "o", false, "")
cmd.Flags().StringVarP(&createTypeFlag, "type", "t", "", "")
cmd.Flags().Uint64Var(&createAssigneeFlag, "assignee", 0, "")
cmd.Flags().StringVar(&createDescriptionFlag, "description", "", "")
cmd.Flags().Uint64Var(&createParentID, "parent", 0, "")
cmd.Flags().BoolVar(&createDryRun, "dry-run", false, "")
return cmd
}

func resetCreateFlags() {
createProjectId = ""
createOpenInBrowser = false
createTypeFlag = ""
createAssigneeFlag = 0
createDescriptionFlag = ""
createParentID = 0
createDryRun = false
}

func TestCreateDryRunRendersPlanWithoutPosting(t *testing.T) {
testingPrinter, requestCount := initWorkPackageTestServer(t, func(response http.ResponseWriter, request *http.Request) {
t.Fatalf("unexpected request: %s %s", request.Method, request.URL.Path)
})

resetCreateFlags()
cmd := newCreateTestCmd()
if err := cmd.Flags().Parse([]string{"--project", "1482", "--parent", "74316", "--dry-run"}); err != nil {
t.Fatal(err)
}
t.Cleanup(resetCreateFlags)

if err := createWorkPackage(cmd, []string{"Draft subject"}); err != nil {
t.Fatalf("createWorkPackage error = %v, want nil", err)
}

if *requestCount != 0 {
t.Errorf("request count = %d, want 0 (no POST for dry-run)", *requestCount)
}

var plan map[string]any
if err := json.Unmarshal([]byte(testingPrinter.Result), &plan); err != nil {
t.Fatalf("failed to parse plan output: %v", err)
}
workPackage, ok := plan["work_package"].(map[string]any)
if !ok {
t.Fatalf("expected work_package object, got %#v", plan["work_package"])
}
if workPackage["subject"] != "Draft subject" {
t.Errorf("expected plan subject %q, got %#v", "Draft subject", workPackage["subject"])
}
if plan["parent_id"] != float64(74316) {
t.Errorf("expected plan parent_id 74316, got %#v", plan["parent_id"])
}
}

func TestCreateParentIncludesParentLinkInPost(t *testing.T) {
var postBody map[string]any

testingPrinter, requestCount := initWorkPackageTestServer(t, func(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost {
t.Fatalf("unexpected request: %s %s", request.Method, request.URL.Path)
}
if err := json.NewDecoder(request.Body).Decode(&postBody); err != nil {
t.Fatal(err)
}
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write([]byte(`{"id":42,"displayId":"42","subject":"Created"}`))
})

resetCreateFlags()
cmd := newCreateTestCmd()
if err := cmd.Flags().Parse([]string{"--project", "1", "--parent", "42"}); err != nil {
t.Fatal(err)
}
t.Cleanup(resetCreateFlags)

if err := createWorkPackage(cmd, []string{"Created"}); err != nil {
t.Fatalf("createWorkPackage error = %v, want nil", err)
}

if *requestCount != 1 {
t.Errorf("request count = %d, want 1", *requestCount)
}
links, ok := postBody["_links"].(map[string]any)
if !ok {
t.Fatalf("expected links object in post body, got %#v", postBody["_links"])
}
parent, ok := links["parent"].(map[string]any)
if !ok {
t.Fatalf("expected parent link object, got %#v", links["parent"])
}
if parent["href"] != "/api/v3/work_packages/42" {
t.Errorf("expected parent href /api/v3/work_packages/42, got %#v", parent["href"])
}
if strings.Contains(testingPrinter.ErrResult, "[ERROR]") {
t.Errorf("expected no error diagnostic, got: %q", testingPrinter.ErrResult)
}
}

func TestCreateDryRunWithOpenErrors(t *testing.T) {
_, requestCount := initWorkPackageTestServer(t, func(response http.ResponseWriter, request *http.Request) {
t.Fatalf("unexpected request: %s %s", request.Method, request.URL.Path)
})

resetCreateFlags()
cmd := newCreateTestCmd()
if err := cmd.Flags().Parse([]string{"--project", "1", "--dry-run", "--open"}); err != nil {
t.Fatal(err)
}
t.Cleanup(resetCreateFlags)

err := createWorkPackage(cmd, []string{"Created"})
if !errors.Is(err, openerrors.ErrHandled) {
t.Fatalf("createWorkPackage error = %v, want ErrHandled", err)
}
if *requestCount != 0 {
t.Errorf("request count = %d, want 0", *requestCount)
}
}

func TestCreateBrowserFailureKeepsSuccessfulExit(t *testing.T) {
testingPrinter, requestCount := initWorkPackageTestServer(t, func(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost {
Expand Down
15 changes: 15 additions & 0 deletions cmd/workpackage/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

var inspectOpenInBrowser bool
var inspectListAvailableTypes bool
var inspectWithChildren bool

var inspectCmd = &cobra.Command{
Use: "inspect [id]",
Expand All @@ -34,6 +35,20 @@ func inspectWorkPackage(_ *cobra.Command, args []string) error {
return openerrors.ErrHandled
}

if inspectWithChildren {
if inspectOpenInBrowser || inspectListAvailableTypes {
printer.ErrorText("cannot use --children together with --open or --types")
return openerrors.ErrHandled
}
payload, err := work_packages.InspectWithChildren(id)
if err != nil {
printer.Error(err)
return openerrors.ErrHandled
}
printer.WorkPackageDetails(payload)
return nil
}

if inspectHasListingFlag() {
switch {
case inspectListAvailableTypes:
Expand Down
84 changes: 84 additions & 0 deletions cmd/workpackage/inspect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package workpackage

import (
"errors"
"io"
"net/http"
"strings"
"testing"
Expand Down Expand Up @@ -35,3 +36,86 @@ func TestInspectBrowserFailureReturnsError(t *testing.T) {
t.Errorf("error diagnostic count = %d, want 1; stderr: %q", count, testingPrinter.ErrResult)
}
}

func TestInspectChildrenRendersPayload(t *testing.T) {
testingPrinter, _ := initWorkPackageTestServer(t, func(response http.ResponseWriter, request *http.Request) {
response.Header().Set("Content-Type", "application/json")
switch {
case request.URL.Path == "/api/v3/work_packages/42":
_, _ = io.WriteString(response, `{
"id": 42,
"subject": "Parent",
"_links": {
"self": {"href": "/api/v3/work_packages/42"},
"project": {"href": "/api/v3/projects/1", "title": "CLI"},
"schema": {"href": "/api/v3/work_packages/schemas/1-6"},
"status": {"href": "/api/v3/statuses/1", "title": "new"},
"type": {"href": "/api/v3/types/6", "title": "Feature"}
}
}`)
case request.URL.Path == "/api/v3/work_packages/schemas/1-6":
_, _ = io.WriteString(response, `{}`)
case request.URL.Path == "/api/v3/work_packages":
if !strings.Contains(request.URL.RawQuery, "parent") {
t.Fatalf("expected parent filter in query: %s", request.URL.RawQuery)
}
_, _ = io.WriteString(response, `{
"_embedded": {
"elements": [
{
"id": 43,
"subject": "Child",
"_links": {
"type": {"title": "Task"},
"status": {"title": "new"}
}
}
]
}
}`)
default:
t.Fatalf("unexpected path: %s", request.URL.Path)
}
})

inspectWithChildren = true
t.Cleanup(func() {
inspectWithChildren = false
})

err := inspectWorkPackage(nil, []string{"42"})
if err != nil {
t.Fatalf("inspectWorkPackage error = %v, want nil", err)
}

if !strings.Contains(testingPrinter.Result, `"id": 43`) {
t.Errorf("expected output to include child row, got: %q", testingPrinter.Result)
}
if !strings.Contains(testingPrinter.Result, `"subject": "Child"`) {
t.Errorf("expected output to include child subject, got: %q", testingPrinter.Result)
}
}

func TestInspectChildrenWithOpenErrors(t *testing.T) {
testingPrinter, requestCount := initWorkPackageTestServer(t, func(response http.ResponseWriter, request *http.Request) {
t.Fatalf("unexpected request: %s", request.URL.Path)
})

inspectWithChildren = true
inspectOpenInBrowser = true
t.Cleanup(func() {
inspectWithChildren = false
inspectOpenInBrowser = false
})

err := inspectWorkPackage(nil, []string{"42"})
if !errors.Is(err, openerrors.ErrHandled) {
t.Fatalf("inspectWorkPackage error = %v, want ErrHandled", err)
}
if *requestCount != 0 {
t.Errorf("request count = %d, want 0", *requestCount)
}
if !strings.Contains(testingPrinter.ErrResult, "cannot use --children together with --open or --types") {
t.Errorf("expected error message about --children, got: %q", testingPrinter.ErrResult)
}
}
Loading
Loading