From 125c7f5eb313b701c24d21e8278419cebe06cac4 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 25 Jul 2026 22:10:10 +0200 Subject: [PATCH 01/13] [#74415] port schema and custom field dtos Ports the schema dto, customField capture via UnmarshalJSON, and nil-safe link conversion from the pre-noun-first branch onto main. https://community.openproject.org/wp/CLI-12 --- dtos/work_package.go | 86 ++++++++++++++++++++++---------- dtos/work_package_schema.go | 39 +++++++++++++++ dtos/work_package_schema_test.go | 35 +++++++++++++ dtos/work_package_test.go | 61 ++++++++++++++++++++-- 4 files changed, 192 insertions(+), 29 deletions(-) create mode 100644 dtos/work_package_schema.go create mode 100644 dtos/work_package_schema_test.go diff --git a/dtos/work_package.go b/dtos/work_package.go index 9fa432f..ab33378 100644 --- a/dtos/work_package.go +++ b/dtos/work_package.go @@ -1,6 +1,9 @@ package dtos import ( + "encoding/json" + "strings" + "github.com/opf/openproject-cli/models" ) @@ -9,6 +12,8 @@ type WorkPackageLinksDto struct { AddAttachment *LinkDto `json:"addAttachment,omitempty"` Status *LinkDto `json:"status,omitempty"` Project *LinkDto `json:"project,omitempty"` + Parent *LinkDto `json:"parent,omitempty"` + Schema *LinkDto `json:"schema,omitempty"` Assignee *LinkDto `json:"assignee,omitempty"` Type *LinkDto `json:"type,omitempty"` CustomActions []*LinkDto `json:"customActions,omitempty"` @@ -16,17 +21,19 @@ type WorkPackageLinksDto struct { } type WorkPackageDto struct { - Id int64 `json:"id,omitempty"` - DisplayId string `json:"displayId,omitempty"` - Subject string `json:"subject,omitempty"` - Links *WorkPackageLinksDto `json:"_links,omitempty"` - Description *LongTextDto `json:"description,omitempty"` - Embedded *embeddedDto `json:"_embedded,omitempty"` - LockVersion int `json:"lockVersion"` + Id int64 `json:"id,omitempty"` + DisplayId string `json:"displayId,omitempty"` + Subject string `json:"subject,omitempty"` + Links *WorkPackageLinksDto `json:"_links,omitempty"` + Description *LongTextDto `json:"description,omitempty"` + Embedded *embeddedDto `json:"_embedded,omitempty"` + LockVersion int `json:"lockVersion"` + CustomFields map[string]any `json:"-"` } type embeddedDto struct { CustomActions []*CustomActionDto `json:"customActions"` + Project *ProjectDto `json:"project,omitempty"` } type workPackageElements struct { @@ -48,31 +55,39 @@ type CreateWorkPackageDto struct { /////////////// MODEL CONVERSION /////////////// -func (dto *WorkPackageDto) Convert() *models.WorkPackage { - var wpType, assignee, status, description string - if dto.Links != nil { - if dto.Links.Type != nil { - wpType = dto.Links.Type.Title - } - if dto.Links.Assignee != nil { - assignee = dto.Links.Assignee.Title - } - if dto.Links.Status != nil { - status = dto.Links.Status.Title - } +func (dto *WorkPackageDto) UnmarshalJSON(data []byte) error { + type alias WorkPackageDto + var base alias + if err := json.Unmarshal(data, &base); err != nil { + return err } - if dto.Description != nil { - description = dto.Description.Raw + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return err } + customFields := make(map[string]any) + for key, value := range raw { + if strings.HasPrefix(key, "customField") { + customFields[key] = value + } + } + + *dto = WorkPackageDto(base) + dto.CustomFields = customFields + return nil +} + +func (dto *WorkPackageDto) Convert() *models.WorkPackage { return &models.WorkPackage{ Id: uint64(dto.Id), DisplayId: dto.DisplayId, Subject: dto.Subject, - Type: wpType, - Assignee: assignee, - Status: status, - Description: description, + Type: linkTitle(dto.Links, func(links *WorkPackageLinksDto) *LinkDto { return links.Type }), + Assignee: linkTitle(dto.Links, func(links *WorkPackageLinksDto) *LinkDto { return links.Assignee }), + Status: linkTitle(dto.Links, func(links *WorkPackageLinksDto) *LinkDto { return links.Status }), + Description: longTextRaw(dto.Description), LockVersion: dto.LockVersion, } } @@ -92,3 +107,24 @@ func (dto *WorkPackageCollectionDto) Convert() *models.WorkPackageCollection { Items: workPackages, } } + +func linkTitle(links *WorkPackageLinksDto, selector func(*WorkPackageLinksDto) *LinkDto) string { + if links == nil { + return "" + } + + link := selector(links) + if link == nil { + return "" + } + + return link.Title +} + +func longTextRaw(description *LongTextDto) string { + if description == nil { + return "" + } + + return description.Raw +} diff --git a/dtos/work_package_schema.go b/dtos/work_package_schema.go new file mode 100644 index 0000000..6f6720b --- /dev/null +++ b/dtos/work_package_schema.go @@ -0,0 +1,39 @@ +package dtos + +import ( + "encoding/json" + "strings" +) + +type WorkPackageSchemaFieldDto struct { + Name string `json:"name"` + Type string `json:"type"` + Writable bool `json:"writable"` +} + +type WorkPackageSchemaDto struct { + Fields map[string]WorkPackageSchemaFieldDto `json:"-"` +} + +func (dto *WorkPackageSchemaDto) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + fields := make(map[string]WorkPackageSchemaFieldDto) + for key, value := range raw { + if !strings.HasPrefix(key, "customField") { + continue + } + + var field WorkPackageSchemaFieldDto + if err := json.Unmarshal(value, &field); err != nil { + return err + } + fields[key] = field + } + + dto.Fields = fields + return nil +} diff --git a/dtos/work_package_schema_test.go b/dtos/work_package_schema_test.go new file mode 100644 index 0000000..a1ff2ec --- /dev/null +++ b/dtos/work_package_schema_test.go @@ -0,0 +1,35 @@ +package dtos + +import ( + "encoding/json" + "testing" +) + +func TestWorkPackageSchemaDtoUnmarshalIgnoresNonCustomFields(t *testing.T) { + body := []byte(`{ + "subject": {"name": "Subject", "type": "String", "writable": true}, + "customField108": {"name": "Requires doc change", "type": "Boolean", "writable": true}, + "customField130": {"name": "Votes", "type": "Integer", "writable": false} + }`) + + var dto WorkPackageSchemaDto + if err := json.Unmarshal(body, &dto); err != nil { + t.Fatal(err) + } + + if len(dto.Fields) != 2 { + t.Fatalf("expected two custom fields, got %+v", dto.Fields) + } + + if _, ok := dto.Fields["subject"]; ok { + t.Fatalf("expected non-custom fields to be ignored, got %+v", dto.Fields) + } + + if !dto.Fields["customField108"].Writable { + t.Fatalf("expected customField108 to be writable, got %+v", dto.Fields["customField108"]) + } + + if dto.Fields["customField130"].Type != "Integer" { + t.Fatalf("expected customField130 type to be Integer, got %+v", dto.Fields["customField130"]) + } +} diff --git a/dtos/work_package_test.go b/dtos/work_package_test.go index 96921be..f6827e4 100644 --- a/dtos/work_package_test.go +++ b/dtos/work_package_test.go @@ -1,18 +1,16 @@ -package dtos_test +package dtos import ( "encoding/json" "strings" "testing" - - "github.com/opf/openproject-cli/dtos" ) // A freshly created work package has lockVersion 0. The PATCH body must // still contain it: omitting lockVersion makes the API respond with // 409 UpdateConflict on every update of a fresh work package. func TestWorkPackageDtoMarshalKeepsZeroLockVersion(t *testing.T) { - patch := dtos.WorkPackageDto{LockVersion: 0} + patch := WorkPackageDto{LockVersion: 0} data, err := json.Marshal(patch) if err != nil { @@ -23,3 +21,58 @@ func TestWorkPackageDtoMarshalKeepsZeroLockVersion(t *testing.T) { t.Errorf("expected marshaled patch to contain \"lockVersion\":0, got %s", string(data)) } } + +func TestWorkPackageDtoUnmarshalCapturesInspectFields(t *testing.T) { + body := []byte(`{ + "id": 74316, + "subject": "Expand op CLI to support scripted work package workflows", + "description": {"raw": "Body"}, + "customField130": 3, + "customField108": false, + "_embedded": { + "project": { + "id": 1482, + "identifier": "cli", + "name": "CLI" + } + }, + "_links": { + "self": {"href": "/api/v3/work_packages/74316"}, + "project": {"href": "/api/v3/projects/1482", "title": "CLI"}, + "parent": {"href": "/api/v3/work_packages/70000", "title": "Umbrella"}, + "schema": {"href": "/api/v3/work_packages/schemas/1482-6"}, + "status": {"href": "/api/v3/statuses/1", "title": "new"}, + "type": {"href": "/api/v3/types/6", "title": "Feature"}, + "assignee": {"href": null, "title": ""} + } + }`) + + var dto WorkPackageDto + if err := json.Unmarshal(body, &dto); err != nil { + t.Fatal(err) + } + + if dto.Embedded == nil || dto.Embedded.Project == nil { + t.Fatalf("expected embedded project, got %+v", dto.Embedded) + } + + if dto.Embedded.Project.Identifier != "cli" { + t.Fatalf("expected project identifier cli, got %q", dto.Embedded.Project.Identifier) + } + + if dto.Links == nil || dto.Links.Parent == nil || dto.Links.Parent.Href != "/api/v3/work_packages/70000" { + t.Fatalf("expected parent link to be captured, got %+v", dto.Links) + } + + if dto.Links == nil || dto.Links.Schema == nil || dto.Links.Schema.Href != "/api/v3/work_packages/schemas/1482-6" { + t.Fatalf("expected schema link to be captured, got %+v", dto.Links) + } + + if dto.CustomFields["customField130"] != float64(3) { + t.Fatalf("expected customField130 to be captured, got %#v", dto.CustomFields["customField130"]) + } + + if dto.CustomFields["customField108"] != false { + t.Fatalf("expected customField108 to be captured, got %#v", dto.CustomFields["customField108"]) + } +} From 5c051294c4921508caff0931b103b08f4920e518 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 25 Jul 2026 22:14:39 +0200 Subject: [PATCH 02/13] [#74415] add work package details and plan models Inspect payload, summary, and dry-run plan models. Plan IDs are strings to match semantic identifier support. https://community.openproject.org/wp/CLI-12 --- models/work_package_details.go | 42 +++++++++++++++++++++++++++++++++ models/work_package_mutation.go | 34 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 models/work_package_details.go create mode 100644 models/work_package_mutation.go diff --git a/models/work_package_details.go b/models/work_package_details.go new file mode 100644 index 0000000..7f4db15 --- /dev/null +++ b/models/work_package_details.go @@ -0,0 +1,42 @@ +package models + +type ProjectRef struct { + ID uint64 `json:"id"` + Identifier string `json:"identifier"` + Name string `json:"name"` +} + +type WorkPackageSummary struct { + ID uint64 `json:"id"` + Subject string `json:"subject"` + Type string `json:"type"` + Status string `json:"status"` + ParentID *uint64 `json:"parent_id"` +} + +type WorkPackageDetails struct { + ID uint64 `json:"id"` + Subject string `json:"subject"` + Type string `json:"type"` + Status string `json:"status"` + Assignee string `json:"assignee"` + Description string `json:"description"` + ParentID *uint64 `json:"parent_id"` + Project ProjectRef `json:"project"` + Fields map[string]any `json:"fields"` + FieldLabels map[string][]string `json:"field_labels"` +} + +type WorkPackageInspectPayload struct { + WorkPackage WorkPackageDetails `json:"work_package"` + Children []WorkPackageSummary `json:"children"` +} + +type ErrorPayload struct { + Error ErrorDetails `json:"error"` +} + +type ErrorDetails struct { + Code string `json:"code"` + Message string `json:"message"` +} diff --git a/models/work_package_mutation.go b/models/work_package_mutation.go new file mode 100644 index 0000000..b180625 --- /dev/null +++ b/models/work_package_mutation.go @@ -0,0 +1,34 @@ +package models + +type WorkPackageDraft struct { + Subject string `json:"subject"` + Type string `json:"type"` + Description string `json:"description"` +} + +type WorkPackageCreatePlan struct { + Valid bool `json:"valid"` + Operation string `json:"operation"` + ProjectID string `json:"project_id"` + ParentID *uint64 `json:"parent_id"` + WorkPackage WorkPackageDraft `json:"work_package"` +} + +type ResolvedField struct { + APIField string `json:"api_field"` + Value any `json:"value"` +} + +type WorkPackageUpdatePlan struct { + Valid bool `json:"valid"` + Operation string `json:"operation"` + WorkPackageID string `json:"work_package_id"` + Subject string `json:"subject,omitempty"` + Type string `json:"type,omitempty"` + Assignee string `json:"assignee,omitempty"` + Status string `json:"status,omitempty"` + Description *string `json:"description,omitempty"` + Action string `json:"action,omitempty"` + Attach string `json:"attach,omitempty"` + ResolvedFields map[string]ResolvedField `json:"resolved_fields"` +} From 3f77c2e3315ea80c2cae02b73624df8ab58ebcd9 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 25 Jul 2026 22:18:00 +0200 Subject: [PATCH 03/13] [#74415] add schema fetch and field resolution Schema-driven custom field resolution: label or API-name lookup, writability check, and type coercion for String, Formattable, Integer, Float, Boolean, and Date fields. https://community.openproject.org/wp/CLI-12 --- .../work_packages/field_resolution.go | 128 ++++++++++++++++++ .../work_packages/field_resolution_test.go | 89 ++++++++++++ components/resources/work_packages/schema.go | 56 ++++++++ .../resources/work_packages/schema_test.go | 64 +++++++++ 4 files changed, 337 insertions(+) create mode 100644 components/resources/work_packages/field_resolution.go create mode 100644 components/resources/work_packages/field_resolution_test.go create mode 100644 components/resources/work_packages/schema.go create mode 100644 components/resources/work_packages/schema_test.go diff --git a/components/resources/work_packages/field_resolution.go b/components/resources/work_packages/field_resolution.go new file mode 100644 index 0000000..1c36e2a --- /dev/null +++ b/components/resources/work_packages/field_resolution.go @@ -0,0 +1,128 @@ +package work_packages + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/opf/openproject-cli/models" +) + +var ( + ErrInvalidFieldAssignment = errors.New("invalid field assignment") + ErrAmbiguousField = errors.New("ambiguous field") + ErrDuplicateField = errors.New("duplicate field") + ErrInvalidFieldValue = errors.New("invalid field value") + ErrNonWritableField = errors.New("non-writable field") + ErrUnknownField = errors.New("unknown field") + ErrUnsupportedFieldType = errors.New("unsupported field type") +) + +func resolveFieldAssignments(schema *Schema, assignments []string) (map[string]models.ResolvedField, error) { + resolved := make(map[string]models.ResolvedField, len(assignments)) + seenAPIFields := make(map[string]struct{}, len(assignments)) + + for _, assignment := range assignments { + key, rawValue, ok := strings.Cut(assignment, "=") + if !ok { + return nil, fmt.Errorf("%w: %q, expected key=value", ErrInvalidFieldAssignment, assignment) + } + + if _, exists := resolved[key]; exists { + return nil, fmt.Errorf("%w: %q", ErrDuplicateField, key) + } + + field, err := resolveSchemaField(schema, key) + if err != nil { + return nil, err + } + + if !field.Writable { + return nil, fmt.Errorf("%w: %q", ErrNonWritableField, field.APIName) + } + + if _, exists := seenAPIFields[field.APIName]; exists { + return nil, fmt.Errorf("%w: %q", ErrDuplicateField, field.APIName) + } + + value, err := coerceFieldValue(field, rawValue) + if err != nil { + return nil, err + } + + resolved[key] = models.ResolvedField{ + APIField: field.APIName, + Value: value, + } + seenAPIFields[field.APIName] = struct{}{} + } + + return resolved, nil +} + +func resolveSchemaField(schema *Schema, key string) (*SchemaField, error) { + var exactAPIField *SchemaField + var labelMatches []SchemaField + + for _, field := range schema.Fields { + if field.APIName == key { + fieldCopy := field + exactAPIField = &fieldCopy + break + } + + if strings.EqualFold(field.Label, key) { + labelMatches = append(labelMatches, field) + } + } + + if exactAPIField != nil { + return exactAPIField, nil + } + + switch len(labelMatches) { + case 1: + fieldCopy := labelMatches[0] + return &fieldCopy, nil + case 0: + return nil, fmt.Errorf("%w: %q", ErrUnknownField, key) + default: + return nil, fmt.Errorf("%w: %q", ErrAmbiguousField, key) + } +} + +func coerceFieldValue(field *SchemaField, raw string) (any, error) { + switch field.Type { + case "String": + return raw, nil + case "Formattable": + return map[string]any{"raw": raw}, nil + case "Integer": + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return nil, fmt.Errorf("%w: invalid integer value %q for %s", ErrInvalidFieldValue, raw, field.Label) + } + return value, nil + case "Float": + value, err := strconv.ParseFloat(raw, 64) + if err != nil { + return nil, fmt.Errorf("%w: invalid float value %q for %s", ErrInvalidFieldValue, raw, field.Label) + } + return value, nil + case "Boolean": + value, err := strconv.ParseBool(raw) + if err != nil { + return nil, fmt.Errorf("%w: invalid boolean value %q for %s", ErrInvalidFieldValue, raw, field.Label) + } + return value, nil + case "Date": + if _, err := time.Parse("2006-01-02", raw); err != nil { + return nil, fmt.Errorf("%w: invalid date value %q for %s", ErrInvalidFieldValue, raw, field.Label) + } + return raw, nil + default: + return nil, fmt.Errorf("%w: %s for %s", ErrUnsupportedFieldType, field.Type, field.Label) + } +} diff --git a/components/resources/work_packages/field_resolution_test.go b/components/resources/work_packages/field_resolution_test.go new file mode 100644 index 0000000..ccebf4a --- /dev/null +++ b/components/resources/work_packages/field_resolution_test.go @@ -0,0 +1,89 @@ +package work_packages + +import ( + "errors" + "testing" +) + +func TestResolveFieldAssignmentsByLabel(t *testing.T) { + schema := &Schema{ + Fields: []SchemaField{ + {APIName: "customField130", Label: "Votes", Type: "Integer", Writable: true}, + }, + } + + resolved, err := resolveFieldAssignments(schema, []string{"Votes=3"}) + if err != nil { + t.Fatal(err) + } + + field := resolved["Votes"] + if field.APIField != "customField130" || field.Value != int64(3) { + t.Fatalf("unexpected resolution: %+v", field) + } +} + +func TestResolveFieldAssignmentsRejectsAmbiguousLabels(t *testing.T) { + schema := &Schema{ + Fields: []SchemaField{ + {APIName: "customField17", Label: "KPI", Type: "Integer", Writable: true}, + {APIName: "customField22", Label: "KPI", Type: "Integer", Writable: true}, + }, + } + + if _, err := resolveFieldAssignments(schema, []string{"KPI=42"}); err == nil { + t.Fatal("expected ambiguous field error") + } else if !errors.Is(err, ErrAmbiguousField) { + t.Fatalf("expected ErrAmbiguousField, got %v", err) + } +} + +func TestResolveFieldAssignmentsRejectsNonWritableFields(t *testing.T) { + schema := &Schema{ + Fields: []SchemaField{ + {APIName: "customField130", Label: "Votes", Type: "Integer", Writable: false}, + }, + } + + if _, err := resolveFieldAssignments(schema, []string{"Votes=3"}); err == nil { + t.Fatal("expected non-writable field error") + } else if !errors.Is(err, ErrNonWritableField) { + t.Fatalf("expected ErrNonWritableField, got %v", err) + } +} + +func TestResolveFieldAssignmentsRejectsDuplicateAPIFields(t *testing.T) { + schema := &Schema{ + Fields: []SchemaField{ + {APIName: "customField130", Label: "Votes", Type: "Integer", Writable: true}, + }, + } + + if _, err := resolveFieldAssignments(schema, []string{"Votes=3", "customField130=4"}); err == nil { + t.Fatal("expected duplicate field error") + } else if !errors.Is(err, ErrDuplicateField) { + t.Fatalf("expected ErrDuplicateField, got %v", err) + } +} + +func TestResolveFieldAssignmentsCoercesFormattableFields(t *testing.T) { + schema := &Schema{ + Fields: []SchemaField{ + {APIName: "customField401", Label: "Acceptance criteria", Type: "Formattable", Writable: true}, + }, + } + + resolved, err := resolveFieldAssignments(schema, []string{"Acceptance criteria=Line one\nLine two"}) + if err != nil { + t.Fatal(err) + } + + field := resolved["Acceptance criteria"] + value, ok := field.Value.(map[string]any) + if !ok { + t.Fatalf("expected formattable value map, got %#v", field.Value) + } + if value["raw"] != "Line one\nLine two" { + t.Fatalf("expected raw long text, got %#v", value["raw"]) + } +} diff --git a/components/resources/work_packages/schema.go b/components/resources/work_packages/schema.go new file mode 100644 index 0000000..549cded --- /dev/null +++ b/components/resources/work_packages/schema.go @@ -0,0 +1,56 @@ +package work_packages + +import ( + "sort" + + "github.com/opf/openproject-cli/components/parser" + "github.com/opf/openproject-cli/components/requests" + "github.com/opf/openproject-cli/dtos" +) + +type SchemaField struct { + APIName string + Label string + Type string + Writable bool +} + +type Schema struct { + Fields []SchemaField +} + +func SchemaFor(workPackage *dtos.WorkPackageDto) (*Schema, error) { + if workPackage == nil || workPackage.Links == nil || workPackage.Links.Schema == nil { + return &Schema{}, nil + } + + response, err := requests.Get(workPackage.Links.Schema.Href, nil) + if err != nil { + return nil, err + } + + dto := parser.Parse[dtos.WorkPackageSchemaDto](response) + fields := make([]SchemaField, 0, len(dto.Fields)) + for apiName, field := range dto.Fields { + fields = append(fields, SchemaField{ + APIName: apiName, + Label: field.Name, + Type: field.Type, + Writable: field.Writable, + }) + } + + sort.Slice(fields, func(i, j int) bool { + return fields[i].APIName < fields[j].APIName + }) + + return &Schema{Fields: fields}, nil +} + +func (schema *Schema) fieldLabels() map[string][]string { + labels := make(map[string][]string) + for _, field := range schema.Fields { + labels[field.Label] = append(labels[field.Label], field.APIName) + } + return labels +} diff --git a/components/resources/work_packages/schema_test.go b/components/resources/work_packages/schema_test.go new file mode 100644 index 0000000..7d766a6 --- /dev/null +++ b/components/resources/work_packages/schema_test.go @@ -0,0 +1,64 @@ +package work_packages_test + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/opf/openproject-cli/components/requests" + "github.com/opf/openproject-cli/components/resources/work_packages" + "github.com/opf/openproject-cli/dtos" +) + +func TestSchemaForWithoutSchemaLinkReturnsEmptySchema(t *testing.T) { + schema, err := work_packages.SchemaFor(&dtos.WorkPackageDto{}) + if err != nil { + t.Fatal(err) + } + + if len(schema.Fields) != 0 { + t.Fatalf("expected empty schema, got %+v", schema.Fields) + } +} + +func TestSchemaForSortsCustomFields(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/work_packages/schemas/1482-6": + _, _ = io.WriteString(w, `{ + "customField130": {"name": "Votes", "type": "Integer", "writable": true}, + "customField108": {"name": "Requires doc change", "type": "Boolean", "writable": true}, + "subject": {"name": "Subject", "type": "String", "writable": true} + }`) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + requests.Init(host, "token", false) + + schema, err := work_packages.SchemaFor(&dtos.WorkPackageDto{ + Links: &dtos.WorkPackageLinksDto{ + Schema: &dtos.LinkDto{Href: "/api/v3/work_packages/schemas/1482-6"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + if len(schema.Fields) != 2 { + t.Fatalf("expected two custom fields, got %+v", schema.Fields) + } + + if schema.Fields[0].APIName != "customField108" || schema.Fields[1].APIName != "customField130" { + t.Fatalf("expected sorted fields, got %+v", schema.Fields) + } +} From fd37ea5f4f6b61ab6f369e217cfa738b5bbafb8d Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 25 Jul 2026 22:21:45 +0200 Subject: [PATCH 04/13] [#74415] add work package inspect payload Inspect returns a details payload with project, parent, custom fields, and field labels; InspectWithChildren adds direct children via the parent filter. IDs are semantic-identifier strings. https://community.openproject.org/wp/CLI-12 --- components/resources/work_packages/details.go | 131 +++++++++ .../resources/work_packages/details_test.go | 266 ++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 components/resources/work_packages/details.go create mode 100644 components/resources/work_packages/details_test.go diff --git a/components/resources/work_packages/details.go b/components/resources/work_packages/details.go new file mode 100644 index 0000000..cbdb978 --- /dev/null +++ b/components/resources/work_packages/details.go @@ -0,0 +1,131 @@ +package work_packages + +import ( + "github.com/opf/openproject-cli/components/parser" + "github.com/opf/openproject-cli/components/paths" + "github.com/opf/openproject-cli/components/requests" + "github.com/opf/openproject-cli/dtos" + "github.com/opf/openproject-cli/models" +) + +func Inspect(id string) (*models.WorkPackageInspectPayload, error) { + workPackage, err := fetch(id) + if err != nil { + return nil, err + } + + schema, err := SchemaFor(workPackage) + if err != nil { + return nil, err + } + + return &models.WorkPackageInspectPayload{ + WorkPackage: workPackageDetails(workPackage, schema), + Children: []models.WorkPackageSummary{}, + }, nil +} + +func InspectWithChildren(id string) (*models.WorkPackageInspectPayload, error) { + payload, err := Inspect(id) + if err != nil { + return nil, err + } + + children, err := children(id) + if err != nil { + return nil, err + } + + payload.Children = children + return payload, nil +} + +func children(parentID string) ([]models.WorkPackageSummary, error) { + query := requests.NewUnpaginatedQuery(nil, []requests.Filter{ParentFilter(parentID)}) + response, err := requests.Get(paths.WorkPackages(), &query) + if err != nil { + return nil, err + } + + collection := parser.Parse[dtos.WorkPackageCollectionDto](response) + items := make([]models.WorkPackageSummary, 0, len(collection.Embedded.Elements)) + for _, element := range collection.Embedded.Elements { + items = append(items, workPackageSummary(element)) + } + + return items, nil +} + +func workPackageDetails(dto *dtos.WorkPackageDto, schema *Schema) models.WorkPackageDetails { + project := models.ProjectRef{} + if dto.Embedded != nil && dto.Embedded.Project != nil { + project = models.ProjectRef{ + ID: uint64(dto.Embedded.Project.Id), + Identifier: dto.Embedded.Project.Identifier, + Name: dto.Embedded.Project.Name, + } + } else if dto.Links != nil && dto.Links.Project != nil { + project = models.ProjectRef{ + ID: parser.IdFromLink(dto.Links.Project.Href), + Name: dto.Links.Project.Title, + } + } + + return models.WorkPackageDetails{ + ID: uint64(dto.Id), + Subject: dto.Subject, + Type: linkTitle(dto.Links, func(links *dtos.WorkPackageLinksDto) *dtos.LinkDto { return links.Type }), + Status: linkTitle(dto.Links, func(links *dtos.WorkPackageLinksDto) *dtos.LinkDto { return links.Status }), + Assignee: linkTitle(dto.Links, func(links *dtos.WorkPackageLinksDto) *dtos.LinkDto { return links.Assignee }), + Description: longTextRaw(dto.Description), + ParentID: linkID(dto.Links, func(links *dtos.WorkPackageLinksDto) *dtos.LinkDto { return links.Parent }), + Project: project, + Fields: dto.CustomFields, + FieldLabels: schema.fieldLabels(), + } +} + +func workPackageSummary(dto *dtos.WorkPackageDto) models.WorkPackageSummary { + return models.WorkPackageSummary{ + ID: uint64(dto.Id), + Subject: dto.Subject, + Type: linkTitle(dto.Links, func(links *dtos.WorkPackageLinksDto) *dtos.LinkDto { return links.Type }), + Status: linkTitle(dto.Links, func(links *dtos.WorkPackageLinksDto) *dtos.LinkDto { return links.Status }), + ParentID: linkID(dto.Links, func(links *dtos.WorkPackageLinksDto) *dtos.LinkDto { return links.Parent }), + } +} + +func linkTitle(links *dtos.WorkPackageLinksDto, selector func(*dtos.WorkPackageLinksDto) *dtos.LinkDto) string { + if links == nil { + return "" + } + + link := selector(links) + if link == nil { + return "" + } + + return link.Title +} + +func longTextRaw(description *dtos.LongTextDto) string { + if description == nil { + return "" + } + + return description.Raw +} + +func linkID(links *dtos.WorkPackageLinksDto, selector func(*dtos.WorkPackageLinksDto) *dtos.LinkDto) *uint64 { + if links == nil { + return nil + } + + link := selector(links) + if link == nil || link.Href == "" { + return nil + } + + id := parser.IdFromLink(link.Href) + return &id +} diff --git a/components/resources/work_packages/details_test.go b/components/resources/work_packages/details_test.go new file mode 100644 index 0000000..29dce1a --- /dev/null +++ b/components/resources/work_packages/details_test.go @@ -0,0 +1,266 @@ +package work_packages_test + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "sort" + "strings" + "testing" + + "github.com/opf/openproject-cli/components/requests" + "github.com/opf/openproject-cli/components/resources/work_packages" +) + +func TestInspectWithChildren(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v3/work_packages/74316": + _, _ = io.WriteString(w, `{ + "id": 74316, + "subject": "Expand op CLI to support scripted work package workflows", + "description": {"raw": "Body"}, + "customField130": 3, + "_embedded": { + "project": { + "id": 1482, + "identifier": "cli", + "name": "CLI" + } + }, + "_links": { + "self": {"href": "/api/v3/work_packages/74316"}, + "project": {"href": "/api/v3/projects/1482", "title": "CLI"}, + "schema": {"href": "/api/v3/work_packages/schemas/1482-6"}, + "status": {"href": "/api/v3/statuses/1", "title": "new"}, + "type": {"href": "/api/v3/types/6", "title": "Feature"}, + "assignee": {"href": null, "title": ""} + } + }`) + case r.URL.Path == "/api/v3/work_packages/schemas/1482-6": + _, _ = io.WriteString(w, `{ + "customField130": {"name": "Votes", "type": "Integer", "writable": true} + }`) + case r.URL.Path == "/api/v3/work_packages": + if !strings.Contains(r.URL.RawQuery, "parent") { + t.Fatalf("expected parent filter in query: %s", r.URL.RawQuery) + } + _, _ = io.WriteString(w, `{ + "_embedded": { + "elements": [ + { + "id": 74413, + "subject": "Build a reusable SKILL.md based on OpenProject CLI", + "_links": { + "type": {"title": "Implementation"}, + "status": {"title": "new"}, + "project": {"href": "/api/v3/projects/1482", "title": "CLI"}, + "parent": {"href": "/api/v3/work_packages/74316", "title": "Expand op CLI to support scripted work package workflows"} + } + } + ] + }, + "_type": "Collection", + "total": 1, + "count": 1, + "pageSize": -1, + "offset": 1 + }`) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + requests.Init(host, "token", false) + + payload, err := work_packages.InspectWithChildren("74316") + if err != nil { + t.Fatal(err) + } + + if payload.WorkPackage.Project.Identifier != "cli" { + t.Fatalf("expected project identifier cli, got %q", payload.WorkPackage.Project.Identifier) + } + + if payload.WorkPackage.Fields["customField130"] != float64(3) { + t.Fatalf("expected custom field value 3, got %#v", payload.WorkPackage.Fields["customField130"]) + } + + labels := payload.WorkPackage.FieldLabels["Votes"] + if len(labels) != 1 || labels[0] != "customField130" { + t.Fatalf("expected Votes label mapping, got %#v", payload.WorkPackage.FieldLabels) + } + + if len(payload.Children) != 1 { + t.Fatalf("expected one child, got %d", len(payload.Children)) + } + + if payload.Children[0].ID != 74413 { + t.Fatalf("expected child 74413, got %+v", payload.Children[0]) + } +} + +func TestInspectDoesNotQueryChildren(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/work_packages/74316": + _, _ = io.WriteString(w, `{ + "id": 74316, + "subject": "Expand op CLI to support scripted work package workflows", + "description": {"raw": "Body"}, + "customField130": 3, + "_embedded": { + "project": { + "id": 1482, + "identifier": "cli", + "name": "CLI" + } + }, + "_links": { + "self": {"href": "/api/v3/work_packages/74316"}, + "project": {"href": "/api/v3/projects/1482", "title": "CLI"}, + "schema": {"href": "/api/v3/work_packages/schemas/1482-6"}, + "status": {"href": "/api/v3/statuses/1", "title": "new"}, + "type": {"href": "/api/v3/types/6", "title": "Feature"}, + "assignee": {"href": null, "title": ""} + } + }`) + case "/api/v3/work_packages/schemas/1482-6": + _, _ = io.WriteString(w, `{ + "customField130": {"name": "Votes", "type": "Integer", "writable": true} + }`) + case "/api/v3/work_packages": + t.Fatalf("unexpected children query: %s", r.URL.Path) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + requests.Init(host, "token", false) + + payload, err := work_packages.Inspect("74316") + if err != nil { + t.Fatal(err) + } + + if len(payload.Children) != 0 { + t.Fatalf("expected no children, got %+v", payload.Children) + } +} + +func TestInspectReturnsErrorForMissingWorkPackage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"message":"not found"}`) + })) + defer server.Close() + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + requests.Init(host, "token", false) + + _, err = work_packages.Inspect("999999") + if err == nil { + t.Fatal("expected error for missing work package, got nil") + } +} + +func TestInspectReturnsErrorWhenSchemaFetchFails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/work_packages/74316": + _, _ = io.WriteString(w, `{ + "id": 74316, + "subject": "Any", + "_links": { + "self": {"href": "/api/v3/work_packages/74316"}, + "project": {"href": "/api/v3/projects/1482", "title": "CLI"}, + "schema": {"href": "/api/v3/work_packages/schemas/1482-6"}, + "status": {"href": "/api/v3/statuses/1", "title": "new"}, + "type": {"href": "/api/v3/types/6", "title": "Feature"}, + "assignee": {"href": null, "title": ""} + } + }`) + case "/api/v3/work_packages/schemas/1482-6": + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"message":"boom"}`) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + requests.Init(host, "token", false) + + _, err = work_packages.Inspect("74316") + if err == nil { + t.Fatal("expected error when schema endpoint fails, got nil") + } +} + +func TestInspectPreservesDuplicateFieldLabels(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/work_packages/74316": + _, _ = io.WriteString(w, `{ + "id": 74316, + "subject": "Any", + "_links": { + "self": {"href": "/api/v3/work_packages/74316"}, + "project": {"href": "/api/v3/projects/1482", "title": "CLI"}, + "schema": {"href": "/api/v3/work_packages/schemas/1482-6"}, + "status": {"href": "/api/v3/statuses/1", "title": "new"}, + "type": {"href": "/api/v3/types/6", "title": "Feature"}, + "assignee": {"href": null, "title": ""} + } + }`) + case "/api/v3/work_packages/schemas/1482-6": + _, _ = io.WriteString(w, `{ + "customField17": {"name": "KPI", "type": "Integer", "writable": true}, + "customField22": {"name": "KPI", "type": "Integer", "writable": true} + }`) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + requests.Init(host, "token", false) + + payload, err := work_packages.Inspect("74316") + if err != nil { + t.Fatal(err) + } + + labels := payload.WorkPackage.FieldLabels["KPI"] + sort.Strings(labels) + if len(labels) != 2 || labels[0] != "customField17" || labels[1] != "customField22" { + t.Fatalf("expected duplicate KPI mappings, got %#v", payload.WorkPackage.FieldLabels) + } +} From cb647d743c5dd016d1538972a99390160df5b9c3 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 25 Jul 2026 22:25:03 +0200 Subject: [PATCH 05/13] [#74415] add schema-driven custom field updates UpdateFields patches resolved custom field assignments with the current lockVersion; DryRunUpdateFields returns the validated plan without writing. https://community.openproject.org/wp/CLI-12 --- .../resources/work_packages/update_fields.go | 68 ++++++++++ .../work_packages/update_fields_test.go | 117 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 components/resources/work_packages/update_fields.go create mode 100644 components/resources/work_packages/update_fields_test.go diff --git a/components/resources/work_packages/update_fields.go b/components/resources/work_packages/update_fields.go new file mode 100644 index 0000000..e9d8b03 --- /dev/null +++ b/components/resources/work_packages/update_fields.go @@ -0,0 +1,68 @@ +package work_packages + +import ( + "bytes" + "encoding/json" + + "github.com/opf/openproject-cli/components/requests" + "github.com/opf/openproject-cli/models" +) + +func DryRunUpdateFields(id string, assignments []string) (*models.WorkPackageUpdatePlan, error) { + workPackage, err := fetch(id) + if err != nil { + return nil, err + } + + schema, err := SchemaFor(workPackage) + if err != nil { + return nil, err + } + + resolved, err := resolveFieldAssignments(schema, assignments) + if err != nil { + return nil, err + } + + return &models.WorkPackageUpdatePlan{ + Valid: true, + Operation: "update", + WorkPackageID: id, + ResolvedFields: resolved, + }, nil +} + +func UpdateFields(id string, assignments []string) error { + workPackage, err := fetch(id) + if err != nil { + return err + } + + schema, err := SchemaFor(workPackage) + if err != nil { + return err + } + + resolved, err := resolveFieldAssignments(schema, assignments) + if err != nil { + return err + } + + patch := map[string]any{ + "lockVersion": workPackage.LockVersion, + } + for _, field := range resolved { + patch[field.APIField] = field.Value + } + + body, err := json.Marshal(patch) + if err != nil { + return err + } + + _, err = requests.Patch(workPackage.Links.Self.Href, &requests.RequestData{ + ContentType: "application/json", + Body: bytes.NewReader(body), + }) + return err +} diff --git a/components/resources/work_packages/update_fields_test.go b/components/resources/work_packages/update_fields_test.go new file mode 100644 index 0000000..7d23fe2 --- /dev/null +++ b/components/resources/work_packages/update_fields_test.go @@ -0,0 +1,117 @@ +package work_packages_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/opf/openproject-cli/components/requests" + "github.com/opf/openproject-cli/components/resources/work_packages" +) + +func TestDryRunUpdateFieldsResolvesLabels(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/work_packages/74316": + _, _ = io.WriteString(w, `{ + "id": 74316, + "subject": "Expand op CLI to support scripted work package workflows", + "_links": { + "self": {"href": "/api/v3/work_packages/74316"}, + "project": {"href": "/api/v3/projects/1482", "title": "CLI"}, + "schema": {"href": "/api/v3/work_packages/schemas/1482-6"}, + "status": {"href": "/api/v3/statuses/1", "title": "new"}, + "type": {"href": "/api/v3/types/6", "title": "Feature"}, + "assignee": {"href": null, "title": ""} + } + }`) + case "/api/v3/work_packages/schemas/1482-6": + _, _ = io.WriteString(w, `{ + "customField130": {"name": "Votes", "type": "Integer", "writable": true} + }`) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + requests.Init(host, "token", false) + + plan, err := work_packages.DryRunUpdateFields("74316", []string{"Votes=3"}) + if err != nil { + t.Fatal(err) + } + + field := plan.ResolvedFields["Votes"] + if field.APIField != "customField130" || field.Value != int64(3) { + t.Fatalf("unexpected plan: %+v", plan) + } +} + +func TestUpdateFieldsPatchesFormattableCustomFieldsAsLongText(t *testing.T) { + var patchBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/work_packages/74172": + switch r.Method { + case http.MethodGet: + _, _ = io.WriteString(w, `{ + "id": 74172, + "subject": "Epic", + "lockVersion": 5, + "_links": { + "self": {"href": "/api/v3/work_packages/74172"}, + "project": {"href": "/api/v3/projects/1482", "title": "CLI"}, + "schema": {"href": "/api/v3/work_packages/schemas/1482-6"}, + "status": {"href": "/api/v3/statuses/1", "title": "new"}, + "type": {"href": "/api/v3/types/6", "title": "Feature"}, + "assignee": {"href": null, "title": ""} + } + }`) + case http.MethodPatch: + if err := json.NewDecoder(r.Body).Decode(&patchBody); err != nil { + t.Fatal(err) + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{}`) + default: + t.Fatalf("unexpected method %s", r.Method) + } + case "/api/v3/work_packages/schemas/1482-6": + _, _ = io.WriteString(w, `{ + "customField401": {"name": "Acceptance criteria", "type": "Formattable", "writable": true} + }`) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + requests.Init(host, "token", false) + + if err := work_packages.UpdateFields("74172", []string{"Acceptance criteria=Native HTML5 DnD was rejected.\nDragula was rejected."}); err != nil { + t.Fatal(err) + } + + value, ok := patchBody["customField401"].(map[string]any) + if !ok { + t.Fatalf("expected long-text patch object, got %#v", patchBody["customField401"]) + } + if value["raw"] != "Native HTML5 DnD was rejected.\nDragula was rejected." { + t.Fatalf("expected raw long text, got %#v", value["raw"]) + } +} From a9eea466d27c8a42eec7d9b588c43cf1f6ee52f1 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 25 Jul 2026 22:30:23 +0200 Subject: [PATCH 06/13] [#74415] add validated status updates and dry-run --status resolves the status name against the statuses collection before patching, and DryRunUpdate returns the resolved plan without writing. Coexists with --action workflow transitions. https://community.openproject.org/wp/CLI-12 --- components/paths/paths.go | 4 + components/resources/work_packages/update.go | 86 +++- .../resources/work_packages/update_test.go | 405 ++++++++++++++++++ 3 files changed, 494 insertions(+), 1 deletion(-) diff --git a/components/paths/paths.go b/components/paths/paths.go index 70be27a..a45ef1d 100644 --- a/components/paths/paths.go +++ b/components/paths/paths.go @@ -46,6 +46,10 @@ func Status() string { return Root() + "/statuses" } +func StatusById(id uint64) string { + return fmt.Sprintf("%s/%d", Status(), id) +} + func TimeEntries() string { return Root() + "/time_entries" } diff --git a/components/resources/work_packages/update.go b/components/resources/work_packages/update.go index 3cf5568..a4d80f0 100644 --- a/components/resources/work_packages/update.go +++ b/components/resources/work_packages/update.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strconv" + "strings" "github.com/opf/openproject-cli/components/common" openerrors "github.com/opf/openproject-cli/components/errors" @@ -13,6 +14,7 @@ import ( "github.com/opf/openproject-cli/components/paths" "github.com/opf/openproject-cli/components/printer" "github.com/opf/openproject-cli/components/requests" + "github.com/opf/openproject-cli/components/resources/status" "github.com/opf/openproject-cli/dtos" "github.com/opf/openproject-cli/models" ) @@ -26,15 +28,68 @@ const ( UpdateDescription UpdateSubject UpdateType + UpdateStatus ) -var patchableUpdates = []UpdateOption{UpdateSubject, UpdateType, UpdateAssignee, UpdateDescription} +var patchableUpdates = []UpdateOption{UpdateSubject, UpdateType, UpdateAssignee, UpdateDescription, UpdateStatus} var patchMap = map[UpdateOption]func(patch, workPackage *dtos.WorkPackageDto, input string) (string, error){ UpdateAssignee: assigneePatch, UpdateDescription: descriptionPatch, UpdateType: typePatch, UpdateSubject: subjectPatch, + UpdateStatus: statusPatch, +} + +func DryRunUpdate(id string, options map[UpdateOption]string) (*models.WorkPackageUpdatePlan, error) { + workPackage, err := fetch(id) + if err != nil { + return nil, err + } + + plan := &models.WorkPackageUpdatePlan{ + Valid: true, + Operation: "update", + WorkPackageID: id, + Subject: options[UpdateSubject], + Status: options[UpdateStatus], + Action: options[UpdateCustomAction], + Attach: options[UpdateAttachment], + ResolvedFields: map[string]models.ResolvedField{}, + } + + if description, ok := options[UpdateDescription]; ok { + plan.Description = &description + } + + if assignee, ok := options[UpdateAssignee]; ok { + plan.Assignee = assignee + } + + if value, ok := options[UpdateStatus]; ok { + resolvedStatus, err := resolveStatus(value) + if err != nil { + return nil, err + } + + plan.Status = resolvedStatus.Name + } + + if value, ok := options[UpdateType]; ok { + types, err := availableTypes(workPackage.Links.Project) + if err != nil { + return nil, err + } + + foundType := findType(value, types) + if foundType == nil { + return nil, fmt.Errorf("no unique available type from input %q found for work package %s", value, id) + } + + plan.Type = foundType.Name + } + + return plan, nil } func Update(id string, options map[UpdateOption]string) (*models.WorkPackage, error) { @@ -220,3 +275,32 @@ func assigneePatch(patch, _ *dtos.WorkPackageDto, input string) (string, error) patch.Links.Assignee = &dtos.LinkDto{Href: paths.User(userId)} return fmt.Sprintf("Assignee -> %s", input), nil } + +func statusPatch(patch, _ *dtos.WorkPackageDto, input string) (string, error) { + resolvedStatus, err := resolveStatus(input) + if err != nil { + return "", err + } + + if patch.Links == nil { + patch.Links = &dtos.WorkPackageLinksDto{} + } + + patch.Links.Status = &dtos.LinkDto{Href: paths.StatusById(resolvedStatus.Id)} + return fmt.Sprintf("Status -> %s", resolvedStatus.Name), nil +} + +func resolveStatus(input string) (*models.Status, error) { + statuses, err := status.All() + if err != nil { + return nil, err + } + + for _, candidate := range statuses { + if strings.EqualFold(candidate.Name, input) { + return candidate, nil + } + } + + return nil, fmt.Errorf("no status named %q found", input) +} diff --git a/components/resources/work_packages/update_test.go b/components/resources/work_packages/update_test.go index 329edce..c5a2de4 100644 --- a/components/resources/work_packages/update_test.go +++ b/components/resources/work_packages/update_test.go @@ -411,3 +411,408 @@ func TestUpdateRejectsFogStorageAttachments(t *testing.T) { t.Errorf("mutation count = %d, want 0", mutationCount) } } + +func TestUpdatePatchIncludesStatus(t *testing.T) { + var patchBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + + switch { + case request.Method == http.MethodGet && request.URL.Path == "/api/v3/work_packages/74416": + _, _ = response.Write([]byte(`{ + "id": 74416, + "subject": "Old subject", + "lockVersion": 7, + "_links": { + "self": {"href": "/api/v3/work_packages/74416"}, + "project": {"href": "/api/v3/projects/1482"}, + "status": {"href": "/api/v3/statuses/1", "title": "New"} + } + }`)) + case request.Method == http.MethodPatch && request.URL.Path == "/api/v3/work_packages/74416": + if err := json.NewDecoder(request.Body).Decode(&patchBody); err != nil { + t.Fatal(err) + } + response.WriteHeader(http.StatusOK) + _, _ = response.Write([]byte(`{}`)) + case request.Method == http.MethodGet && request.URL.Path == "/api/v3/statuses": + _, _ = response.Write([]byte(`{ + "_embedded": { + "elements": [ + {"id": 1, "name": "New"}, + {"id": 2, "name": "In development"} + ] + } + }`)) + default: + http.Error(response, "unexpected request", http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + requests.Init(host, "", false) + printer.Init(&printer.TestingPrinter{}) + + _, err = work_packages.Update("74416", map[work_packages.UpdateOption]string{ + work_packages.UpdateStatus: "In development", + }) + if err != nil { + t.Fatal(err) + } + + links, ok := patchBody["_links"].(map[string]any) + if !ok { + t.Fatalf("expected links object, got %#v", patchBody["_links"]) + } + status, ok := links["status"].(map[string]any) + if !ok { + t.Fatalf("expected status link object, got %#v", links["status"]) + } + if status["href"] != "/api/v3/statuses/2" { + t.Fatalf("expected status href /api/v3/statuses/2, got %#v", status["href"]) + } +} + +func TestUpdatePatchStatusIsCaseInsensitive(t *testing.T) { + var patchBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + + switch { + case request.Method == http.MethodGet && request.URL.Path == "/api/v3/work_packages/74416": + _, _ = response.Write([]byte(`{ + "id": 74416, + "lockVersion": 7, + "_links": { + "self": {"href": "/api/v3/work_packages/74416"}, + "project": {"href": "/api/v3/projects/1482"} + } + }`)) + case request.Method == http.MethodPatch && request.URL.Path == "/api/v3/work_packages/74416": + if err := json.NewDecoder(request.Body).Decode(&patchBody); err != nil { + t.Fatal(err) + } + response.WriteHeader(http.StatusOK) + _, _ = response.Write([]byte(`{}`)) + case request.Method == http.MethodGet && request.URL.Path == "/api/v3/statuses": + _, _ = response.Write([]byte(`{ + "_embedded": { + "elements": [ + {"id": 1, "name": "New"}, + {"id": 2, "name": "In development"} + ] + } + }`)) + default: + http.Error(response, "unexpected request", http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + requests.Init(host, "", false) + printer.Init(&printer.TestingPrinter{}) + + _, err = work_packages.Update("74416", map[work_packages.UpdateOption]string{ + work_packages.UpdateStatus: "in development", + }) + if err != nil { + t.Fatal(err) + } + + links, ok := patchBody["_links"].(map[string]any) + if !ok { + t.Fatalf("expected links object, got %#v", patchBody["_links"]) + } + status, ok := links["status"].(map[string]any) + if !ok { + t.Fatalf("expected status link object, got %#v", links["status"]) + } + if status["href"] != "/api/v3/statuses/2" { + t.Fatalf("expected status href /api/v3/statuses/2, got %#v", status["href"]) + } +} + +func TestUpdateReturnsErrorForUnknownStatus(t *testing.T) { + mutationCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + + switch { + case request.Method == http.MethodGet && request.URL.Path == "/api/v3/work_packages/74416": + _, _ = response.Write([]byte(`{ + "id": 74416, + "lockVersion": 7, + "_links": { + "self": {"href": "/api/v3/work_packages/74416"}, + "project": {"href": "/api/v3/projects/1482"} + } + }`)) + case request.Method == http.MethodGet && request.URL.Path == "/api/v3/statuses": + _, _ = response.Write([]byte(`{"_embedded":{"elements":[{"id": 1, "name": "New"}]}}`)) + case request.Method == http.MethodPost || request.Method == http.MethodPatch: + mutationCount++ + _, _ = response.Write([]byte(`{}`)) + default: + http.Error(response, "unexpected request", http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + requests.Init(host, "", false) + printer.Init(&printer.TestingPrinter{}) + + _, err = work_packages.Update("74416", map[work_packages.UpdateOption]string{ + work_packages.UpdateStatus: "Nonexistent", + }) + if err == nil { + t.Fatal("Update returned nil, want an unknown status error") + } + if !strings.Contains(err.Error(), `no status named "Nonexistent" found`) { + t.Errorf("error should mention the unresolved status name, got: %v", err) + } + if mutationCount != 0 { + t.Errorf("mutation count = %d, want 0", mutationCount) + } +} + +func TestDryRunUpdateIncludesLegacyFields(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + + switch request.URL.Path { + case "/api/v3/work_packages/74416": + _, _ = response.Write([]byte(`{ + "id": 74416, + "subject": "Old subject", + "_links": { + "self": {"href": "/api/v3/work_packages/74416"}, + "project": {"href": "/api/v3/projects/1482"}, + "status": {"href": "/api/v3/statuses/1", "title": "new"}, + "type": {"href": "/api/v3/types/7", "title": "Implementation"} + } + }`)) + case "/api/v3/projects/1482": + _, _ = response.Write([]byte(`{ + "id": 1482, + "_links": {"types": {"href": "/api/v3/projects/1482/types/available"}} + }`)) + case "/api/v3/projects/1482/types/available": + _, _ = response.Write([]byte(`{ + "_embedded": { + "elements": [ + {"id": 7, "name": "Implementation", "_links": {"self": {"href": "/api/v3/types/7"}}}, + {"id": 6, "name": "Feature", "_links": {"self": {"href": "/api/v3/types/6"}}} + ] + } + }`)) + case "/api/v3/statuses": + _, _ = response.Write([]byte(`{ + "_embedded": { + "elements": [ + {"id": 1, "name": "New"}, + {"id": 2, "name": "In development"} + ] + } + }`)) + default: + http.Error(response, "unexpected request", http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + requests.Init(host, "", false) + printer.Init(&printer.TestingPrinter{}) + + cases := []struct { + name string + options map[work_packages.UpdateOption]string + field string + want string + }{ + { + name: "subject only", + options: map[work_packages.UpdateOption]string{work_packages.UpdateSubject: "Renamed"}, + field: "subject", + want: "Renamed", + }, + { + name: "type by name resolves against project types", + options: map[work_packages.UpdateOption]string{work_packages.UpdateType: "Feature"}, + field: "type", + want: "Feature", + }, + { + name: "assignee is echoed back", + options: map[work_packages.UpdateOption]string{work_packages.UpdateAssignee: "42"}, + field: "assignee", + want: "42", + }, + { + name: "status resolves against known statuses", + options: map[work_packages.UpdateOption]string{work_packages.UpdateStatus: "in development"}, + field: "status", + want: "In development", + }, + { + name: "action surfaces as preview", + options: map[work_packages.UpdateOption]string{work_packages.UpdateCustomAction: "Claim"}, + field: "action", + want: "Claim", + }, + { + name: "attach surfaces as preview", + options: map[work_packages.UpdateOption]string{work_packages.UpdateAttachment: "/tmp/f.txt"}, + field: "attach", + want: "/tmp/f.txt", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + plan, err := work_packages.DryRunUpdate("74416", tc.options) + if err != nil { + t.Fatalf("DryRunUpdate returned error: %v", err) + } + if !plan.Valid { + t.Fatalf("expected valid plan, got %+v", plan) + } + if plan.WorkPackageID != "74416" { + t.Errorf("WorkPackageID = %q, want 74416", plan.WorkPackageID) + } + + marshalled, err := json.Marshal(plan) + if err != nil { + t.Fatal(err) + } + var unmarshalled map[string]any + if err := json.Unmarshal(marshalled, &unmarshalled); err != nil { + t.Fatal(err) + } + if unmarshalled[tc.field] != tc.want { + t.Fatalf("expected %s=%q, got %#v", tc.field, tc.want, unmarshalled[tc.field]) + } + }) + } +} + +func TestDryRunUpdateReturnsErrorForMissingWorkPackage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.WriteHeader(http.StatusNotFound) + _, _ = response.Write([]byte(`{"message":"not found"}`)) + })) + t.Cleanup(server.Close) + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + requests.Init(host, "", false) + printer.Init(&printer.TestingPrinter{}) + + _, err = work_packages.DryRunUpdate("999999", map[work_packages.UpdateOption]string{ + work_packages.UpdateSubject: "x", + }) + if err == nil { + t.Fatal("expected error for missing work package, got nil") + } +} + +func TestDryRunUpdateReturnsErrorForUnresolvedType(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + + switch request.URL.Path { + case "/api/v3/work_packages/74416": + _, _ = response.Write([]byte(`{ + "id": 74416, + "_links": { + "self": {"href": "/api/v3/work_packages/74416"}, + "project": {"href": "/api/v3/projects/1482"} + } + }`)) + case "/api/v3/projects/1482": + _, _ = response.Write([]byte(`{ + "_links": {"types": {"href": "/api/v3/projects/1482/types/available"}} + }`)) + case "/api/v3/projects/1482/types/available": + _, _ = response.Write([]byte(`{"_embedded":{"elements":[]}}`)) + default: + http.Error(response, "unexpected request", http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + requests.Init(host, "", false) + printer.Init(&printer.TestingPrinter{}) + + _, err = work_packages.DryRunUpdate("74416", map[work_packages.UpdateOption]string{ + work_packages.UpdateType: "Nonsense", + }) + if err == nil { + t.Fatal("expected error for unresolved type, got nil") + } + if !strings.Contains(err.Error(), "no unique available type from input") { + t.Errorf("error should mention the unresolved type, got: %v", err) + } +} + +func TestDryRunUpdateReturnsErrorForUnresolvedStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + + switch request.URL.Path { + case "/api/v3/work_packages/74416": + _, _ = response.Write([]byte(`{ + "id": 74416, + "_links": { + "self": {"href": "/api/v3/work_packages/74416"}, + "project": {"href": "/api/v3/projects/1482"} + } + }`)) + case "/api/v3/statuses": + _, _ = response.Write([]byte(`{"_embedded":{"elements":[]}}`)) + default: + http.Error(response, "unexpected request", http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + requests.Init(host, "", false) + printer.Init(&printer.TestingPrinter{}) + + _, err = work_packages.DryRunUpdate("74416", map[work_packages.UpdateOption]string{ + work_packages.UpdateStatus: "In progress", + }) + if err == nil { + t.Fatal("expected error for unresolved status, got nil") + } + if !strings.Contains(err.Error(), `no status named "In progress" found`) { + t.Errorf("error should mention the unresolved status, got: %v", err) + } +} From 2fae7e64ddb424ee0b26cf512eea7ea0bb7575ea Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 25 Jul 2026 22:38:33 +0200 Subject: [PATCH 07/13] [#74415] add parent-aware create and dry-run Create accepts a parent work package option patched via _links.parent; DryRunCreate returns the draft plan without writing. https://community.openproject.org/wp/CLI-12 --- components/resources/work_packages/create.go | 46 +++++++ .../resources/work_packages/create_test.go | 123 ++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/components/resources/work_packages/create.go b/components/resources/work_packages/create.go index da313eb..12266a8 100644 --- a/components/resources/work_packages/create.go +++ b/components/resources/work_packages/create.go @@ -22,6 +22,7 @@ const ( CreateType CreateAssignee CreateDescription + CreateParent ) var createMap = map[CreateOption]func(projectId string, workPackage *dtos.WorkPackageDto, input string) error{ @@ -29,6 +30,7 @@ var createMap = map[CreateOption]func(projectId string, workPackage *dtos.WorkPa CreateType: typeCreate, CreateAssignee: assigneeCreate, CreateDescription: descriptionCreate, + CreateParent: parentCreate, } func subjectCreate(_ string, workPackage *dtos.WorkPackageDto, input string) error { @@ -83,6 +85,50 @@ func descriptionCreate(_ string, workPackage *dtos.WorkPackageDto, input string) return nil } +func parentCreate(_ string, workPackage *dtos.WorkPackageDto, input string) error { + parentID, err := strconv.ParseUint(input, 10, 64) + if err != nil { + return fmt.Errorf("invalid work package id %q: must be a number", input) + } + + if workPackage.Links == nil { + workPackage.Links = &dtos.WorkPackageLinksDto{} + } + + workPackage.Links.Parent = &dtos.LinkDto{Href: paths.WorkPackage(strconv.FormatUint(parentID, 10))} + return nil +} + +func DryRunCreate(projectId string, options map[CreateOption]string) (*models.WorkPackageCreatePlan, error) { + plan := &models.WorkPackageCreatePlan{ + Valid: true, + Operation: "create", + ProjectID: projectId, + } + + for _, option := range []CreateOption{CreateSubject, CreateParent, CreateDescription} { + value, ok := options[option] + if !ok { + continue + } + + switch option { + case CreateSubject: + plan.WorkPackage.Subject = value + case CreateParent: + parentID, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid work package id %q: must be a number", value) + } + plan.ParentID = &parentID + case CreateDescription: + plan.WorkPackage.Description = value + } + } + + return plan, nil +} + func Create(projectId string, options map[CreateOption]string) (*models.WorkPackage, error) { return create(projectId, options) } diff --git a/components/resources/work_packages/create_test.go b/components/resources/work_packages/create_test.go index d447e35..98b7cbe 100644 --- a/components/resources/work_packages/create_test.go +++ b/components/resources/work_packages/create_test.go @@ -1,6 +1,7 @@ package work_packages_test import ( + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -89,3 +90,125 @@ func TestCreateInvalidTypeHintGoesToStderr(t *testing.T) { t.Errorf("stderr = %q, want it to list available type %q", testingPrinter.ErrResult, "Task") } } + +func TestDryRunCreateIncludesParentAndDescription(t *testing.T) { + plan, err := work_packages.DryRunCreate("1482", map[work_packages.CreateOption]string{ + work_packages.CreateSubject: "Build reusable skill", + work_packages.CreateParent: "74316", + work_packages.CreateDescription: "Body", + }) + if err != nil { + t.Fatalf("DryRunCreate returned error: %v", err) + } + + if !plan.Valid || plan.Operation != "create" { + t.Fatalf("unexpected plan: %+v", plan) + } + if plan.ProjectID != "1482" { + t.Errorf("ProjectID = %q, want 1482", plan.ProjectID) + } + if plan.ParentID == nil || *plan.ParentID != 74316 { + t.Fatalf("expected parent id 74316, got %+v", plan.ParentID) + } + if plan.WorkPackage.Subject != "Build reusable skill" { + t.Errorf("Subject = %q, want %q", plan.WorkPackage.Subject, "Build reusable skill") + } + if plan.WorkPackage.Description != "Body" { + t.Errorf("Description = %q, want %q", plan.WorkPackage.Description, "Body") + } +} + +func TestDryRunCreateWithoutParentLeavesParentIDNil(t *testing.T) { + plan, err := work_packages.DryRunCreate("1482", map[work_packages.CreateOption]string{ + work_packages.CreateSubject: "No parent", + }) + if err != nil { + t.Fatalf("DryRunCreate returned error: %v", err) + } + if plan.ParentID != nil { + t.Errorf("ParentID = %+v, want nil", plan.ParentID) + } +} + +func TestDryRunCreateReturnsErrorForInvalidParent(t *testing.T) { + _, err := work_packages.DryRunCreate("1482", map[work_packages.CreateOption]string{ + work_packages.CreateSubject: "Subject", + work_packages.CreateParent: "not-a-number", + }) + if err == nil { + t.Fatal("expected error for invalid parent id, got nil") + } +} + +func TestCreatePatchIncludesParentLink(t *testing.T) { + var postBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + switch { + case request.Method == http.MethodPost && request.URL.Path == "/api/v3/projects/1482/work_packages": + if err := json.NewDecoder(request.Body).Decode(&postBody); err != nil { + t.Fatal(err) + } + response.WriteHeader(http.StatusCreated) + _, _ = response.Write([]byte(`{"id":99999,"subject":"","_links":{"self":{"href":"/api/v3/work_packages/99999"}}}`)) + default: + http.Error(response, "unexpected request", http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + requests.Init(host, "", false) + + _, err = work_packages.Create("1482", map[work_packages.CreateOption]string{ + work_packages.CreateSubject: "Subject", + work_packages.CreateParent: "74316", + }) + if err != nil { + t.Fatalf("Create returned error: %v", err) + } + + 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 in POST body, got %#v", links["parent"]) + } + if parent["href"] != "/api/v3/work_packages/74316" { + t.Errorf("parent href = %v, want %q", parent["href"], "/api/v3/work_packages/74316") + } +} + +func TestCreateRejectsInvalidParentBeforePosting(t *testing.T) { + postCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Method == http.MethodPost { + postCount++ + } + response.WriteHeader(http.StatusCreated) + _, _ = response.Write([]byte(`{"id":1}`)) + })) + t.Cleanup(server.Close) + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + requests.Init(host, "", false) + + _, err = work_packages.Create("1482", map[work_packages.CreateOption]string{ + work_packages.CreateSubject: "Subject", + work_packages.CreateParent: "not-a-number", + }) + if err == nil { + t.Fatal("expected error for invalid parent id, got nil") + } + if postCount != 0 { + t.Errorf("POST count = %d, want 0", postCount) + } +} From 05661d24624c20ed52ecbf8c6f9802571402ba9d Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 25 Jul 2026 22:43:54 +0200 Subject: [PATCH 08/13] [#74415] resolve create type in dry-run plan DryRunCreate now resolves --type against the project's available types and sets WorkPackage.Type, reusing the same helpers DryRunUpdate uses for UpdateType; an unresolvable type now errors instead of being silently dropped from the plan. https://community.openproject.org/wp/CLI-12 --- components/resources/work_packages/create.go | 14 ++++ .../resources/work_packages/create_test.go | 80 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/components/resources/work_packages/create.go b/components/resources/work_packages/create.go index 12266a8..ef4bdca 100644 --- a/components/resources/work_packages/create.go +++ b/components/resources/work_packages/create.go @@ -126,6 +126,20 @@ func DryRunCreate(projectId string, options map[CreateOption]string) (*models.Wo } } + if value, ok := options[CreateType]; ok { + types, err := availableTypes(&dtos.LinkDto{Href: paths.Project(projectId)}) + if err != nil { + return nil, err + } + + foundType := findType(value, types) + if foundType == nil { + return nil, fmt.Errorf("no unique available type from input %q found for project %s", value, projectId) + } + + plan.WorkPackage.Type = foundType.Name + } + return plan, nil } diff --git a/components/resources/work_packages/create_test.go b/components/resources/work_packages/create_test.go index 98b7cbe..c06d9fd 100644 --- a/components/resources/work_packages/create_test.go +++ b/components/resources/work_packages/create_test.go @@ -118,6 +118,86 @@ func TestDryRunCreateIncludesParentAndDescription(t *testing.T) { } } +func TestDryRunCreateWithTypeResolvesTypeName(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + switch request.URL.Path { + case "/api/v3/projects/1482": + _, _ = response.Write([]byte(`{ + "id": 1482, + "_links": {"types": {"href": "/api/v3/projects/1482/types/available"}} + }`)) + case "/api/v3/projects/1482/types/available": + _, _ = response.Write([]byte(`{ + "_embedded": { + "elements": [ + {"id": 7, "name": "Implementation", "_links": {"self": {"href": "/api/v3/types/7"}}}, + {"id": 6, "name": "Feature", "_links": {"self": {"href": "/api/v3/types/6"}}} + ] + } + }`)) + default: + http.Error(response, "unexpected request", http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + requests.Init(host, "", false) + + plan, err := work_packages.DryRunCreate("1482", map[work_packages.CreateOption]string{ + work_packages.CreateSubject: "Build reusable skill", + work_packages.CreateType: "Feature", + }) + if err != nil { + t.Fatalf("DryRunCreate returned error: %v", err) + } + if plan.WorkPackage.Type != "Feature" { + t.Errorf("Type = %q, want %q", plan.WorkPackage.Type, "Feature") + } +} + +func TestDryRunCreateReturnsErrorForUnresolvedType(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + switch request.URL.Path { + case "/api/v3/projects/1482": + _, _ = response.Write([]byte(`{ + "id": 1482, + "_links": {"types": {"href": "/api/v3/projects/1482/types/available"}} + }`)) + case "/api/v3/projects/1482/types/available": + _, _ = response.Write([]byte(`{ + "_embedded": { + "elements": [ + {"id": 7, "name": "Implementation", "_links": {"self": {"href": "/api/v3/types/7"}}} + ] + } + }`)) + default: + http.Error(response, "unexpected request", http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + requests.Init(host, "", false) + + _, err = work_packages.DryRunCreate("1482", map[work_packages.CreateOption]string{ + work_packages.CreateSubject: "Build reusable skill", + work_packages.CreateType: "Missing", + }) + if err == nil { + t.Fatal("expected error for unresolved type, got nil") + } +} + func TestDryRunCreateWithoutParentLeavesParentIDNil(t *testing.T) { plan, err := work_packages.DryRunCreate("1482", map[work_packages.CreateOption]string{ work_packages.CreateSubject: "No parent", From 37fff97200bca741e4bd8046ee8e97ea63f09793 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 25 Jul 2026 22:50:52 +0200 Subject: [PATCH 09/13] [#74415] render inspect payloads and dry-run plans Extends the renderer interface with work package details and create/update plan output in both text and JSON formats. https://community.openproject.org/wp/CLI-12 --- components/printer/json_renderer.go | 12 ++ components/printer/renderer.go | 3 + components/printer/text_renderer.go | 95 ++++++++++ .../printer/work_package_details_test.go | 162 ++++++++++++++++++ components/printer/work_packages.go | 12 ++ 5 files changed, 284 insertions(+) create mode 100644 components/printer/work_package_details_test.go diff --git a/components/printer/json_renderer.go b/components/printer/json_renderer.go index a933a68..9dbb5c2 100644 --- a/components/printer/json_renderer.go +++ b/components/printer/json_renderer.go @@ -57,6 +57,18 @@ func (r *JsonRenderer) WorkPackages(wps []*models.WorkPackage) { printJson(out) } +func (r *JsonRenderer) WorkPackageDetails(p *models.WorkPackageInspectPayload) { + printJson(p) +} + +func (r *JsonRenderer) WorkPackageCreatePlan(p *models.WorkPackageCreatePlan) { + printJson(p) +} + +func (r *JsonRenderer) WorkPackageUpdatePlan(p *models.WorkPackageUpdatePlan) { + printJson(p) +} + func (r *JsonRenderer) Project(p *models.Project) { printJson(struct { Id uint64 `json:"id"` diff --git a/components/printer/renderer.go b/components/printer/renderer.go index 522870a..9a20bb6 100644 --- a/components/printer/renderer.go +++ b/components/printer/renderer.go @@ -11,6 +11,9 @@ type Renderer interface { Budgets([]*models.Budget) WorkPackage(*models.WorkPackage) WorkPackages([]*models.WorkPackage) + WorkPackageDetails(*models.WorkPackageInspectPayload) + WorkPackageCreatePlan(*models.WorkPackageCreatePlan) + WorkPackageUpdatePlan(*models.WorkPackageUpdatePlan) Project(*models.Project) Projects([]*models.Project) User(*models.User) diff --git a/components/printer/text_renderer.go b/components/printer/text_renderer.go index e3ba21d..992a228 100644 --- a/components/printer/text_renderer.go +++ b/components/printer/text_renderer.go @@ -50,6 +50,58 @@ func (r *TextRenderer) WorkPackages(wps []*models.WorkPackage) { } } +func (r *TextRenderer) WorkPackageDetails(p *models.WorkPackageInspectPayload) { + wp := p.WorkPackage + idStr := fmt.Sprintf("#%d", wp.ID) + activePrinter.Printf("%s %s %s\n", Red(idStr), Green(strings.ToUpper(wp.Type)), Cyan(wp.Subject)) + activePrinter.Printf("[%s]\n", Yellow(wp.Status)) + + assigneeStr := wp.Assignee + if len(assigneeStr) == 0 { + assigneeStr = "-" + } + activePrinter.Printf("Assignee: %s\n", assigneeStr) + activePrinter.Printf("Project: %s (%s)\n", wp.Project.Name, wp.Project.Identifier) + if wp.ParentID != nil { + activePrinter.Printf("Parent: #%d\n", *wp.ParentID) + } + + printCustomFields(wp.FieldLabels, wp.Fields) + + activePrinter.Printf("Children (%d):\n", len(p.Children)) + for _, child := range p.Children { + activePrinter.Printf("#%d [%s] %s\n", child.ID, child.Status, child.Subject) + } +} + +func (r *TextRenderer) WorkPackageCreatePlan(p *models.WorkPackageCreatePlan) { + activePrinter.Println("Dry run — no changes applied.") + printPlanField("operation", p.Operation) + printPlanField("project_id", p.ProjectID) + if p.ParentID != nil { + printPlanField("parent_id", strconv.FormatUint(*p.ParentID, 10)) + } + printPlanField("subject", p.WorkPackage.Subject) + printPlanField("type", p.WorkPackage.Type) + printPlanField("description", p.WorkPackage.Description) +} + +func (r *TextRenderer) WorkPackageUpdatePlan(p *models.WorkPackageUpdatePlan) { + activePrinter.Println("Dry run — no changes applied.") + printPlanField("operation", p.Operation) + printPlanField("work_package_id", p.WorkPackageID) + printPlanField("subject", p.Subject) + printPlanField("type", p.Type) + printPlanField("assignee", p.Assignee) + printPlanField("status", p.Status) + if p.Description != nil { + printPlanField("description", *p.Description) + } + printPlanField("action", p.Action) + printPlanField("attach", p.Attach) + printResolvedFields(p.ResolvedFields) +} + func (r *TextRenderer) Project(p *models.Project) { printProject(p) } @@ -209,3 +261,46 @@ func printStatus(s *models.Status, maxIdLength int) { func printCustomAction(a *models.CustomAction) { activePrinter.Printf("%s %s\n", Red(fmt.Sprintf("#%d", a.Id)), Cyan(a.Name)) } + +// printCustomFields prints one "label: value" line per API field, sorted +// first by label and then by API name so output stays deterministic even +// though a single label can map to more than one API field. +func printCustomFields(fieldLabels map[string][]string, fields map[string]any) { + labels := make([]string, 0, len(fieldLabels)) + for label := range fieldLabels { + labels = append(labels, label) + } + sort.Strings(labels) + + for _, label := range labels { + apiNames := append([]string(nil), fieldLabels[label]...) + sort.Strings(apiNames) + for _, apiName := range apiNames { + activePrinter.Printf("%s: %v\n", label, fields[apiName]) + } + } +} + +// printPlanField prints a single "name: value" line, skipping fields that +// are empty or omitted from the dry-run plan. +func printPlanField(name, value string) { + if len(value) == 0 { + return + } + activePrinter.Printf("%s: %s\n", name, value) +} + +// printResolvedFields prints one line per schema-resolved custom field +// assignment, sorted by key for deterministic output. +func printResolvedFields(resolvedFields map[string]models.ResolvedField) { + keys := make([]string, 0, len(resolvedFields)) + for key := range resolvedFields { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + field := resolvedFields[key] + activePrinter.Printf("%s (%s): %v\n", key, field.APIField, field.Value) + } +} diff --git a/components/printer/work_package_details_test.go b/components/printer/work_package_details_test.go new file mode 100644 index 0000000..c6038a8 --- /dev/null +++ b/components/printer/work_package_details_test.go @@ -0,0 +1,162 @@ +package printer_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/models" +) + +func samplePayload() *models.WorkPackageInspectPayload { + parentID := uint64(74300) + return &models.WorkPackageInspectPayload{ + WorkPackage: models.WorkPackageDetails{ + ID: 74316, + Subject: "Expand op CLI", + Type: "Feature", + Status: "New", + Assignee: "Jane Doe", + Description: "Body", + ParentID: &parentID, + Project: models.ProjectRef{ + ID: 1482, + Identifier: "cli", + Name: "CLI", + }, + Fields: map[string]any{ + "customField130": float64(3), + }, + FieldLabels: map[string][]string{ + "Votes": {"customField130"}, + }, + }, + Children: []models.WorkPackageSummary{ + {ID: 74413, Subject: "Build a reusable SKILL.md", Type: "Implementation", Status: "New"}, + }, + } +} + +func TestWorkPackageDetails_Json_IncludesChildrenAndCustomFields(t *testing.T) { + if err := printer.InitRenderer("json"); err != nil { + t.Fatalf("InitRenderer(json) failed: %v", err) + } + defer func() { + _ = printer.InitRenderer("text") + }() + testingPrinter.Reset() + + printer.WorkPackageDetails(samplePayload()) + + if !strings.Contains(testingPrinter.Result, `"children"`) { + t.Errorf("expected output to contain \"children\", got: %s", testingPrinter.Result) + } + if !strings.Contains(testingPrinter.Result, "customField130") { + t.Errorf("expected output to contain custom field key customField130, got: %s", testingPrinter.Result) + } + + var parsed map[string]any + if err := json.Unmarshal([]byte(testingPrinter.Result), &parsed); err != nil { + t.Fatalf("output is not valid JSON: %v\noutput: %q", err, testingPrinter.Result) + } +} + +func TestWorkPackageDetails_Text_ListsChildren(t *testing.T) { + testingPrinter.Reset() + + printer.WorkPackageDetails(samplePayload()) + + if !strings.Contains(testingPrinter.Result, "Children (1):") { + t.Errorf("expected output to contain \"Children (1):\", got: %s", testingPrinter.Result) + } + if !strings.Contains(testingPrinter.Result, "#74413 [New] Build a reusable SKILL.md") { + t.Errorf("expected output to list child #74413, got: %s", testingPrinter.Result) + } + if !strings.Contains(testingPrinter.Result, "Votes: 3") { + t.Errorf("expected output to contain custom field line \"Votes: 3\", got: %s", testingPrinter.Result) + } +} + +func TestWorkPackageCreatePlan_Text_PrintsDryRunFields(t *testing.T) { + testingPrinter.Reset() + + parentID := uint64(74300) + plan := &models.WorkPackageCreatePlan{ + Valid: true, + Operation: "create", + ProjectID: "cli", + ParentID: &parentID, + WorkPackage: models.WorkPackageDraft{ + Subject: "New task", + Type: "Task", + Description: "", + }, + } + + printer.WorkPackageCreatePlan(plan) + + if !strings.Contains(testingPrinter.Result, "Dry run — no changes applied.") { + t.Errorf("expected dry-run banner, got: %s", testingPrinter.Result) + } + if !strings.Contains(testingPrinter.Result, "project_id: cli") { + t.Errorf("expected project_id line, got: %s", testingPrinter.Result) + } + if !strings.Contains(testingPrinter.Result, "parent_id: 74300") { + t.Errorf("expected parent_id line, got: %s", testingPrinter.Result) + } + if !strings.Contains(testingPrinter.Result, "subject: New task") { + t.Errorf("expected subject line, got: %s", testingPrinter.Result) + } + if strings.Contains(testingPrinter.Result, "description:") { + t.Errorf("expected empty description to be omitted, got: %s", testingPrinter.Result) + } +} + +func TestWorkPackageUpdatePlan_Json_EmitsResolvedFields(t *testing.T) { + if err := printer.InitRenderer("json"); err != nil { + t.Fatalf("InitRenderer(json) failed: %v", err) + } + defer func() { + _ = printer.InitRenderer("text") + }() + testingPrinter.Reset() + + plan := &models.WorkPackageUpdatePlan{ + Valid: true, + Operation: "update_fields", + WorkPackageID: "74316", + ResolvedFields: map[string]models.ResolvedField{ + "Story points": {APIField: "customField130", Value: float64(5)}, + }, + } + + printer.WorkPackageUpdatePlan(plan) + + if !strings.Contains(testingPrinter.Result, "customField130") { + t.Errorf("expected resolved field api name in JSON output, got: %s", testingPrinter.Result) + } +} + +func TestWorkPackageUpdatePlan_Text_SkipsEmptyFields(t *testing.T) { + testingPrinter.Reset() + + plan := &models.WorkPackageUpdatePlan{ + Valid: true, + Operation: "update", + WorkPackageID: "74316", + Status: "Closed", + } + + printer.WorkPackageUpdatePlan(plan) + + if !strings.Contains(testingPrinter.Result, "status: Closed") { + t.Errorf("expected status line, got: %s", testingPrinter.Result) + } + if strings.Contains(testingPrinter.Result, "subject:") { + t.Errorf("expected empty subject to be omitted, got: %s", testingPrinter.Result) + } + if strings.Contains(testingPrinter.Result, "assignee:") { + t.Errorf("expected empty assignee to be omitted, got: %s", testingPrinter.Result) + } +} diff --git a/components/printer/work_packages.go b/components/printer/work_packages.go index 778a6fd..a85caea 100644 --- a/components/printer/work_packages.go +++ b/components/printer/work_packages.go @@ -18,6 +18,18 @@ func WorkPackages(wps []*models.WorkPackage) { activeRenderer.WorkPackages(wps) } +func WorkPackageDetails(p *models.WorkPackageInspectPayload) { + activeRenderer.WorkPackageDetails(p) +} + +func WorkPackageCreatePlan(p *models.WorkPackageCreatePlan) { + activeRenderer.WorkPackageCreatePlan(p) +} + +func WorkPackageUpdatePlan(p *models.WorkPackageUpdatePlan) { + activeRenderer.WorkPackageUpdatePlan(p) +} + func idLength(id uint64) int { return len(strconv.FormatUint(id, 10)) + 1 } From d77cf83f03cdb4e71a5cc6f49095e874078df646 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 25 Jul 2026 22:56:44 +0200 Subject: [PATCH 10/13] [#74415] add inspect --children Inspect gains a --children flag returning the full details payload with direct children, rendered as text or JSON via --format. https://community.openproject.org/wp/CLI-12 --- cmd/workpackage/inspect.go | 15 ++++++ cmd/workpackage/inspect_test.go | 84 +++++++++++++++++++++++++++++++++ cmd/workpackage/workpackage.go | 6 +++ 3 files changed, 105 insertions(+) diff --git a/cmd/workpackage/inspect.go b/cmd/workpackage/inspect.go index 6af6e94..dba7050 100644 --- a/cmd/workpackage/inspect.go +++ b/cmd/workpackage/inspect.go @@ -14,6 +14,7 @@ import ( var inspectOpenInBrowser bool var inspectListAvailableTypes bool +var inspectWithChildren bool var inspectCmd = &cobra.Command{ Use: "inspect [id]", @@ -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: diff --git a/cmd/workpackage/inspect_test.go b/cmd/workpackage/inspect_test.go index 8a47b37..d405783 100644 --- a/cmd/workpackage/inspect_test.go +++ b/cmd/workpackage/inspect_test.go @@ -2,6 +2,7 @@ package workpackage import ( "errors" + "io" "net/http" "strings" "testing" @@ -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) + } +} diff --git a/cmd/workpackage/workpackage.go b/cmd/workpackage/workpackage.go index 2f39122..08bcaa3 100644 --- a/cmd/workpackage/workpackage.go +++ b/cmd/workpackage/workpackage.go @@ -98,6 +98,12 @@ func init() { false, "List the available types on the work package.", ) + inspectCmd.Flags().BoolVar( + &inspectWithChildren, + "children", + false, + "Include direct children and full field details in the output", + ) searchCmd.Flags().StringVarP( &searchProjectId, From 0837a5ad4037d494c5fcb867ac24368b214921dd Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 25 Jul 2026 23:02:52 +0200 Subject: [PATCH 11/13] [#74415] add update --set, --status and --dry-run Schema-driven --set assignments with label resolution, validated --status changes, and --dry-run plans for both, rendered via --format. https://community.openproject.org/wp/CLI-12 --- cmd/workpackage/update.go | 53 ++++++ cmd/workpackage/update_test.go | 298 +++++++++++++++++++++++++++++++++ cmd/workpackage/workpackage.go | 18 ++ 3 files changed, 369 insertions(+) create mode 100644 cmd/workpackage/update_test.go diff --git a/cmd/workpackage/update.go b/cmd/workpackage/update.go index 1627fcc..d77f59b 100644 --- a/cmd/workpackage/update.go +++ b/cmd/workpackage/update.go @@ -18,6 +18,9 @@ var updateAttachFlag string var updateDescriptionFlag string var updateSubjectFlag string var updateTypeFlag string +var updateStatusFlag string +var updateSetFlags []string +var updateDryRun bool var updateCmd = &cobra.Command{ Use: "update [id]", @@ -39,12 +42,50 @@ func updateWorkPackage(cmd *cobra.Command, args []string) error { return openerrors.ErrHandled } + if len(updateSetFlags) > 0 && updateHasFieldFlags(cmd) { + printer.ErrorText("cannot combine --set with other update flags") + return openerrors.ErrHandled + } + + if len(updateSetFlags) > 0 { + if updateDryRun { + plan, err := work_packages.DryRunUpdateFields(id, updateSetFlags) + if err != nil { + printer.Error(err) + return openerrors.ErrHandled + } + printer.WorkPackageUpdatePlan(plan) + return nil + } + if err := work_packages.UpdateFields(id, updateSetFlags); err != nil { + printer.Error(err) + return openerrors.ErrHandled + } + payload, err := work_packages.Inspect(id) + if err != nil { + printer.Error(err) + return openerrors.ErrHandled + } + printer.WorkPackageDetails(payload) + return nil + } + options := updateOptions(cmd) if len(options) == 0 { printer.ErrorText("No update options provided. Use --help to see available flags.") return openerrors.ErrHandled } + if updateDryRun { + plan, err := work_packages.DryRunUpdate(id, options) + if err != nil { + printer.Error(err) + return openerrors.ErrHandled + } + printer.WorkPackageUpdatePlan(plan) + return nil + } + workPackage, err := work_packages.Update(id, options) if err != nil { if !stderrors.Is(err, openerrors.ErrHandled) { @@ -58,6 +99,15 @@ func updateWorkPackage(cmd *cobra.Command, args []string) error { return nil } +func updateHasFieldFlags(cmd *cobra.Command) bool { + for _, name := range []string{"subject", "type", "assignee", "description", "status", "action", "attach"} { + if cmd.Flags().Changed(name) { + return true + } + } + return false +} + func updateOptions(cmd *cobra.Command) map[work_packages.UpdateOption]string { options := make(map[work_packages.UpdateOption]string) if len(updateActionFlag) > 0 { @@ -78,5 +128,8 @@ func updateOptions(cmd *cobra.Command) map[work_packages.UpdateOption]string { if len(updateTypeFlag) > 0 { options[work_packages.UpdateType] = updateTypeFlag } + if len(updateStatusFlag) > 0 { + options[work_packages.UpdateStatus] = updateStatusFlag + } return options } diff --git a/cmd/workpackage/update_test.go b/cmd/workpackage/update_test.go new file mode 100644 index 0000000..47bbbd2 --- /dev/null +++ b/cmd/workpackage/update_test.go @@ -0,0 +1,298 @@ +package workpackage + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/spf13/cobra" + + openerrors "github.com/opf/openproject-cli/components/errors" +) + +// newUpdateTestCmd builds a fresh *cobra.Command with the same flags +// registered on the real updateCmd, bound to the shared package-level +// vars. Using a fresh command per test keeps pflag's Changed() state +// isolated between test cases. +func newUpdateTestCmd() *cobra.Command { + cmd := &cobra.Command{Use: "update"} + cmd.Flags().StringVarP(&updateActionFlag, "action", "a", "", "") + cmd.Flags().Uint64Var(&updateAssigneeFlag, "assignee", 0, "") + cmd.Flags().StringVar(&updateAttachFlag, "attach", "", "") + cmd.Flags().StringVar(&updateDescriptionFlag, "description", "", "") + cmd.Flags().StringVar(&updateSubjectFlag, "subject", "", "") + cmd.Flags().StringVarP(&updateTypeFlag, "type", "t", "", "") + cmd.Flags().StringVar(&updateStatusFlag, "status", "", "") + cmd.Flags().StringArrayVar(&updateSetFlags, "set", nil, "") + cmd.Flags().BoolVar(&updateDryRun, "dry-run", false, "") + return cmd +} + +func resetUpdateFlags() { + updateActionFlag = "" + updateAssigneeFlag = 0 + updateAttachFlag = "" + updateDescriptionFlag = "" + updateSubjectFlag = "" + updateTypeFlag = "" + updateStatusFlag = "" + updateSetFlags = nil + updateDryRun = false +} + +func TestUpdateSetDryRunRendersPlanWithoutPatching(t *testing.T) { + testingPrinter, requestCount := initWorkPackageTestServer(t, func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + switch { + case request.Method == http.MethodGet && request.URL.Path == "/api/v3/work_packages/42": + _, _ = io.WriteString(response, `{ + "id": 42, + "subject": "Example", + "lockVersion": 3, + "_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, `{ + "customField130": {"name": "Story points", "type": "Integer", "writable": true} + }`) + default: + t.Fatalf("unexpected request: %s %s", request.Method, request.URL.Path) + } + }) + + resetUpdateFlags() + cmd := newUpdateTestCmd() + if err := cmd.Flags().Parse([]string{"--set", "Story points=5", "--dry-run"}); err != nil { + t.Fatal(err) + } + t.Cleanup(resetUpdateFlags) + + if err := updateWorkPackage(cmd, []string{"42"}); err != nil { + t.Fatalf("updateWorkPackage error = %v, want nil", err) + } + + if *requestCount != 2 { + t.Errorf("request count = %d, want 2 (fetch + schema, no patch)", *requestCount) + } + if !strings.Contains(testingPrinter.Result, `"resolved_fields"`) { + t.Errorf("expected update plan output, got: %q", testingPrinter.Result) + } + var plan map[string]any + if err := json.Unmarshal([]byte(testingPrinter.Result), &plan); err != nil { + t.Fatalf("failed to parse plan output: %v", err) + } + resolved, ok := plan["resolved_fields"].(map[string]any) + if !ok { + t.Fatalf("expected resolved_fields object, got %#v", plan["resolved_fields"]) + } + if _, ok := resolved["Story points"]; !ok { + t.Errorf("expected resolved field for Story points, got %#v", resolved) + } +} + +func TestUpdateSetLivePatchesAndRendersDetails(t *testing.T) { + var patchBody map[string]any + + testingPrinter, requestCount := initWorkPackageTestServer(t, func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + switch { + case request.Method == http.MethodGet && request.URL.Path == "/api/v3/work_packages/42": + _, _ = io.WriteString(response, `{ + "id": 42, + "subject": "Example", + "lockVersion": 3, + "_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.Method == http.MethodPatch && request.URL.Path == "/api/v3/work_packages/42": + if err := json.NewDecoder(request.Body).Decode(&patchBody); err != nil { + t.Fatal(err) + } + response.WriteHeader(http.StatusOK) + _, _ = io.WriteString(response, `{}`) + case request.URL.Path == "/api/v3/work_packages/schemas/1-6": + _, _ = io.WriteString(response, `{ + "customField130": {"name": "Story points", "type": "Integer", "writable": true} + }`) + default: + t.Fatalf("unexpected request: %s %s", request.Method, request.URL.Path) + } + }) + + resetUpdateFlags() + cmd := newUpdateTestCmd() + if err := cmd.Flags().Parse([]string{"--set", "Story points=5"}); err != nil { + t.Fatal(err) + } + t.Cleanup(resetUpdateFlags) + + if err := updateWorkPackage(cmd, []string{"42"}); err != nil { + t.Fatalf("updateWorkPackage error = %v, want nil", err) + } + + if patchBody["customField130"] != float64(5) { + t.Errorf("expected patch body to include customField130=5, got %#v", patchBody) + } + // UpdateFields: fetch + schema + patch (3). Inspect: fetch + schema (2). + if *requestCount != 5 { + t.Errorf("request count = %d, want 5 (fetch, schema, patch, fetch, schema)", *requestCount) + } + if !strings.Contains(testingPrinter.Result, `"subject"`) { + t.Errorf("expected work package details output, got: %q", testingPrinter.Result) + } +} + +func TestUpdateSetCombinedWithOtherFlagsErrors(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) + }) + + resetUpdateFlags() + cmd := newUpdateTestCmd() + if err := cmd.Flags().Parse([]string{"--set", "Story points=5", "--subject", "New subject"}); err != nil { + t.Fatal(err) + } + t.Cleanup(resetUpdateFlags) + + err := updateWorkPackage(cmd, []string{"42"}) + if !errors.Is(err, openerrors.ErrHandled) { + t.Fatalf("updateWorkPackage error = %v, want ErrHandled", err) + } + if *requestCount != 0 { + t.Errorf("request count = %d, want 0", *requestCount) + } + if !strings.Contains(testingPrinter.ErrResult, "cannot combine --set with other update flags") { + t.Errorf("stderr = %q, want combine-flags diagnostic", testingPrinter.ErrResult) + } +} + +func TestUpdateStatusPatchesWorkPackage(t *testing.T) { + var patchBody map[string]any + + testingPrinter, requestCount := initWorkPackageTestServer(t, func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + switch { + case request.Method == http.MethodGet && request.URL.Path == "/api/v3/work_packages/42": + _, _ = io.WriteString(response, `{ + "id": 42, + "subject": "Example", + "lockVersion": 3, + "_links": { + "self": {"href": "/api/v3/work_packages/42"}, + "project": {"href": "/api/v3/projects/1", "title": "CLI"}, + "status": {"href": "/api/v3/statuses/1", "title": "New"} + } + }`) + case request.Method == http.MethodPatch && request.URL.Path == "/api/v3/work_packages/42": + if err := json.NewDecoder(request.Body).Decode(&patchBody); err != nil { + t.Fatal(err) + } + response.WriteHeader(http.StatusOK) + _, _ = io.WriteString(response, `{}`) + case request.Method == http.MethodGet && request.URL.Path == "/api/v3/statuses": + _, _ = io.WriteString(response, `{ + "_embedded": { + "elements": [ + {"id": 1, "name": "New"}, + {"id": 2, "name": "In development"} + ] + } + }`) + default: + t.Fatalf("unexpected request: %s %s", request.Method, request.URL.Path) + } + }) + + resetUpdateFlags() + cmd := newUpdateTestCmd() + if err := cmd.Flags().Parse([]string{"--status", "In development"}); err != nil { + t.Fatal(err) + } + t.Cleanup(resetUpdateFlags) + + if err := updateWorkPackage(cmd, []string{"42"}); err != nil { + t.Fatalf("updateWorkPackage error = %v, want nil", err) + } + + links, ok := patchBody["_links"].(map[string]any) + if !ok { + t.Fatalf("expected links object in patch body, got %#v", patchBody["_links"]) + } + status, ok := links["status"].(map[string]any) + if !ok { + t.Fatalf("expected status link object, got %#v", links["status"]) + } + if status["href"] != "/api/v3/statuses/2" { + t.Errorf("expected status href /api/v3/statuses/2, got %#v", status["href"]) + } + if *requestCount == 0 { + t.Errorf("expected requests to have been made") + } + if strings.Contains(testingPrinter.ErrResult, "[ERROR]") { + t.Errorf("expected no error diagnostic, got: %q", testingPrinter.ErrResult) + } +} + +func TestUpdateStatusDryRunRendersResolvedStatusWithoutPatching(t *testing.T) { + testingPrinter, requestCount := initWorkPackageTestServer(t, func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + switch { + case request.Method == http.MethodGet && request.URL.Path == "/api/v3/work_packages/42": + _, _ = io.WriteString(response, `{ + "id": 42, + "subject": "Example", + "lockVersion": 3, + "_links": { + "self": {"href": "/api/v3/work_packages/42"}, + "project": {"href": "/api/v3/projects/1", "title": "CLI"}, + "status": {"href": "/api/v3/statuses/1", "title": "New"} + } + }`) + case request.Method == http.MethodGet && request.URL.Path == "/api/v3/statuses": + _, _ = io.WriteString(response, `{ + "_embedded": { + "elements": [ + {"id": 1, "name": "New"}, + {"id": 2, "name": "In development"} + ] + } + }`) + default: + t.Fatalf("unexpected request: %s %s", request.Method, request.URL.Path) + } + }) + + resetUpdateFlags() + cmd := newUpdateTestCmd() + if err := cmd.Flags().Parse([]string{"--status", "in development", "--dry-run"}); err != nil { + t.Fatal(err) + } + t.Cleanup(resetUpdateFlags) + + if err := updateWorkPackage(cmd, []string{"42"}); err != nil { + t.Fatalf("updateWorkPackage error = %v, want nil", err) + } + + if !strings.Contains(testingPrinter.Result, `"status": "In development"`) && + !strings.Contains(testingPrinter.Result, `"status":"In development"`) { + t.Errorf("expected resolved status name in plan output, got: %q", testingPrinter.Result) + } + // only the work package fetch and the status lookup, no PATCH. + if *requestCount != 2 { + t.Errorf("request count = %d, want 2 (fetch + status list, no patch)", *requestCount) + } +} diff --git a/cmd/workpackage/workpackage.go b/cmd/workpackage/workpackage.go index 08bcaa3..630844a 100644 --- a/cmd/workpackage/workpackage.go +++ b/cmd/workpackage/workpackage.go @@ -84,6 +84,24 @@ func init() { "", "Change the work package type", ) + updateCmd.Flags().StringVar( + &updateStatusFlag, + "status", + "", + "Change the status of the work package by name", + ) + updateCmd.Flags().StringArrayVar( + &updateSetFlags, + "set", + nil, + "Set a field by label or API name, e.g. --set \"Story points=5\"", + ) + updateCmd.Flags().BoolVar( + &updateDryRun, + "dry-run", + false, + "Validate and show the resulting plan without applying changes", + ) inspectCmd.Flags().BoolVarP( &inspectOpenInBrowser, From b75be7cc038e8abd27c31ad5ed2c31bd085be8e1 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 25 Jul 2026 23:08:48 +0200 Subject: [PATCH 12/13] [#74415] add create --parent and --dry-run Create can link a parent work package at creation time and preview the draft as a dry-run plan via --format json or text. https://community.openproject.org/wp/CLI-12 --- cmd/workpackage/create.go | 19 +++++ cmd/workpackage/create_test.go | 133 +++++++++++++++++++++++++++++++++ cmd/workpackage/workpackage.go | 12 +++ 3 files changed, 164 insertions(+) diff --git a/cmd/workpackage/create.go b/cmd/workpackage/create.go index 61f1d29..d89aca6 100644 --- a/cmd/workpackage/create.go +++ b/cmd/workpackage/create.go @@ -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]", @@ -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) { @@ -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 } diff --git a/cmd/workpackage/create_test.go b/cmd/workpackage/create_test.go index 8868fb8..111ac9d 100644 --- a/cmd/workpackage/create_test.go +++ b/cmd/workpackage/create_test.go @@ -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 { diff --git a/cmd/workpackage/workpackage.go b/cmd/workpackage/workpackage.go index 630844a..046d371 100644 --- a/cmd/workpackage/workpackage.go +++ b/cmd/workpackage/workpackage.go @@ -45,6 +45,18 @@ func init() { "", "Description of the work package (markdown)", ) + createCmd.Flags().Uint64Var( + &createParentID, + "parent", + 0, + "ID of the parent work package", + ) + createCmd.Flags().BoolVar( + &createDryRun, + "dry-run", + false, + "Validate and show the resulting plan without creating the work package", + ) updateCmd.Flags().StringVarP( &updateActionFlag, From 2843e4d41e72392ecfe339902d0069cae6c02a38 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 25 Jul 2026 23:16:59 +0200 Subject: [PATCH 13/13] [#74415] document JSON work package workflows Adds README examples for inspect --children, update --set/--status, and create --parent, all composable with --dry-run and the global --format json flag, so scripts and agents can discover the final work package flag surface without reading the source. https://community.openproject.org/wp/CLI-12 --- README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/README.md b/README.md index a94deca..5d35c12 100644 --- a/README.md +++ b/README.md @@ -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; @@ -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 @@ -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. +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 @@ -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.