From 04c6bc6f0ef05177de652afd09b88dc6fd4c04f3 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 31 Mar 2026 17:12:42 +0200 Subject: [PATCH 01/39] Add CLAUDE.md with developer conventions and architecture guide --- .gitignore | 3 ++ CLAUDE.md | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index f9b658d..98569df 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ openproject-cli # Go workspace file go.work + +# Claude local settings file +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6d3f390 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# OpenProject CLI — Developer Guide + +## Build & Run + +```bash +# Build +go build -o op . + +# Run tests +go test ./... + +# Run a specific test package +go test ./components/printer/... + +# Install locally +go install . +``` + +## Architecture + +The codebase is organized in strict layers. Each layer only depends on layers below it. + +``` +cmd/ # Cobra commands — CLI entry points only, no business logic +components/ + resources/ # Business logic — API calls, option handling + paths/ # API path constants (paths.go — single file) + requests/ # HTTP client (GET, POST, PATCH) + parser/ # JSON response parsing + printer/ # Terminal output formatting + routes/ # Browser URL generation + common/ # Shared utilities (string, slice, math) + configuration/ # Config file reading, CLI version + launch/ # Browser launcher +models/ # Domain models (plain structs, no logic) +dtos/ # JSON DTOs with Convert() to models +``` + +### Data flow + +API response → `parser.Parse[SomethingDto]()` → `dto.Convert()` → `models.Something` → `printer.Something()` + +### DTO conventions + +- DTOs live in `dtos/`, named `Dto` +- DTOs mirror the OpenProject API v3 HAL JSON structure +- Links use `*LinkDto` with `Href` and `Title` fields, serialized as `_links` +- Every DTO implements `Convert() *models.Something` to produce a domain model +- Collections follow the pattern: `CollectionDto` with `Embedded.Elements` +- `omitempty` on all JSON fields in DTOs used for POST/PATCH bodies + +### Command conventions + +- Commands follow `op ` (verb-first) +- Each verb has its own package under `cmd/` (`create`, `list`, `update`, `inspect`, `search`) +- Each verb package exposes a `RootCmd` registered in `cmd/root.go` +- One file per resource within each verb package (e.g. `cmd/list/work_packages.go`) +- Flags: always define long flag names; add short flags (`-p`, `-o`) for frequently used ones +- Flags that resolve a resource (e.g. `--type`, `--assignee`) perform an API lookup and store the resolved link in the DTO + +### Resource conventions + +- Each resource has its own package under `components/resources/` +- Operation types use `iota` enums: `CreateOption`, `UpdateOption`, `FilterOption` +- Operations are dispatched via a `map[Option]func(...)` — add new options by extending the map +- Public API: `Create(...)`, `Lookup(id)`, `All(filters, query, ...)`, `Update(id, options)` + +### Paths conventions + +- All API paths are defined in `components/paths/paths.go` +- Functions are named after the resource: `WorkPackage(id)`, `WorkPackages()`, `ProjectWorkPackages(projectId)` +- All paths are relative (no host), starting with `/api/v3` + +### Printer conventions + +- All terminal output goes through `printer/` — never `fmt.Println` directly in commands +- Color scheme: Red = ID, Green = type, Cyan = subject/name, Yellow = status +- `printer.Error(err)` for errors, `printer.ErrorText(msg)` for plain error strings +- `printer.Info(msg)` for progress messages, `printer.Done()` after successful mutations + +## Testing conventions + +- Test files use external test packages: `package printer_test`, `package requests_test` +- `TestMain` in `printer_test` initializes shared state (routes, printer) for the package +- Tests use plain `t.Errorf` — no test framework, no assertions library +- Tests only exist for `printer`, `requests`, and `common` — no tests on `cmd/` or `resources/` +- When adding a new printer function, add a corresponding test in `components/printer/` + +## Dependencies + +| Package | Purpose | +|---------|---------| +| `github.com/spf13/cobra` | CLI framework | +| `github.com/fatih/color` | Terminal colors (via printer) | +| `github.com/briandowns/spinner` | Progress spinner | +| `github.com/go-git/go-git/v5` | Git integration (`op git` commands) | +| `github.com/sosodev/duration` | ISO 8601 duration parsing | From d8babc5b6f395b3a40aa538ff1a71573e3e30a95 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 31 Mar 2026 18:36:15 +0200 Subject: [PATCH 02/39] Add noun-first commands alongside existing verb-first commands New noun-first commands (old verb-first commands kept during transition): op workpackage list/create/update/inspect (was: op list/create/update/inspect workpackage[s]) op activities list [--wp id] (was: op list activities [id]) op project list/inspect (was: op list/inspect project[s]) op user search (was: op search user) op timeentry list (was: op list timeentries) op type list (was: op list types) op status list (was: op list status) op notification list (was: op list notifications) op activities list uses --wp flag instead of positional argument to allow future scoping (--project, global) without breaking the interface. op git start workpackage is unchanged. --- cmd/activities/activities.go | 21 ++++ cmd/activities/list.go | 45 +++++++++ cmd/notification/list.go | 36 +++++++ cmd/notification/notification.go | 21 ++++ cmd/project/inspect.go | 50 +++++++++ cmd/project/list.go | 24 +++++ cmd/project/project.go | 21 ++++ cmd/root.go | 18 ++++ cmd/status/list.go | 23 +++++ cmd/status/status.go | 13 +++ cmd/timeentry/list.go | 51 ++++++++++ cmd/timeentry/list_flags.go | 13 +++ cmd/timeentry/timeentry.go | 15 +++ cmd/user/search.go | 49 +++++++++ cmd/user/user.go | 13 +++ cmd/workpackage/create.go | 55 ++++++++++ cmd/workpackage/inspect.go | 72 +++++++++++++ cmd/workpackage/list.go | 168 +++++++++++++++++++++++++++++++ cmd/workpackage/list_flags.go | 65 ++++++++++++ cmd/workpackage/update.go | 65 ++++++++++++ cmd/workpackage/workpackage.go | 85 ++++++++++++++++ cmd/wptype/list.go | 23 +++++ cmd/wptype/wptype.go | 13 +++ 23 files changed, 959 insertions(+) create mode 100644 cmd/activities/activities.go create mode 100644 cmd/activities/list.go create mode 100644 cmd/notification/list.go create mode 100644 cmd/notification/notification.go create mode 100644 cmd/project/inspect.go create mode 100644 cmd/project/list.go create mode 100644 cmd/project/project.go create mode 100644 cmd/status/list.go create mode 100644 cmd/status/status.go create mode 100644 cmd/timeentry/list.go create mode 100644 cmd/timeentry/list_flags.go create mode 100644 cmd/timeentry/timeentry.go create mode 100644 cmd/user/search.go create mode 100644 cmd/user/user.go create mode 100644 cmd/workpackage/create.go create mode 100644 cmd/workpackage/inspect.go create mode 100644 cmd/workpackage/list.go create mode 100644 cmd/workpackage/list_flags.go create mode 100644 cmd/workpackage/update.go create mode 100644 cmd/workpackage/workpackage.go create mode 100644 cmd/wptype/list.go create mode 100644 cmd/wptype/wptype.go diff --git a/cmd/activities/activities.go b/cmd/activities/activities.go new file mode 100644 index 0000000..84e7d89 --- /dev/null +++ b/cmd/activities/activities.go @@ -0,0 +1,21 @@ +package activities + +import "github.com/spf13/cobra" + +var RootCmd = &cobra.Command{ + Use: "activities [verb]", + Short: "Manage activities", + Long: "List activities scoped by work package, project, or globally.", +} + +func init() { + listCmd.Flags().Uint64VarP( + &listWpId, + "wp", + "", + 0, + "Work package ID to list activities for", + ) + + RootCmd.AddCommand(listCmd) +} diff --git a/cmd/activities/list.go b/cmd/activities/list.go new file mode 100644 index 0000000..2d9c512 --- /dev/null +++ b/cmd/activities/list.go @@ -0,0 +1,45 @@ +package activities + +import ( + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/resources/users" + "github.com/opf/openproject-cli/components/resources/work_packages" +) + +var listWpId uint64 + +var listCmd = &cobra.Command{ + Use: "list", + Short: "Lists activities", + Long: "Get a list of activities, scoped by the provided flag (e.g. --wp).", + Run: listActivities, +} + +func listActivities(_ *cobra.Command, _ []string) { + if listWpId > 0 { + listWorkPackageActivities(listWpId) + return + } + + printer.ErrorText("Please specify a scope. Example: --wp [id]") +} + +func listWorkPackageActivities(wpId uint64) { + acts, err := work_packages.Activities(wpId) + if err != nil { + printer.ErrorText(err.Error()) + return + } + + var userIds []uint64 + for _, a := range acts { + if a.UserId > 0 { + userIds = append(userIds, a.UserId) + } + } + + userList := users.ByIds(userIds) + printer.Activities(acts, userList) +} diff --git a/cmd/notification/list.go b/cmd/notification/list.go new file mode 100644 index 0000000..424be1f --- /dev/null +++ b/cmd/notification/list.go @@ -0,0 +1,36 @@ +package notification + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/common" + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/resources/notifications" +) + +var listReason string + +var validReasons = []string{"", "assigned", "mentioned", "responsible", "watched", "dateAlert"} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "Lists notifications", + Long: `Get a list of unread notifications. +The list can get filtered further.`, + Run: listNotifications, +} + +func listNotifications(_ *cobra.Command, _ []string) { + if !common.Contains(validReasons, listReason) { + printer.ErrorText(fmt.Sprintf("Reason '%s' is invalid.", listReason)) + return + } + + if all, err := notifications.All(listReason); err == nil { + printer.Notifications(all) + } else { + printer.Error(err) + } +} diff --git a/cmd/notification/notification.go b/cmd/notification/notification.go new file mode 100644 index 0000000..4fad4b2 --- /dev/null +++ b/cmd/notification/notification.go @@ -0,0 +1,21 @@ +package notification + +import "github.com/spf13/cobra" + +var RootCmd = &cobra.Command{ + Use: "notification [verb]", + Short: "Manage notifications", + Long: "List notifications in OpenProject.", +} + +func init() { + listCmd.Flags().StringVarP( + &listReason, + "reason", + "r", + "", + "The reason for the notification", + ) + + RootCmd.AddCommand(listCmd) +} diff --git a/cmd/project/inspect.go b/cmd/project/inspect.go new file mode 100644 index 0000000..8c7b5fd --- /dev/null +++ b/cmd/project/inspect.go @@ -0,0 +1,50 @@ +package project + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/launch" + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/resources/projects" + "github.com/opf/openproject-cli/components/routes" +) + +var openInBrowser bool + +var inspectCmd = &cobra.Command{ + Use: "inspect [id]", + Short: "Show details about a project", + Long: "Show detailed information of a project referenced by it's ID.", + Run: inspectProject, +} + +func inspectProject(_ *cobra.Command, args []string) { + if len(args) != 1 { + printer.ErrorText(fmt.Sprintf("Expected 1 argument [id], but got %d", len(args))) + return + } + + id, err := strconv.ParseUint(args[0], 10, 64) + if err != nil { + printer.ErrorText(fmt.Sprintf("'%s' is an invalid project id. Must be a number.", args[0])) + return + } + + project, err := projects.Lookup(id) + if err != nil { + printer.Error(err) + return + } + + if openInBrowser { + err = launch.Browser(routes.ProjectUrl(project)) + if err != nil { + printer.ErrorText(fmt.Sprintf("Error opening browser: %+v", err)) + } + } else { + printer.Project(project) + } +} diff --git a/cmd/project/list.go b/cmd/project/list.go new file mode 100644 index 0000000..52e8fad --- /dev/null +++ b/cmd/project/list.go @@ -0,0 +1,24 @@ +package project + +import ( + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/resources/projects" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "Lists projects", + Long: `Get a list of visible projects. +The list can get filtered further.`, + Run: listProjects, +} + +func listProjects(_ *cobra.Command, _ []string) { + if all, err := projects.All(); err == nil { + printer.Projects(all) + } else { + printer.Error(err) + } +} diff --git a/cmd/project/project.go b/cmd/project/project.go new file mode 100644 index 0000000..34b958d --- /dev/null +++ b/cmd/project/project.go @@ -0,0 +1,21 @@ +package project + +import "github.com/spf13/cobra" + +var RootCmd = &cobra.Command{ + Use: "project [verb]", + Short: "Manage projects", + Long: "List and inspect projects in OpenProject.", +} + +func init() { + inspectCmd.Flags().BoolVarP( + &openInBrowser, + "open", + "o", + false, + "Open the project in the default browser", + ) + + RootCmd.AddCommand(listCmd, inspectCmd) +} diff --git a/cmd/root.go b/cmd/root.go index ce21db8..e2ce190 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,12 +9,20 @@ import ( "github.com/spf13/cobra" + "github.com/opf/openproject-cli/cmd/activities" "github.com/opf/openproject-cli/cmd/create" "github.com/opf/openproject-cli/cmd/git" "github.com/opf/openproject-cli/cmd/inspect" "github.com/opf/openproject-cli/cmd/list" + "github.com/opf/openproject-cli/cmd/notification" + "github.com/opf/openproject-cli/cmd/project" "github.com/opf/openproject-cli/cmd/search" + "github.com/opf/openproject-cli/cmd/status" + "github.com/opf/openproject-cli/cmd/timeentry" "github.com/opf/openproject-cli/cmd/update" + "github.com/opf/openproject-cli/cmd/user" + "github.com/opf/openproject-cli/cmd/workpackage" + "github.com/opf/openproject-cli/cmd/wptype" "github.com/opf/openproject-cli/components/configuration" "github.com/opf/openproject-cli/components/printer" "github.com/opf/openproject-cli/components/requests" @@ -92,6 +100,16 @@ func init() { rootCmd.AddCommand( loginCmd, + // noun-first (new) + activities.RootCmd, + workpackage.RootCmd, + project.RootCmd, + user.RootCmd, + timeentry.RootCmd, + wptype.RootCmd, + status.RootCmd, + notification.RootCmd, + // verb-first (kept during transition) list.RootCmd, update.RootCmd, inspect.RootCmd, diff --git a/cmd/status/list.go b/cmd/status/list.go new file mode 100644 index 0000000..ec28437 --- /dev/null +++ b/cmd/status/list.go @@ -0,0 +1,23 @@ +package status + +import ( + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/printer" + wpstatus "github.com/opf/openproject-cli/components/resources/status" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "Lists statuses", + Long: "Get a list of all statuses of the instance.", + Run: listStatuses, +} + +func listStatuses(_ *cobra.Command, _ []string) { + if all, err := wpstatus.All(); err == nil { + printer.StatusList(all) + } else { + printer.Error(err) + } +} diff --git a/cmd/status/status.go b/cmd/status/status.go new file mode 100644 index 0000000..7397c1d --- /dev/null +++ b/cmd/status/status.go @@ -0,0 +1,13 @@ +package status + +import "github.com/spf13/cobra" + +var RootCmd = &cobra.Command{ + Use: "status [verb]", + Short: "Manage statuses", + Long: "List work package statuses in OpenProject.", +} + +func init() { + RootCmd.AddCommand(listCmd) +} diff --git a/cmd/timeentry/list.go b/cmd/timeentry/list.go new file mode 100644 index 0000000..e53b2b1 --- /dev/null +++ b/cmd/timeentry/list.go @@ -0,0 +1,51 @@ +package timeentry + +import ( + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/requests" + "github.com/opf/openproject-cli/components/resources" + "github.com/opf/openproject-cli/components/resources/time_entries" + "github.com/opf/openproject-cli/components/resources/time_entries/filters" +) + +var activeTimeEntryFilters = map[string]resources.Filter{ + "user": filters.NewUserFilter(), +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "Lists time entries", + Long: "Get a list of all time entries.", + Run: listTimeEntries, +} + +func listTimeEntries(_ *cobra.Command, _ []string) { + query, err := buildTimeEntriesQuery() + if err != nil { + printer.ErrorText(err.Error()) + return + } + + if all, err := time_entries.All(query); err == nil { + printer.TimeEntryList(all) + } else { + printer.Error(err) + } +} + +func buildTimeEntriesQuery() (requests.Query, error) { + var q requests.Query + + for _, filter := range activeTimeEntryFilters { + err := filter.ValidateInput() + if err != nil { + return requests.NewEmptyQuery(), err + } + + q = q.Merge(filter.Query()) + } + + return q, nil +} diff --git a/cmd/timeentry/list_flags.go b/cmd/timeentry/list_flags.go new file mode 100644 index 0000000..cd8b226 --- /dev/null +++ b/cmd/timeentry/list_flags.go @@ -0,0 +1,13 @@ +package timeentry + +func initListFlags() { + for _, filter := range activeTimeEntryFilters { + listCmd.Flags().StringVarP( + filter.ValuePointer(), + filter.Name(), + filter.ShortHand(), + filter.DefaultValue(), + filter.Usage(), + ) + } +} diff --git a/cmd/timeentry/timeentry.go b/cmd/timeentry/timeentry.go new file mode 100644 index 0000000..27d2cc0 --- /dev/null +++ b/cmd/timeentry/timeentry.go @@ -0,0 +1,15 @@ +package timeentry + +import "github.com/spf13/cobra" + +var RootCmd = &cobra.Command{ + Use: "timeentry [verb]", + Short: "Manage time entries", + Long: "List time entries in OpenProject.", +} + +func init() { + initListFlags() + + RootCmd.AddCommand(listCmd) +} diff --git a/cmd/user/search.go b/cmd/user/search.go new file mode 100644 index 0000000..b2de431 --- /dev/null +++ b/cmd/user/search.go @@ -0,0 +1,49 @@ +package user + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/common" + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/resources/users" +) + +var keywords = []string{"me"} + +var searchCmd = &cobra.Command{ + Use: "search [searchInput]", + Short: "Searches for a user", + Long: "Searches for a user by id, keyword, or name. Returns a list of possible matches.", + Run: searchUser, +} + +func searchUser(_ *cobra.Command, args []string) { + if len(args) != 1 { + printer.ErrorText(fmt.Sprintf("Expected 1 argument [searchInput], but got %d", len(args))) + return + } + + if common.Contains(keywords, args[0]) { + me, err := users.Me() + if err != nil { + printer.Error(err) + } else { + printer.User(me) + } + return + } + + collection, err := users.Search(args[0]) + if err != nil { + printer.Error(err) + return + } + + if len(collection) == 0 { + printer.Info(fmt.Sprintf("No user found for search input %s.", printer.Cyan(args[0]))) + } else { + printer.Users(collection) + } +} diff --git a/cmd/user/user.go b/cmd/user/user.go new file mode 100644 index 0000000..52bf1e3 --- /dev/null +++ b/cmd/user/user.go @@ -0,0 +1,13 @@ +package user + +import "github.com/spf13/cobra" + +var RootCmd = &cobra.Command{ + Use: "user [verb]", + Short: "Manage users", + Long: "Search and inspect users in OpenProject.", +} + +func init() { + RootCmd.AddCommand(searchCmd) +} diff --git a/cmd/workpackage/create.go b/cmd/workpackage/create.go new file mode 100644 index 0000000..d0ff233 --- /dev/null +++ b/cmd/workpackage/create.go @@ -0,0 +1,55 @@ +package workpackage + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/launch" + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/resources/work_packages" + "github.com/opf/openproject-cli/components/routes" +) + +var createProjectId uint64 +var createOpenInBrowser bool +var createTypeFlag string + +var createCmd = &cobra.Command{ + Use: "create [subject]", + Short: "Create work package in project", + Long: "Create a new work package with the given subject in a project", + Run: createWorkPackage, +} + +func createWorkPackage(_ *cobra.Command, args []string) { + if len(args) != 1 { + printer.ErrorText(fmt.Sprintf("Expected 1 argument [subject], but got %d", len(args))) + return + } + + subject := args[0] + workPackage, err := work_packages.Create(createProjectId, createOptions(subject)) + if err != nil { + printer.Error(err) + return + } + + if createOpenInBrowser { + err = launch.Browser(routes.WorkPackageUrl(workPackage)) + if err != nil { + printer.ErrorText(fmt.Sprintf("Error opening browser: %+v", err)) + } + } else { + printer.WorkPackage(workPackage) + } +} + +func createOptions(subject string) map[work_packages.CreateOption]string { + options := make(map[work_packages.CreateOption]string) + options[work_packages.CreateSubject] = subject + if len(createTypeFlag) > 0 { + options[work_packages.CreateType] = createTypeFlag + } + return options +} diff --git a/cmd/workpackage/inspect.go b/cmd/workpackage/inspect.go new file mode 100644 index 0000000..e3d437f --- /dev/null +++ b/cmd/workpackage/inspect.go @@ -0,0 +1,72 @@ +package workpackage + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/launch" + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/resources/work_packages" + "github.com/opf/openproject-cli/components/routes" +) + +var inspectOpenInBrowser bool +var inspectListAvailableTypes bool + +var inspectCmd = &cobra.Command{ + Use: "inspect [id]", + Short: "Show details about a work package", + Long: "Show detailed information of a work package referenced by it's ID.", + Run: inspectWorkPackage, +} + +func inspectWorkPackage(_ *cobra.Command, args []string) { + if len(args) != 1 { + printer.ErrorText(fmt.Sprintf("Expected 1 argument [id], but got %d", len(args))) + return + } + + id, err := strconv.ParseUint(args[0], 10, 64) + if err != nil { + printer.ErrorText(fmt.Sprintf("'%s' is an invalid work package id. Must be a number.", args[0])) + return + } + + if inspectHasListingFlag() { + switch { + case inspectListAvailableTypes: + inspectAvailableTypes(id) + } + return + } + + workPackage, err := work_packages.Lookup(id) + if err != nil { + printer.Error(err) + return + } + + if inspectOpenInBrowser { + err = launch.Browser(routes.WorkPackageUrl(workPackage)) + if err != nil { + printer.ErrorText(fmt.Sprintf("Error opening browser: %+v", err)) + } + } else { + printer.WorkPackage(workPackage) + } +} + +func inspectAvailableTypes(id uint64) { + types, err := work_packages.AvailableTypes(id) + if err != nil { + printer.Error(err) + return + } + printer.Types(types) +} + +func inspectHasListingFlag() bool { + return inspectListAvailableTypes +} diff --git a/cmd/workpackage/list.go b/cmd/workpackage/list.go new file mode 100644 index 0000000..b9394f5 --- /dev/null +++ b/cmd/workpackage/list.go @@ -0,0 +1,168 @@ +package workpackage + +import ( + "fmt" + "os" + "regexp" + "strconv" + + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/common" + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/requests" + "github.com/opf/openproject-cli/components/resources" + "github.com/opf/openproject-cli/components/resources/projects" + "github.com/opf/openproject-cli/components/resources/work_packages" + "github.com/opf/openproject-cli/components/resources/work_packages/filters" + "github.com/opf/openproject-cli/models" +) + +var listAssignee string +var listProjectId uint64 +var listShowTotal bool +var listStatusFilter string +var listTypeFilter string +var listIncludeSubProjects bool + +var activeFilters = map[string]resources.Filter{ + "notSubProject": filters.NewNotSubProjectFilter(), + "notVersion": filters.NewNotVersionFilter(), + "subProject": filters.NewSubProjectFilter(), + "timestamp": filters.NewTimestampFilter(), + "version": filters.NewVersionFilter(), +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "Lists work packages", + Long: "Get a list of visible work packages. Filter flags can be applied.", + Run: listWorkPackages, +} + +func listWorkPackages(_ *cobra.Command, _ []string) { + if errorText := validateCommandFlagComposition(); len(errorText) > 0 { + printer.ErrorText(errorText) + return + } + + query, err := buildQuery() + if err != nil { + printer.ErrorText(err.Error()) + return + } + + collection, err := work_packages.All(filterOptions(), query, listShowTotal) + switch { + case err == nil && listShowTotal: + printer.Number(collection.Total) + case err == nil: + printer.WorkPackages(collection.Items) + default: + printer.Error(err) + } +} + +func validateCommandFlagComposition() (errorText string) { + switch { + case len(activeFilters["version"].Value()) != 0 && listProjectId == 0: + return "Version flag (--version) can only be used in conjunction with projectId flag (-p or --project-id)." + case len(activeFilters["notVersion"].Value()) != 0 && listProjectId == 0: + return "Not version filter flag (--not-version) can only be used in conjunction with projectId flag (-p or --project-id)." + case len(activeFilters["subProject"].Value()) > 0 || len(activeFilters["notSubProject"].Value()) > 0: + if !listIncludeSubProjects || listProjectId == 0 { + return `Sub project filter flags (--sub-project or --not-sub-project) can only be used +in conjunction with setting the flag --include-sub-projects and setting a +project with the projectId flag (-p or --project-id).` + } + } + + return "" +} + +func buildQuery() (requests.Query, error) { + var q requests.Query + + for _, filter := range activeFilters { + if filter.Value() == filter.DefaultValue() { + continue + } + + err := filter.ValidateInput() + if err != nil { + return requests.NewEmptyQuery(), err + } + + q = q.Merge(filter.Query()) + } + + return q, nil +} + +func filterOptions() *map[work_packages.FilterOption]string { + options := make(map[work_packages.FilterOption]string) + + options[work_packages.IncludeSubProjects] = strconv.FormatBool(listIncludeSubProjects) + + if listProjectId > 0 { + options[work_packages.Project] = strconv.FormatUint(listProjectId, 10) + } + + if len(listAssignee) > 0 { + options[work_packages.Assignee] = listAssignee + } + + if len(listStatusFilter) > 0 { + options[work_packages.Status] = validateFilterValue(work_packages.Status, listStatusFilter) + } + + if len(listTypeFilter) > 0 { + options[work_packages.Type] = validateFilterValue(work_packages.Type, listTypeFilter) + } + + return &options +} + +func validatedVersionId(version string) string { + project, err := projects.Lookup(listProjectId) + if err != nil { + printer.Error(err) + } + + versions, err := projects.AvailableVersions(project.Id) + if err != nil { + printer.Error(err) + } + + filteredVersions := common.Filter(versions, func(v *models.Version) bool { + return v.Name == version + }) + + if len(filteredVersions) != 1 { + printer.Info(fmt.Sprintf( + "No unique available version from input %s found for projectId %s. Please use one of the versions listed below.", + printer.Cyan(version), + printer.Red(fmt.Sprintf("#%d", project.Id)), + )) + + printer.Versions(versions) + + os.Exit(-1) + } + + return strconv.FormatUint(filteredVersions[0].Id, 10) +} + +func validateFilterValue(filter work_packages.FilterOption, value string) string { + matched, err := regexp.Match(work_packages.InputValidationExpression[filter], []byte(value)) + if err != nil { + printer.Error(err) + } + + if !matched { + printer.ErrorText(fmt.Sprintf("Invalid %s value %s.", filter, printer.Yellow(value))) + os.Exit(-1) + } + + return value +} diff --git a/cmd/workpackage/list_flags.go b/cmd/workpackage/list_flags.go new file mode 100644 index 0000000..fb51fff --- /dev/null +++ b/cmd/workpackage/list_flags.go @@ -0,0 +1,65 @@ +package workpackage + +func initListFlags() { + listCmd.Flags().StringVarP( + &listAssignee, + "assignee", + "a", + "", + "Assignee of the work package (can be ID or 'me')", + ) + + listCmd.Flags().Uint64VarP( + &listProjectId, + "project-id", + "p", + 0, + "Show only work packages within the specified projectId") + + listCmd.Flags().StringVarP( + &listStatusFilter, + "status", + "s", + "", + `Show only work packages having the specified status. The value can be the +keywords 'open', 'closed', a single ID or a comma separated array of IDs, i.e. +'7,13'. Multiple values are concatenated with a logical 'OR'. If the IDs are +prefixed with an '!' the list is instead filtered to not have the specified +status.`) + + listCmd.Flags().StringVarP( + &listTypeFilter, + "type", + "t", + "", + `Show only work packages having the specified types. The value can be a single +ID or a comma separated array of IDs, i.e. '7,13'. Multiple values are +concatenated with a logical 'OR'. If the IDs are prefixed with an '!' the list +is instead filtered to not have the specified status.`) + + listCmd.Flags().BoolVarP( + &listIncludeSubProjects, + "include-sub-projects", + "", + false, + `If listing the work packages of a project, this flag indicates if work +packages of sub projects should be included in the list. If omitting the flag, +the default is false.`) + + listCmd.Flags().BoolVarP( + &listShowTotal, + "total", + "", + false, + "Show only the total number of work packages matching the filter options.") + + for _, filter := range activeFilters { + listCmd.Flags().StringVarP( + filter.ValuePointer(), + filter.Name(), + filter.ShortHand(), + filter.DefaultValue(), + filter.Usage(), + ) + } +} diff --git a/cmd/workpackage/update.go b/cmd/workpackage/update.go new file mode 100644 index 0000000..8b4f618 --- /dev/null +++ b/cmd/workpackage/update.go @@ -0,0 +1,65 @@ +package workpackage + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/resources/work_packages" +) + +var updateActionFlag string +var updateAssigneeFlag uint64 +var updateAttachFlag string +var updateSubjectFlag string +var updateTypeFlag string + +var updateCmd = &cobra.Command{ + Use: "update [id]", + Short: "Updates the work package", + Long: `Update a work package. Each update +provided by a flag is executed on its own.`, + Run: updateWorkPackage, +} + +func updateWorkPackage(_ *cobra.Command, args []string) { + if len(args) != 1 { + printer.ErrorText(fmt.Sprintf("Expected 1 argument [id], but got %d", len(args))) + return + } + + id, err := strconv.ParseUint(args[0], 10, 64) + if err != nil { + printer.ErrorText(fmt.Sprintf("'%s' is an invalid work package id. Must be a number.", args[0])) + return + } + + if workPackage, err := work_packages.Update(id, updateOptions()); err == nil { + printer.Info("-- ") + printer.WorkPackage(workPackage) + } else { + printer.Error(err) + } +} + +func updateOptions() map[work_packages.UpdateOption]string { + options := make(map[work_packages.UpdateOption]string) + if len(updateActionFlag) > 0 { + options[work_packages.UpdateCustomAction] = updateActionFlag + } + if updateAssigneeFlag > 0 { + options[work_packages.UpdateAssignee] = strconv.FormatUint(updateAssigneeFlag, 10) + } + if len(updateAttachFlag) > 0 { + options[work_packages.UpdateAttachment] = updateAttachFlag + } + if len(updateSubjectFlag) > 0 { + options[work_packages.UpdateSubject] = updateSubjectFlag + } + if len(updateTypeFlag) > 0 { + options[work_packages.UpdateType] = updateTypeFlag + } + return options +} diff --git a/cmd/workpackage/workpackage.go b/cmd/workpackage/workpackage.go new file mode 100644 index 0000000..56b53f7 --- /dev/null +++ b/cmd/workpackage/workpackage.go @@ -0,0 +1,85 @@ +package workpackage + +import "github.com/spf13/cobra" + +var RootCmd = &cobra.Command{ + Use: "workpackage [verb]", + Short: "Manage work packages", + Long: "Create, list, update, inspect, and manage work packages in OpenProject.", +} + +func init() { + initListFlags() + + createCmd.Flags().Uint64VarP( + &createProjectId, + "project", + "p", + 0, + "Project ID to create the work package in", + ) + _ = createCmd.MarkFlagRequired("project") + createCmd.Flags().BoolVarP( + &createOpenInBrowser, + "open", + "o", + false, + "Open the created work package in the default browser", + ) + createCmd.Flags().StringVarP( + &createTypeFlag, + "type", + "t", + "", + "Change the work package type", + ) + + updateCmd.Flags().StringVarP( + &updateActionFlag, + "action", + "a", + "", + "Executes a custom action on a work package", + ) + updateCmd.Flags().Uint64Var( + &updateAssigneeFlag, + "assignee", + 0, + "Assign a user to the work package", + ) + updateCmd.Flags().StringVar( + &updateAttachFlag, + "attach", + "", + "Attach a file to the work package", + ) + updateCmd.Flags().StringVar( + &updateSubjectFlag, + "subject", + "", + "Change the subject of the work package", + ) + updateCmd.Flags().StringVarP( + &updateTypeFlag, + "type", + "t", + "", + "Change the work package type", + ) + + inspectCmd.Flags().BoolVarP( + &inspectOpenInBrowser, + "open", + "o", + false, + "Open the work package in the default browser", + ) + inspectCmd.Flags().BoolVar( + &inspectListAvailableTypes, + "types", + false, + "List the available types on the work package.", + ) + + RootCmd.AddCommand(listCmd, createCmd, updateCmd, inspectCmd) +} diff --git a/cmd/wptype/list.go b/cmd/wptype/list.go new file mode 100644 index 0000000..5f4c338 --- /dev/null +++ b/cmd/wptype/list.go @@ -0,0 +1,23 @@ +package wptype + +import ( + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/resources/types" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "Lists work package types", + Long: "Get a list of all work package types of the instance.", + Run: listTypes, +} + +func listTypes(_ *cobra.Command, _ []string) { + if all, err := types.All(); err == nil { + printer.Types(all) + } else { + printer.Error(err) + } +} diff --git a/cmd/wptype/wptype.go b/cmd/wptype/wptype.go new file mode 100644 index 0000000..c643cbd --- /dev/null +++ b/cmd/wptype/wptype.go @@ -0,0 +1,13 @@ +package wptype + +import "github.com/spf13/cobra" + +var RootCmd = &cobra.Command{ + Use: "type [verb]", + Short: "Manage work package types", + Long: "List work package types in OpenProject.", +} + +func init() { + RootCmd.AddCommand(listCmd) +} From be6b36e35b95c1cae06e449907bf89b162f37231 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 31 Mar 2026 18:42:05 +0200 Subject: [PATCH 03/39] Remove verb-first commands, complete noun-first migration Deleted: cmd/list/, cmd/create/, cmd/update/, cmd/inspect/, cmd/search/ Remaining commands: op workpackage list/create/update/inspect op activities list [--wp id] op project list/inspect op user search op timeentry list op type list op status list op notification list op git start workpackage (unchanged) op login (unchanged) --- CLAUDE.md | 8 +- cmd/create/create.go | 38 ------- cmd/create/work_package.go | 58 ----------- cmd/inspect/inspect.go | 36 ------- cmd/inspect/project.go | 50 ---------- cmd/inspect/work_package.go | 74 -------------- cmd/list/activities.go | 47 --------- cmd/list/list.go | 33 ------- cmd/list/notifications.go | 35 ------- cmd/list/projects.go | 24 ----- cmd/list/status.go | 22 ----- cmd/list/time_entries.go | 50 ---------- cmd/list/time_entries_flags.go | 13 --- cmd/list/types.go | 22 ----- cmd/list/work_packages.go | 169 -------------------------------- cmd/list/work_packages_flags.go | 65 ------------ cmd/root.go | 11 --- cmd/search/search.go | 15 --- cmd/search/user.go | 48 --------- cmd/update/update.go | 53 ---------- cmd/update/work_package.go | 68 ------------- 21 files changed, 4 insertions(+), 935 deletions(-) delete mode 100644 cmd/create/create.go delete mode 100644 cmd/create/work_package.go delete mode 100644 cmd/inspect/inspect.go delete mode 100644 cmd/inspect/project.go delete mode 100644 cmd/inspect/work_package.go delete mode 100644 cmd/list/activities.go delete mode 100644 cmd/list/list.go delete mode 100644 cmd/list/notifications.go delete mode 100644 cmd/list/projects.go delete mode 100644 cmd/list/status.go delete mode 100644 cmd/list/time_entries.go delete mode 100644 cmd/list/time_entries_flags.go delete mode 100644 cmd/list/types.go delete mode 100644 cmd/list/work_packages.go delete mode 100644 cmd/list/work_packages_flags.go delete mode 100644 cmd/search/search.go delete mode 100644 cmd/search/user.go delete mode 100644 cmd/update/update.go delete mode 100644 cmd/update/work_package.go diff --git a/CLAUDE.md b/CLAUDE.md index 6d3f390..ede1631 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,10 +51,10 @@ API response → `parser.Parse[SomethingDto]()` → `dto.Convert()` → `models. ### Command conventions -- Commands follow `op ` (verb-first) -- Each verb has its own package under `cmd/` (`create`, `list`, `update`, `inspect`, `search`) -- Each verb package exposes a `RootCmd` registered in `cmd/root.go` -- One file per resource within each verb package (e.g. `cmd/list/work_packages.go`) +- Commands follow `op ` (noun-first), e.g. `op work-package list`, `op time-entry create` +- Each noun has its own package under `cmd/` (`workpackage`, `timeentry`, `project`, `user`, …) +- Each noun package exposes a `RootCmd` registered in `cmd/root.go` +- One file per verb within each noun package (e.g. `cmd/workpackage/list.go`, `cmd/workpackage/create.go`) - Flags: always define long flag names; add short flags (`-p`, `-o`) for frequently used ones - Flags that resolve a resource (e.g. `--type`, `--assignee`) perform an API lookup and store the resolved link in the DTO diff --git a/cmd/create/create.go b/cmd/create/create.go deleted file mode 100644 index e03767c..0000000 --- a/cmd/create/create.go +++ /dev/null @@ -1,38 +0,0 @@ -package create - -import "github.com/spf13/cobra" - -var RootCmd = &cobra.Command{ - Use: "create [resource]", - Short: "Creates a specific resource", - Long: "Create a specific resource in OpenProject", -} - -func init() { - createWorkPackageCmd.Flags().Uint64VarP( - &projectId, - "project", - "p", - 0, - "Project ID to create the work package in", - ) - _ = createWorkPackageCmd.MarkFlagRequired("project") - - createWorkPackageCmd.Flags().BoolVarP( - &shouldOpenWorkPackageInBrowser, - "open", - "o", - false, - "Open the created work package in the default browser", - ) - - createWorkPackageCmd.Flags().StringVarP( - &typeFlag, - "type", - "t", - "", - "Change the work package type", - ) - - RootCmd.AddCommand(createWorkPackageCmd) -} diff --git a/cmd/create/work_package.go b/cmd/create/work_package.go deleted file mode 100644 index 5e1bee4..0000000 --- a/cmd/create/work_package.go +++ /dev/null @@ -1,58 +0,0 @@ -package create - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/opf/openproject-cli/components/launch" - "github.com/opf/openproject-cli/components/printer" - "github.com/opf/openproject-cli/components/resources/work_packages" - "github.com/opf/openproject-cli/components/routes" -) - -var projectId uint64 -var shouldOpenWorkPackageInBrowser bool -var typeFlag string - -var createWorkPackageCmd = &cobra.Command{ - Use: "workpackage [subject]", - Short: "Create work package in project", - Long: "Create a new work package with the given subject in a project", - Run: createWorkPackage, -} - -func createWorkPackage(_ *cobra.Command, args []string) { - if len(args) != 1 { - printer.ErrorText(fmt.Sprintf("Expected 1 argument [subject], but got %d", len(args))) - return - } - - subject := args[0] - workPackage, err := work_packages.Create(projectId, createOptions(subject)) - if err != nil { - printer.Error(err) - return - } - - if shouldOpenWorkPackageInBrowser { - err = launch.Browser(routes.WorkPackageUrl(workPackage)) - if err != nil { - printer.ErrorText(fmt.Sprintf("Error opening browser: %+v", err)) - } - } else { - printer.WorkPackage(workPackage) - } -} - -func createOptions(subject string) map[work_packages.CreateOption]string { - var options = make(map[work_packages.CreateOption]string) - - options[work_packages.CreateSubject] = subject - - if len(typeFlag) > 0 { - options[work_packages.CreateType] = typeFlag - } - - return options -} diff --git a/cmd/inspect/inspect.go b/cmd/inspect/inspect.go deleted file mode 100644 index b956ab4..0000000 --- a/cmd/inspect/inspect.go +++ /dev/null @@ -1,36 +0,0 @@ -package inspect - -import "github.com/spf13/cobra" - -var RootCmd = &cobra.Command{ - Use: "inspect [type] [id]", - Short: "Show details about an object", - Long: "Show detailed information of an object of a specific type referenced by it's ID.", -} - -func init() { - inspectProjectCmd.Flags().BoolVarP( - &shouldOpenProjectInBrowser, - "open", - "o", - false, - "Open the project in the default browser", - ) - - inspectWorkPackageCmd.Flags().BoolVarP( - &shouldOpenWorkPackageInBrowser, - "open", - "o", - false, - "Open the work package in the default browser", - ) - - inspectWorkPackageCmd.Flags().BoolVar( - &listAvailableTypes, - "types", - false, - "List the available types on the work package.", - ) - - RootCmd.AddCommand(inspectProjectCmd, inspectWorkPackageCmd) -} diff --git a/cmd/inspect/project.go b/cmd/inspect/project.go deleted file mode 100644 index f351127..0000000 --- a/cmd/inspect/project.go +++ /dev/null @@ -1,50 +0,0 @@ -package inspect - -import ( - "fmt" - "strconv" - - "github.com/spf13/cobra" - - "github.com/opf/openproject-cli/components/launch" - "github.com/opf/openproject-cli/components/printer" - "github.com/opf/openproject-cli/components/resources/projects" - "github.com/opf/openproject-cli/components/routes" -) - -var shouldOpenProjectInBrowser bool - -var inspectProjectCmd = &cobra.Command{ - Use: "project [id]", - Short: "Show details about a project", - Long: "Show detailed information of a project refereced by it's ID.", - Run: inspectProject, -} - -func inspectProject(_ *cobra.Command, args []string) { - if len(args) != 1 { - printer.ErrorText(fmt.Sprintf("Expected 1 argument [id], but got %d", len(args))) - return - } - - id, err := strconv.ParseUint(args[0], 10, 64) - if err != nil { - printer.ErrorText(fmt.Sprintf("'%s' is an invalid project id. Must be a number.", args[0])) - return - } - - project, err := projects.Lookup(id) - if err != nil { - printer.Error(err) - return - } - - if shouldOpenProjectInBrowser { - err = launch.Browser(routes.ProjectUrl(project)) - if err != nil { - printer.ErrorText(fmt.Sprintf("Error opening browser: %+v", err)) - } - } else { - printer.Project(project) - } -} diff --git a/cmd/inspect/work_package.go b/cmd/inspect/work_package.go deleted file mode 100644 index a3e7349..0000000 --- a/cmd/inspect/work_package.go +++ /dev/null @@ -1,74 +0,0 @@ -package inspect - -import ( - "fmt" - "strconv" - - "github.com/spf13/cobra" - - "github.com/opf/openproject-cli/components/launch" - "github.com/opf/openproject-cli/components/printer" - "github.com/opf/openproject-cli/components/resources/work_packages" - "github.com/opf/openproject-cli/components/routes" -) - -var shouldOpenWorkPackageInBrowser bool -var listAvailableTypes bool - -var inspectWorkPackageCmd = &cobra.Command{ - Use: "workpackage [id]", - Aliases: []string{"wp"}, - Short: "Show details about a work package", - Long: "Show detailed information of a work package referenced by it's ID.", - Run: inspectWorkPackage, -} - -func inspectWorkPackage(_ *cobra.Command, args []string) { - if len(args) != 1 { - printer.ErrorText(fmt.Sprintf("Expected 1 argument [id], but got %d", len(args))) - return - } - - id, err := strconv.ParseUint(args[0], 10, 64) - if err != nil { - printer.ErrorText(fmt.Sprintf("'%s' is an invalid work package id. Must be a number.", args[0])) - return - } - - if hasListingFlag() { - switch { - case listAvailableTypes: - listTypes(id) - } - return - } - - workPackage, err := work_packages.Lookup(id) - if err != nil { - printer.Error(err) - return - } - - if shouldOpenWorkPackageInBrowser { - err = launch.Browser(routes.WorkPackageUrl(workPackage)) - if err != nil { - printer.ErrorText(fmt.Sprintf("Error opening browser: %+v", err)) - } - } else { - printer.WorkPackage(workPackage) - } -} - -func listTypes(id uint64) { - types, err := work_packages.AvailableTypes(id) - if err != nil { - printer.Error(err) - return - } - - printer.Types(types) -} - -func hasListingFlag() bool { - return listAvailableTypes -} diff --git a/cmd/list/activities.go b/cmd/list/activities.go deleted file mode 100644 index 7137371..0000000 --- a/cmd/list/activities.go +++ /dev/null @@ -1,47 +0,0 @@ -package list - -import ( - "fmt" - "strconv" - - "github.com/spf13/cobra" - - "github.com/opf/openproject-cli/components/printer" - "github.com/opf/openproject-cli/components/resources/users" - "github.com/opf/openproject-cli/components/resources/work_packages" -) - -var activitiesCmd = &cobra.Command{ - Use: "activities [workPackageId]", - Aliases: []string{"ac"}, - Short: "Lists activities for work package", - Long: `Get a list of activities for a work package.`, - Run: listActivities, -} - -func listActivities(_ *cobra.Command, args []string) { - if len(args) != 1 { - printer.ErrorText(fmt.Sprintf("Expected 1 argument [workPackageId], but got %d", len(args))) - return - } - - wpId, err := strconv.ParseUint(args[0], 10, 64) - if err != nil { - printer.ErrorText(err.Error()) - } - activities, err := work_packages.Activities(wpId) - if err != nil { - printer.ErrorText(err.Error()) - } - - var userIds []uint64 - for _, a := range activities { - if a.UserId > 0 { - userIds = append(userIds, a.UserId) - continue - } - } - - userList := users.ByIds(userIds) - printer.Activities(activities, userList) -} diff --git a/cmd/list/list.go b/cmd/list/list.go deleted file mode 100644 index b230188..0000000 --- a/cmd/list/list.go +++ /dev/null @@ -1,33 +0,0 @@ -package list - -import "github.com/spf13/cobra" - -var RootCmd = &cobra.Command{ - Use: "list [resource]", - Short: "Lists the specific resource", - Long: `Get a list of the ordered resource. -The list can get filtered further.`, -} - -func init() { - initWorkPackagesFlags() - initTimeEntriesFlags() - - notificationsCmd.Flags().StringVarP( - ¬ificationReason, - "reason", - "r", - "", - "The reason for the notification", - ) - - RootCmd.AddCommand( - projectsCmd, - notificationsCmd, - workPackagesCmd, - activitiesCmd, - statusCmd, - timeEntriesCmd, - typesCmd, - ) -} diff --git a/cmd/list/notifications.go b/cmd/list/notifications.go deleted file mode 100644 index 7665fce..0000000 --- a/cmd/list/notifications.go +++ /dev/null @@ -1,35 +0,0 @@ -package list - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/opf/openproject-cli/components/common" - "github.com/opf/openproject-cli/components/printer" - "github.com/opf/openproject-cli/components/resources/notifications" -) - -var notificationReason string - -var validReasons = []string{"", "assigned", "mentioned", "responsible", "watched", "dateAlert"} -var notificationsCmd = &cobra.Command{ - Use: "notifications", - Short: "Lists notifications", - Long: `Get a list of unread notifications. -The list can get filtered further.`, - Run: listNotifications, -} - -func listNotifications(_ *cobra.Command, _ []string) { - if !common.Contains(validReasons, notificationReason) { - printer.ErrorText(fmt.Sprintf("Reason '%s' is invalid.", notificationReason)) - return - } - - if all, err := notifications.All(notificationReason); err == nil { - printer.Notifications(all) - } else { - printer.Error(err) - } -} diff --git a/cmd/list/projects.go b/cmd/list/projects.go deleted file mode 100644 index d3c117f..0000000 --- a/cmd/list/projects.go +++ /dev/null @@ -1,24 +0,0 @@ -package list - -import ( - "github.com/spf13/cobra" - - "github.com/opf/openproject-cli/components/printer" - "github.com/opf/openproject-cli/components/resources/projects" -) - -var projectsCmd = &cobra.Command{ - Use: "projects", - Short: "Lists projects", - Long: `Get a list of visible projects. -The list can get filtered further.`, - Run: listProjects, -} - -func listProjects(_ *cobra.Command, _ []string) { - if all, err := projects.All(); err == nil { - printer.Projects(all) - } else { - printer.Error(err) - } -} diff --git a/cmd/list/status.go b/cmd/list/status.go deleted file mode 100644 index cf9df8f..0000000 --- a/cmd/list/status.go +++ /dev/null @@ -1,22 +0,0 @@ -package list - -import ( - "github.com/opf/openproject-cli/components/printer" - "github.com/opf/openproject-cli/components/resources/status" - "github.com/spf13/cobra" -) - -var statusCmd = &cobra.Command{ - Use: "status", - Short: "Lists status", - Long: "Get a list of all status of the instance.", - Run: listStatus, -} - -func listStatus(_ *cobra.Command, _ []string) { - if all, err := status.All(); err == nil { - printer.StatusList(all) - } else { - printer.Error(err) - } -} diff --git a/cmd/list/time_entries.go b/cmd/list/time_entries.go deleted file mode 100644 index 0bd04f8..0000000 --- a/cmd/list/time_entries.go +++ /dev/null @@ -1,50 +0,0 @@ -package list - -import ( - "github.com/opf/openproject-cli/components/printer" - "github.com/opf/openproject-cli/components/requests" - "github.com/opf/openproject-cli/components/resources" - "github.com/opf/openproject-cli/components/resources/time_entries" - "github.com/opf/openproject-cli/components/resources/time_entries/filters" - "github.com/spf13/cobra" -) - -var activeTimeEntryFilters = map[string]resources.Filter{ - "user": filters.NewUserFilter(), -} - -var timeEntriesCmd = &cobra.Command{ - Use: "timeentries", - Short: "Lists time entries", - Long: "Get a list of all time entries.", - Run: listTimeEntries, -} - -func listTimeEntries(_ *cobra.Command, _ []string) { - query, err := buildTimeEntriesQuery() - if err != nil { - printer.ErrorText(err.Error()) - return - } - - if all, err := time_entries.All(query); err == nil { - printer.TimeEntryList(all) - } else { - printer.Error(err) - } -} - -func buildTimeEntriesQuery() (requests.Query, error) { - var q requests.Query - - for _, filter := range activeTimeEntryFilters { - err := filter.ValidateInput() - if err != nil { - return requests.NewEmptyQuery(), err - } - - q = q.Merge(filter.Query()) - } - - return q, nil -} diff --git a/cmd/list/time_entries_flags.go b/cmd/list/time_entries_flags.go deleted file mode 100644 index 873a587..0000000 --- a/cmd/list/time_entries_flags.go +++ /dev/null @@ -1,13 +0,0 @@ -package list - -func initTimeEntriesFlags() { - for _, filter := range activeTimeEntryFilters { - timeEntriesCmd.Flags().StringVarP( - filter.ValuePointer(), - filter.Name(), - filter.ShortHand(), - filter.DefaultValue(), - filter.Usage(), - ) - } -} diff --git a/cmd/list/types.go b/cmd/list/types.go deleted file mode 100644 index e917354..0000000 --- a/cmd/list/types.go +++ /dev/null @@ -1,22 +0,0 @@ -package list - -import ( - "github.com/opf/openproject-cli/components/printer" - "github.com/opf/openproject-cli/components/resources/types" - "github.com/spf13/cobra" -) - -var typesCmd = &cobra.Command{ - Use: "types", - Short: "Lists work package types", - Long: "Get a list of all work package types of the instance.", - Run: listTypes, -} - -func listTypes(_ *cobra.Command, _ []string) { - if all, err := types.All(); err == nil { - printer.Types(all) - } else { - printer.Error(err) - } -} diff --git a/cmd/list/work_packages.go b/cmd/list/work_packages.go deleted file mode 100644 index 509a4e5..0000000 --- a/cmd/list/work_packages.go +++ /dev/null @@ -1,169 +0,0 @@ -package list - -import ( - "fmt" - "os" - "regexp" - "strconv" - - "github.com/opf/openproject-cli/components/common" - "github.com/opf/openproject-cli/components/printer" - "github.com/opf/openproject-cli/components/requests" - "github.com/opf/openproject-cli/components/resources" - "github.com/opf/openproject-cli/components/resources/projects" - "github.com/opf/openproject-cli/components/resources/work_packages" - "github.com/opf/openproject-cli/components/resources/work_packages/filters" - "github.com/opf/openproject-cli/models" - "github.com/spf13/cobra" -) - -var assignee string -var projectId uint64 -var showTotal bool -var statusFilter string -var typeFilter string -var includeSubProjects bool - -var activeFilters = map[string]resources.Filter{ - "notSubProject": filters.NewNotSubProjectFilter(), - "notVersion": filters.NewNotVersionFilter(), - "subProject": filters.NewSubProjectFilter(), - "timestamp": filters.NewTimestampFilter(), - "version": filters.NewVersionFilter(), -} - -var workPackagesCmd = &cobra.Command{ - Use: "workpackages", - Aliases: []string{"wps"}, - Short: "Lists work packages", - Long: "Get a list of visible work packages. Filter flags can be applied.", - Run: listWorkPackages, -} - -func listWorkPackages(_ *cobra.Command, _ []string) { - // This needs to be removed, once all filters are built the "new" way - if errorText := validateCommandFlagComposition(); len(errorText) > 0 { - printer.ErrorText(errorText) - return - } - - query, err := buildQuery() - if err != nil { - printer.ErrorText(err.Error()) - return - } - - collection, err := work_packages.All(filterOptions(), query, showTotal) - switch { - case err == nil && showTotal: - printer.Number(collection.Total) - case err == nil: - printer.WorkPackages(collection.Items) - default: - printer.Error(err) - } -} - -func validateCommandFlagComposition() (errorText string) { - switch { - case len(activeFilters["version"].Value()) != 0 && projectId == 0: - return "Version flag (--version) can only be used in conjunction with projectId flag (-p or --project-id)." - case len(activeFilters["notVersion"].Value()) != 0 && projectId == 0: - return "Not version filter flag (--not-version) can only be used in conjunction with projectId flag (-p or --project-id)." - case len(activeFilters["subProject"].Value()) > 0 || len(activeFilters["notSubProject"].Value()) > 0: - if !includeSubProjects || projectId == 0 { - return `Sub project filter flags (--sub-project or --not-sub-project) can only be used -in conjunction with setting the flag --include-sub-projects and setting a -project with the projectId flag (-p or --project-id).` - } - } - - return "" -} - -func buildQuery() (requests.Query, error) { - var q requests.Query - - for _, filter := range activeFilters { - if filter.Value() == filter.DefaultValue() { - continue - } - - err := filter.ValidateInput() - if err != nil { - return requests.NewEmptyQuery(), err - } - - q = q.Merge(filter.Query()) - } - - return q, nil -} - -func filterOptions() *map[work_packages.FilterOption]string { - options := make(map[work_packages.FilterOption]string) - - options[work_packages.IncludeSubProjects] = strconv.FormatBool(includeSubProjects) - - if projectId > 0 { - options[work_packages.Project] = strconv.FormatUint(projectId, 10) - } - - if len(assignee) > 0 { - options[work_packages.Assignee] = assignee - } - - if len(statusFilter) > 0 { - options[work_packages.Status] = validateFilterValue(work_packages.Status, statusFilter) - } - - if len(typeFilter) > 0 { - options[work_packages.Type] = validateFilterValue(work_packages.Type, typeFilter) - } - - return &options -} - -func validatedVersionId(version string) string { - project, err := projects.Lookup(projectId) - if err != nil { - printer.Error(err) - } - - versions, err := projects.AvailableVersions(project.Id) - if err != nil { - printer.Error(err) - } - - filteredVersions := common.Filter(versions, func(v *models.Version) bool { - return v.Name == version - }) - - if len(filteredVersions) != 1 { - printer.Info(fmt.Sprintf( - "No unique available version from input %s found for projectId %s. Please use one of the versions listed below.", - printer.Cyan(version), - printer.Red(fmt.Sprintf("#%d", project.Id)), - )) - - printer.Versions(versions) - - os.Exit(-1) - } - - return strconv.FormatUint(filteredVersions[0].Id, 10) -} - -func validateFilterValue(filter work_packages.FilterOption, value string) string { - matched, err := regexp.Match(work_packages.InputValidationExpression[filter], []byte(value)) - if err != nil { - printer.Error(err) - } - - if !matched { - printer.ErrorText(fmt.Sprintf("Invalid %s value %s.", filter, printer.Yellow(value))) - os.Exit(-1) - } - - return value -} diff --git a/cmd/list/work_packages_flags.go b/cmd/list/work_packages_flags.go deleted file mode 100644 index a08b9f9..0000000 --- a/cmd/list/work_packages_flags.go +++ /dev/null @@ -1,65 +0,0 @@ -package list - -func initWorkPackagesFlags() { - workPackagesCmd.Flags().StringVarP( - &assignee, - "assignee", - "a", - "", - "Assignee of the work package (can be ID or 'me')", - ) - - workPackagesCmd.Flags().Uint64VarP( - &projectId, - "project-id", - "p", - 0, - "Show only work packages within the specified projectId") - - workPackagesCmd.Flags().StringVarP( - &statusFilter, - "status", - "s", - "", - `Show only work packages having the specified status. The value can be the -keywords 'open', 'closed', a single ID or a comma separated array of IDs, i.e. -'7,13'. Multiple values are concatenated with a logical 'OR'. If the IDs are -prefixed with an '!' the list is instead filtered to not have the specified -status.`) - - workPackagesCmd.Flags().StringVarP( - &typeFilter, - "type", - "t", - "", - `Show only work packages having the specified types. The value can be a single -ID or a comma separated array of IDs, i.e. '7,13'. Multiple values are -concatenated with a logical 'OR'. If the IDs are prefixed with an '!' the list -is instead filtered to not have the specified status.`) - - workPackagesCmd.Flags().BoolVarP( - &includeSubProjects, - "include-sub-projects", - "", - false, - `If listing the work packages of a project, this flag indicates if work -packages of sub projects should be included in the list. If omitting the flag, -the default is false.`) - - workPackagesCmd.Flags().BoolVarP( - &showTotal, - "total", - "", - false, - "Show only the total number of work packages matching the filter options.") - - for _, filter := range activeFilters { - workPackagesCmd.Flags().StringVarP( - filter.ValuePointer(), - filter.Name(), - filter.ShortHand(), - filter.DefaultValue(), - filter.Usage(), - ) - } -} diff --git a/cmd/root.go b/cmd/root.go index e2ce190..3865fdc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,16 +10,11 @@ import ( "github.com/spf13/cobra" "github.com/opf/openproject-cli/cmd/activities" - "github.com/opf/openproject-cli/cmd/create" "github.com/opf/openproject-cli/cmd/git" - "github.com/opf/openproject-cli/cmd/inspect" - "github.com/opf/openproject-cli/cmd/list" "github.com/opf/openproject-cli/cmd/notification" "github.com/opf/openproject-cli/cmd/project" - "github.com/opf/openproject-cli/cmd/search" "github.com/opf/openproject-cli/cmd/status" "github.com/opf/openproject-cli/cmd/timeentry" - "github.com/opf/openproject-cli/cmd/update" "github.com/opf/openproject-cli/cmd/user" "github.com/opf/openproject-cli/cmd/workpackage" "github.com/opf/openproject-cli/cmd/wptype" @@ -109,12 +104,6 @@ func init() { wptype.RootCmd, status.RootCmd, notification.RootCmd, - // verb-first (kept during transition) - list.RootCmd, - update.RootCmd, - inspect.RootCmd, - create.RootCmd, - search.RootCmd, git.RootCmd, ) } diff --git a/cmd/search/search.go b/cmd/search/search.go deleted file mode 100644 index 389c1cf..0000000 --- a/cmd/search/search.go +++ /dev/null @@ -1,15 +0,0 @@ -package search - -import "github.com/spf13/cobra" - -var RootCmd = &cobra.Command{ - Use: "search [resource]", - Short: "Searches for the specific resource", - Long: `Execute a search on the given resource, with an search -input text. This input can be an id, a key word -like 'me', or a name.`, -} - -func init() { - RootCmd.AddCommand(userCmd) -} diff --git a/cmd/search/user.go b/cmd/search/user.go deleted file mode 100644 index f99f27c..0000000 --- a/cmd/search/user.go +++ /dev/null @@ -1,48 +0,0 @@ -package search - -import ( - "fmt" - "github.com/opf/openproject-cli/components/common" - "github.com/opf/openproject-cli/components/printer" - "github.com/opf/openproject-cli/components/resources/users" - "github.com/spf13/cobra" -) - -var userCmd = &cobra.Command{ - Use: "user [searchInput]", - Short: "Searches for a user", - Long: "Searches for a user by id, keyword, or name. Returns a list of possible matches.", - Run: searchUser, -} - -var keywords = []string{"me"} - -func searchUser(_ *cobra.Command, args []string) { - if len(args) != 1 { - printer.ErrorText(fmt.Sprintf("Expected 1 argument [searchInput], but got %d", len(args))) - return - } - - if common.Contains(keywords, args[0]) { - me, err := users.Me() - if err != nil { - printer.Error(err) - } else { - printer.User(me) - } - - return - } - - collection, err := users.Search(args[0]) - if err != nil { - printer.Error(err) - return - } - - if len(collection) == 0 { - printer.Info(fmt.Sprintf("No user found for search input %s.", printer.Cyan(args[0]))) - } else { - printer.Users(collection) - } -} diff --git a/cmd/update/update.go b/cmd/update/update.go deleted file mode 100644 index d6e9fc1..0000000 --- a/cmd/update/update.go +++ /dev/null @@ -1,53 +0,0 @@ -package update - -import "github.com/spf13/cobra" - -var RootCmd = &cobra.Command{ - Use: "update [resource] [id]", - Short: "Updates the specific resource", - Long: `Sends an update to the given resource, -which is identified by its id. The data -to update is determined by the provided -flags.`, -} - -func init() { - addWorkPackageFlags() - - RootCmd.AddCommand(workPackageCmd) -} - -func addWorkPackageFlags() { - workPackageCmd.Flags().StringVarP( - &actionFlag, - "action", - "a", - "", - "Executes a custom action on a work package", - ) - workPackageCmd.Flags().Uint64Var( - &assigneeFlag, - "assignee", - 0, - "Assign a user to the work package", - ) - workPackageCmd.Flags().StringVar( - &attachFlag, - "attach", - "", - "Attach a file to the work package", - ) - workPackageCmd.Flags().StringVar( - &subjectFlag, - "subject", - "", - "Change the subject of the work package", - ) - workPackageCmd.Flags().StringVarP( - &typeFlag, - "type", - "t", - "", - "Change the work package type", - ) -} diff --git a/cmd/update/work_package.go b/cmd/update/work_package.go deleted file mode 100644 index ad80ec7..0000000 --- a/cmd/update/work_package.go +++ /dev/null @@ -1,68 +0,0 @@ -package update - -import ( - "fmt" - "strconv" - - "github.com/spf13/cobra" - - "github.com/opf/openproject-cli/components/printer" - "github.com/opf/openproject-cli/components/resources/work_packages" -) - -var ( - actionFlag string - assigneeFlag uint64 - attachFlag string - subjectFlag string - typeFlag string -) - -var workPackageCmd = &cobra.Command{ - Use: "workpackage [id]", - Short: "Updates the work package", - Long: `Update a work package. Each update -provided by a flag is executed on its own.`, - Run: updateWorkPackage, -} - -func updateWorkPackage(_ *cobra.Command, args []string) { - if len(args) != 1 { - printer.ErrorText(fmt.Sprintf("Expected 1 argument [id], but got %d", len(args))) - return - } - - id, err := strconv.ParseUint(args[0], 10, 64) - if err != nil { - printer.ErrorText(fmt.Sprintf("'%s' is an invalid work package id. Must be a number.", args[0])) - return - } - - if workPackage, err := work_packages.Update(id, updateOptions()); err == nil { - printer.Info("-- ") - printer.WorkPackage(workPackage) - } else { - printer.Error(err) - } -} - -func updateOptions() map[work_packages.UpdateOption]string { - var options = make(map[work_packages.UpdateOption]string) - if len(actionFlag) > 0 { - options[work_packages.UpdateCustomAction] = actionFlag - } - if assigneeFlag > 0 { - options[work_packages.UpdateAssignee] = strconv.FormatUint(assigneeFlag, 10) - } - if len(attachFlag) > 0 { - options[work_packages.UpdateAttachment] = attachFlag - } - if len(subjectFlag) > 0 { - options[work_packages.UpdateSubject] = subjectFlag - } - if len(typeFlag) > 0 { - options[work_packages.UpdateType] = typeFlag - } - - return options -} From a43a50c7f993ba673d7dabe2a9f7f3a3895b83aa Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 31 Mar 2026 20:34:52 +0200 Subject: [PATCH 04/39] Fix activities list: rename --wp to --work-package and mark required - --wp renamed to --work-package for consistency with other flag names - MarkFlagRequired ensures Cobra validates the flag before execution rather than failing at runtime --- cmd/activities/activities.go | 3 ++- cmd/activities/list.go | 9 ++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/cmd/activities/activities.go b/cmd/activities/activities.go index 84e7d89..48a7777 100644 --- a/cmd/activities/activities.go +++ b/cmd/activities/activities.go @@ -11,11 +11,12 @@ var RootCmd = &cobra.Command{ func init() { listCmd.Flags().Uint64VarP( &listWpId, - "wp", + "work-package", "", 0, "Work package ID to list activities for", ) + _ = listCmd.MarkFlagRequired("work-package") RootCmd.AddCommand(listCmd) } diff --git a/cmd/activities/list.go b/cmd/activities/list.go index 2d9c512..f033d29 100644 --- a/cmd/activities/list.go +++ b/cmd/activities/list.go @@ -13,17 +13,12 @@ var listWpId uint64 var listCmd = &cobra.Command{ Use: "list", Short: "Lists activities", - Long: "Get a list of activities, scoped by the provided flag (e.g. --wp).", + Long: "Get a list of activities, scoped by the provided flag (e.g. --work-package).", Run: listActivities, } func listActivities(_ *cobra.Command, _ []string) { - if listWpId > 0 { - listWorkPackageActivities(listWpId) - return - } - - printer.ErrorText("Please specify a scope. Example: --wp [id]") + listWorkPackageActivities(listWpId) } func listWorkPackageActivities(wpId uint64) { From 365ff65f29e93e0a995fa7b79e625007c36929c5 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Wed, 1 Apr 2026 11:12:55 +0200 Subject: [PATCH 05/39] Add JSON output support via --format flag Introduce a Renderer interface with TextRenderer (existing behavior) and JsonRenderer (new) implementations. The active renderer is selected at startup via the root command's PersistentPreRun based on the --format flag value. --- cmd/root.go | 12 ++ components/printer/activities.go | 25 +-- components/printer/custom_actions.go | 15 +- components/printer/json_renderer.go | 228 +++++++++++++++++++++++++++ components/printer/notifications.go | 26 +-- components/printer/numbers.go | 6 +- components/printer/projects.go | 17 +- components/printer/renderer.go | 33 ++++ components/printer/status.go | 38 +---- components/printer/text_renderer.go | 174 ++++++++++++++++++++ components/printer/time_entries.go | 50 ++---- components/printer/types.go | 28 +--- components/printer/users.go | 30 +--- components/printer/work_packages.go | 49 ++---- 14 files changed, 498 insertions(+), 233 deletions(-) create mode 100644 components/printer/json_renderer.go create mode 100644 components/printer/renderer.go create mode 100644 components/printer/text_renderer.go diff --git a/cmd/root.go b/cmd/root.go index 3865fdc..3faec4b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,6 +26,7 @@ import ( var Verbose bool var showVersionFlag bool +var outputFormat string var rootCmd = &cobra.Command{ Use: os.Args[0], @@ -33,6 +34,9 @@ var rootCmd = &cobra.Command{ Long: `OpenProject CLI is a fast, reliable and easy-to-use tool to manage your work packages, notifications and projects of your OpenProject instance.`, + PersistentPreRun: func(_ *cobra.Command, _ []string) { + printer.InitRenderer(outputFormat) + }, Run: func(cmd *cobra.Command, args []string) { if showVersionFlag { versionText := fmt.Sprintf( @@ -93,6 +97,14 @@ func init() { "Print verbose information of any process that supports this output.", ) + rootCmd.PersistentFlags().StringVarP( + &outputFormat, + "format", + "", + "text", + `Output format. Accepted values: text, json`, + ) + rootCmd.AddCommand( loginCmd, // noun-first (new) diff --git a/components/printer/activities.go b/components/printer/activities.go index e3f0320..9854e5d 100644 --- a/components/printer/activities.go +++ b/components/printer/activities.go @@ -1,59 +1,36 @@ package printer import ( - "sort" "strings" "github.com/opf/openproject-cli/models" ) func Activities(activities []*models.Activity, users []*models.User) { - for _, activity := range activities { - user := &models.User{ - Id: 0, - Name: "", - FirstName: "", - LastName: "", - } - if activity.UserId > 0 { - userIndex := sort.Search(len(users)-1, func(i int) bool { return users[i].Id == activity.UserId }) - user = users[userIndex] - } - printActivityHeadline(activity, user) - printActivityBody(activity) - println("") - } + activeRenderer.Activities(activities, users) } func printActivityHeadline(activity *models.Activity, user *models.User) { var parts []string - if len(user.Name) > 0 { parts = append(parts, Green(user.Name)) } - parts = append(parts, Yellow(activity.UpdatedAt)) - activePrinter.Println(strings.Join(parts, " ")) } func printActivityBody(activity *models.Activity) { var parts []string - if len(activity.Comment) > 0 { parts = append(parts, Yellow(activity.Comment)) - if len(activity.Details) > 0 { parts = append(parts, "---") } } - var detailsParts []string for _, detail := range activity.Details { detailsParts = append(detailsParts, *detail) } - parts = append(parts, strings.Join(detailsParts, "\n")) - activePrinter.Println(strings.Join(parts, "\n \n")) } diff --git a/components/printer/custom_actions.go b/components/printer/custom_actions.go index 4bb1844..6b02188 100644 --- a/components/printer/custom_actions.go +++ b/components/printer/custom_actions.go @@ -1,18 +1,7 @@ package printer -import ( - "fmt" - - "github.com/opf/openproject-cli/models" -) +import "github.com/opf/openproject-cli/models" func CustomActions(actions []*models.CustomAction) { - for _, a := range actions { - printCustomAction(a) - } -} - -func printCustomAction(action *models.CustomAction) { - id := fmt.Sprintf("#%d", action.Id) - activePrinter.Printf("%s %s\n", Red(id), Cyan(action.Name)) + activeRenderer.CustomActions(actions) } diff --git a/components/printer/json_renderer.go b/components/printer/json_renderer.go new file mode 100644 index 0000000..0651e02 --- /dev/null +++ b/components/printer/json_renderer.go @@ -0,0 +1,228 @@ +package printer + +import ( + "encoding/json" + "fmt" + "sort" + + "github.com/opf/openproject-cli/models" +) + +type JsonRenderer struct{} + +func (r *JsonRenderer) WorkPackage(wp *models.WorkPackage) { + printJson(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"` + }{wp.Id, wp.Subject, wp.Type, wp.Status, wp.Assignee, wp.Description}) +} + +func (r *JsonRenderer) WorkPackages(wps []*models.WorkPackage) { + type item struct { + Id uint64 `json:"id"` + Subject string `json:"subject"` + Type string `json:"type"` + Status string `json:"status"` + Assignee string `json:"assignee"` + } + out := make([]item, len(wps)) + for i, wp := range wps { + out[i] = item{wp.Id, wp.Subject, wp.Type, wp.Status, wp.Assignee} + } + printJson(out) +} + +func (r *JsonRenderer) Project(p *models.Project) { + printJson(struct { + Id uint64 `json:"id"` + Name string `json:"name"` + }{p.Id, p.Name}) +} + +func (r *JsonRenderer) Projects(ps []*models.Project) { + type item struct { + Id uint64 `json:"id"` + Name string `json:"name"` + } + out := make([]item, len(ps)) + for i, p := range ps { + out[i] = item{p.Id, p.Name} + } + printJson(out) +} + +func (r *JsonRenderer) User(u *models.User) { + printJson(struct { + Id uint64 `json:"id"` + Name string `json:"name"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + }{u.Id, u.Name, u.FirstName, u.LastName}) +} + +func (r *JsonRenderer) Users(us []*models.User) { + type item struct { + Id uint64 `json:"id"` + Name string `json:"name"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + } + out := make([]item, len(us)) + for i, u := range us { + out[i] = item{u.Id, u.Name, u.FirstName, u.LastName} + } + printJson(out) +} + +func (r *JsonRenderer) Types(types []*models.Type) { + type item struct { + Id uint64 `json:"id"` + Name string `json:"name"` + } + out := make([]item, len(types)) + for i, t := range types { + out[i] = item{t.Id, t.Name} + } + printJson(out) +} + +func (r *JsonRenderer) Status(s *models.Status) { + printJson(struct { + Id uint64 `json:"id"` + Name string `json:"name"` + IsDefault bool `json:"is_default"` + IsClosed bool `json:"is_closed"` + }{s.Id, s.Name, s.IsDefault, s.IsClosed}) +} + +func (r *JsonRenderer) StatusList(statuses []*models.Status) { + type item struct { + Id uint64 `json:"id"` + Name string `json:"name"` + IsDefault bool `json:"is_default"` + IsClosed bool `json:"is_closed"` + } + out := make([]item, len(statuses)) + for i, s := range statuses { + out[i] = item{s.Id, s.Name, s.IsDefault, s.IsClosed} + } + printJson(out) +} + +func (r *JsonRenderer) TimeEntry(t *models.TimeEntry) { + printJson(struct { + Id uint64 `json:"id"` + Comment string `json:"comment"` + Project string `json:"project"` + WorkPackage string `json:"work_package"` + SpentOn string `json:"spent_on"` + Hours float64 `json:"hours"` + Activity string `json:"activity"` + User string `json:"user"` + }{t.Id, t.Comment, t.Project, t.WorkPackage, t.SpentOn.Format("2006-01-02"), t.Hours.Hours(), t.Activity, t.User}) +} + +func (r *JsonRenderer) TimeEntryList(entries []*models.TimeEntry) { + type item struct { + Id uint64 `json:"id"` + Comment string `json:"comment"` + Project string `json:"project"` + WorkPackage string `json:"work_package"` + SpentOn string `json:"spent_on"` + Hours float64 `json:"hours"` + Activity string `json:"activity"` + User string `json:"user"` + } + out := make([]item, len(entries)) + for i, t := range entries { + out[i] = item{ + t.Id, + t.Comment, + t.Project, + t.WorkPackage, + t.SpentOn.Format("2006-01-02"), + t.Hours.Hours(), + t.Activity, + t.User, + } + } + printJson(out) +} + +func (r *JsonRenderer) Notification(n *models.Notification) { + printJson(struct { + Id uint64 `json:"id"` + ResourceId uint64 `json:"resource_id"` + ResourceSubject string `json:"resource_subject"` + Reason string `json:"reason"` + Read bool `json:"read"` + }{n.Id, n.ResourceId, n.ResourceSubject, n.Reason, n.Read}) +} + +func (r *JsonRenderer) Notifications(ns []*models.Notification) { + type item struct { + Id uint64 `json:"id"` + ResourceId uint64 `json:"resource_id"` + ResourceSubject string `json:"resource_subject"` + Reason string `json:"reason"` + Read bool `json:"read"` + } + out := make([]item, len(ns)) + for i, n := range ns { + out[i] = item{n.Id, n.ResourceId, n.ResourceSubject, n.Reason, n.Read} + } + printJson(out) +} + +func (r *JsonRenderer) Activities(activities []*models.Activity, users []*models.User) { + type item struct { + Id uint64 `json:"id"` + Comment string `json:"comment"` + Details []*string `json:"details"` + UpdatedAt string `json:"updated_at"` + User string `json:"user"` + } + out := make([]item, len(activities)) + for i, a := range activities { + userName := "" + if a.UserId > 0 { + idx := sort.Search(len(users)-1, func(j int) bool { return users[j].Id == a.UserId }) + if idx < len(users) { + userName = users[idx].Name + } + } + out[i] = item{a.Id, a.Comment, a.Details, a.UpdatedAt, userName} + } + printJson(out) +} + +func (r *JsonRenderer) CustomActions(actions []*models.CustomAction) { + type item struct { + Id uint64 `json:"id"` + Name string `json:"name"` + } + out := make([]item, len(actions)) + for i, a := range actions { + out[i] = item{a.Id, a.Name} + } + printJson(out) +} + +func (r *JsonRenderer) Number(n int64) { + printJson(struct { + Total int64 `json:"total"` + }{n}) +} + +func printJson(v any) { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + fmt.Printf("{\"error\": \"failed to serialize output: %s\"}\n", err) + return + } + fmt.Println(string(b)) +} diff --git a/components/printer/notifications.go b/components/printer/notifications.go index bfea26b..36f4fb3 100644 --- a/components/printer/notifications.go +++ b/components/printer/notifications.go @@ -4,32 +4,20 @@ import ( "fmt" "strings" - "github.com/opf/openproject-cli/components/common" "github.com/opf/openproject-cli/models" ) -type groupedNotification struct { - notification *models.Notification - count int +func Notification(n *models.Notification) { + activeRenderer.Notification(n) } func Notifications(notifications []*models.Notification) { - grouped := group(notifications) - - var maxIdLength int - var maxReasonLength int - for _, element := range grouped { - maxIdLength = common.Max(maxIdLength, idLength(element.notification.ResourceId)) - maxReasonLength = common.Max(maxReasonLength, len(element.notification.Reason)) - } - - for _, notification := range grouped { - printNotification(notification, maxIdLength, maxReasonLength) - } + activeRenderer.Notifications(notifications) } -func Notification(notification *models.Notification) { - printNotification(&groupedNotification{notification: notification, count: 1}, idLength(notification.ResourceId), len(notification.Reason)) +type groupedNotification struct { + notification *models.Notification + count int } func printNotification(line *groupedNotification, maxIdLength, maxReasonLength int) { @@ -63,12 +51,10 @@ func group(notifications []*models.Notification) []*groupedNotification { break } } - if !alreadyAdded { list = append(list, &groupedNotification{notification: notification, count: 1}) } } - return list } diff --git a/components/printer/numbers.go b/components/printer/numbers.go index e770436..b70535f 100644 --- a/components/printer/numbers.go +++ b/components/printer/numbers.go @@ -1,7 +1,5 @@ package printer -import "strconv" - -func Number(number int64) { - activePrinter.Printf("%s\n", Cyan(strconv.FormatInt(number, 10))) +func Number(n int64) { + activeRenderer.Number(n) } diff --git a/components/printer/projects.go b/components/printer/projects.go index e3cda1c..a085630 100644 --- a/components/printer/projects.go +++ b/components/printer/projects.go @@ -1,22 +1,11 @@ package printer -import ( - "fmt" - - "github.com/opf/openproject-cli/models" -) +import "github.com/opf/openproject-cli/models" func Projects(projects []*models.Project) { - for _, p := range projects { - printProject(p) - } + activeRenderer.Projects(projects) } func Project(project *models.Project) { - printProject(project) -} - -func printProject(project *models.Project) { - id := fmt.Sprintf("#%d", project.Id) - activePrinter.Printf("%s %s\n", Red(id), Cyan(project.Name)) + activeRenderer.Project(project) } diff --git a/components/printer/renderer.go b/components/printer/renderer.go new file mode 100644 index 0000000..369feaf --- /dev/null +++ b/components/printer/renderer.go @@ -0,0 +1,33 @@ +package printer + +import "github.com/opf/openproject-cli/models" + +type Renderer interface { + WorkPackage(*models.WorkPackage) + WorkPackages([]*models.WorkPackage) + Project(*models.Project) + Projects([]*models.Project) + User(*models.User) + Users([]*models.User) + Types([]*models.Type) + Status(*models.Status) + StatusList([]*models.Status) + TimeEntryList([]*models.TimeEntry) + TimeEntry(*models.TimeEntry) + Notification(*models.Notification) + Notifications([]*models.Notification) + Activities([]*models.Activity, []*models.User) + CustomActions([]*models.CustomAction) + Number(int64) +} + +var activeRenderer Renderer = &TextRenderer{} + +func InitRenderer(format string) { + switch format { + case "json": + activeRenderer = &JsonRenderer{} + default: + activeRenderer = &TextRenderer{} + } +} diff --git a/components/printer/status.go b/components/printer/status.go index fec62e5..079c92c 100644 --- a/components/printer/status.go +++ b/components/printer/status.go @@ -1,39 +1,11 @@ package printer -import ( - "fmt" - "github.com/opf/openproject-cli/components/common" - "github.com/opf/openproject-cli/models" - "strings" -) +import "github.com/opf/openproject-cli/models" -func StatusList(status []*models.Status) { - var maxIdLength = 0 - for _, s := range status { - maxIdLength = common.Max(maxIdLength, idLength(s.Id)) - } - - for _, s := range status { - printStatus(s, maxIdLength) - } -} - -func Status(status *models.Status) { - printStatus(status, idLength(status.Id)) +func Status(s *models.Status) { + activeRenderer.Status(s) } -func printStatus(status *models.Status, maxIdLength int) { - var parts []string - - diff := maxIdLength - idLength(status.Id) - idStr := fmt.Sprintf("%s#%d", indent(diff), status.Id) - parts = append(parts, Red(idStr)) - - parts = append(parts, Cyan(status.Name)) - - if status.IsDefault { - parts = append(parts, fmt.Sprintf("(%s)", Yellow("default"))) - } - - activePrinter.Println(strings.Join(parts, " ")) +func StatusList(statuses []*models.Status) { + activeRenderer.StatusList(statuses) } diff --git a/components/printer/text_renderer.go b/components/printer/text_renderer.go new file mode 100644 index 0000000..af839f0 --- /dev/null +++ b/components/printer/text_renderer.go @@ -0,0 +1,174 @@ +package printer + +import ( + "fmt" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "github.com/opf/openproject-cli/components/common" + "github.com/opf/openproject-cli/models" +) + +type TextRenderer struct{} + +func (r *TextRenderer) WorkPackage(wp *models.WorkPackage) { + printHeadline(wp, idLength(wp.Id), 0, utf8.RuneCountInString(wp.Type)) + printAttributes(wp) + activePrinter.Println() + printOpenLink(wp) + activePrinter.Println() + printDescription(wp) +} + +func (r *TextRenderer) WorkPackages(wps []*models.WorkPackage) { + var maxIdLength = 0 + var maxTypeLength = 0 + var maxStatusLength = 0 + for _, w := range wps { + maxIdLength = common.Max(maxIdLength, idLength(w.Id)) + maxTypeLength = common.Max(maxTypeLength, utf8.RuneCountInString(w.Type)) + maxStatusLength = common.Max(maxStatusLength, utf8.RuneCountInString(w.Status)) + } + for _, wp := range wps { + printHeadline(wp, maxIdLength, maxStatusLength, maxTypeLength) + } +} + +func (r *TextRenderer) Project(p *models.Project) { + printProject(p) +} + +func (r *TextRenderer) Projects(ps []*models.Project) { + for _, p := range ps { + printProject(p) + } +} + +func (r *TextRenderer) User(u *models.User) { + printUser(u, idLength(u.Id)) +} + +func (r *TextRenderer) Users(us []*models.User) { + var maxIdLength = 0 + for _, u := range us { + maxIdLength = common.Max(maxIdLength, idLength(u.Id)) + } + for _, u := range us { + printUser(u, maxIdLength) + } +} + +func (r *TextRenderer) Types(types []*models.Type) { + var maxIdLength = 0 + for _, t := range types { + maxIdLength = common.Max(maxIdLength, idLength(t.Id)) + } + for _, t := range types { + printType(t, maxIdLength) + } +} + +func (r *TextRenderer) Status(s *models.Status) { + printStatus(s, idLength(s.Id)) +} + +func (r *TextRenderer) StatusList(statuses []*models.Status) { + var maxIdLength = 0 + for _, s := range statuses { + maxIdLength = common.Max(maxIdLength, idLength(s.Id)) + } + for _, s := range statuses { + printStatus(s, maxIdLength) + } +} + +func (r *TextRenderer) TimeEntry(t *models.TimeEntry) { + printTimeEntry(t, idLength(t.Id), len(t.Activity), len(t.Project)) +} + +func (r *TextRenderer) TimeEntryList(entries []*models.TimeEntry) { + var maxIdLength = 0 + var maxActivityLength = 0 + var maxProjectLength = 0 + for _, t := range entries { + maxIdLength = common.Max(maxIdLength, idLength(t.Id)) + maxActivityLength = common.Max(maxActivityLength, len(t.Activity)) + maxProjectLength = common.Max(maxProjectLength, len(t.Project)) + } + for _, t := range entries { + printTimeEntry(t, maxIdLength, maxActivityLength, maxProjectLength) + } +} + +func (r *TextRenderer) Notification(n *models.Notification) { + printNotification(&groupedNotification{notification: n, count: 1}, idLength(n.ResourceId), len(n.Reason)) +} + +func (r *TextRenderer) Notifications(ns []*models.Notification) { + grouped := group(ns) + var maxIdLength int + var maxReasonLength int + for _, element := range grouped { + maxIdLength = common.Max(maxIdLength, idLength(element.notification.ResourceId)) + maxReasonLength = common.Max(maxReasonLength, len(element.notification.Reason)) + } + for _, n := range grouped { + printNotification(n, maxIdLength, maxReasonLength) + } +} + +func (r *TextRenderer) Activities(activities []*models.Activity, users []*models.User) { + for _, activity := range activities { + user := &models.User{} + if activity.UserId > 0 { + idx := sort.Search(len(users)-1, func(i int) bool { return users[i].Id == activity.UserId }) + user = users[idx] + } + printActivityHeadline(activity, user) + printActivityBody(activity) + activePrinter.Println("") + } +} + +func (r *TextRenderer) CustomActions(actions []*models.CustomAction) { + for _, a := range actions { + printCustomAction(a) + } +} + +func (r *TextRenderer) Number(n int64) { + activePrinter.Printf("%s\n", Cyan(strconv.FormatInt(n, 10))) +} + +func printProject(p *models.Project) { + id := fmt.Sprintf("#%d", p.Id) + activePrinter.Printf("%s %s\n", Red(id), Cyan(p.Name)) +} + +func printUser(u *models.User, maxIdLength int) { + diff := maxIdLength - idLength(u.Id) + idStr := fmt.Sprintf("%s#%d", indent(diff), u.Id) + activePrinter.Println(strings.Join([]string{Red(idStr), Cyan(u.Name)}, " ")) +} + +func printType(t *models.Type, maxIdLength int) { + diff := maxIdLength - idLength(t.Id) + idStr := fmt.Sprintf("%s#%d", indent(diff), t.Id) + activePrinter.Println(strings.Join([]string{Red(idStr), Cyan(t.Name)}, " ")) +} + +func printStatus(s *models.Status, maxIdLength int) { + diff := maxIdLength - idLength(s.Id) + idStr := fmt.Sprintf("%s#%d", indent(diff), s.Id) + parts := []string{Red(idStr), Cyan(s.Name)} + if s.IsDefault { + parts = append(parts, fmt.Sprintf("(%s)", Yellow("default"))) + } + activePrinter.Println(strings.Join(parts, " ")) +} + +func printCustomAction(a *models.CustomAction) { + activePrinter.Printf("%s %s\n", Red(fmt.Sprintf("#%d", a.Id)), Cyan(a.Name)) +} diff --git a/components/printer/time_entries.go b/components/printer/time_entries.go index 67a7335..ab19405 100644 --- a/components/printer/time_entries.go +++ b/components/printer/time_entries.go @@ -4,61 +4,45 @@ import ( "fmt" "strings" - "github.com/opf/openproject-cli/components/common" "github.com/opf/openproject-cli/models" ) -func TimeEntryList(timeEntry []*models.TimeEntry) { - var maxIdLength = 0 - var maxActivityLength = 0 - var maxProjectLength = 0 - for _, t := range timeEntry { - maxIdLength = common.Max(maxIdLength, idLength(t.Id)) - maxActivityLength = common.Max(maxActivityLength, len(t.Activity)) - maxProjectLength = common.Max(maxProjectLength, len(t.Project)) - } - - for _, t := range timeEntry { - printTimeEntry(t, maxIdLength, maxActivityLength, maxProjectLength) - } +func TimeEntry(t *models.TimeEntry) { + activeRenderer.TimeEntry(t) } -func TimeEntry(timeEntry *models.TimeEntry) { - printTimeEntry(timeEntry, idLength(timeEntry.Id), len(timeEntry.Activity), len(timeEntry.Project)) +func TimeEntryList(entries []*models.TimeEntry) { + activeRenderer.TimeEntryList(entries) } -func printTimeEntry(timeEntry *models.TimeEntry, maxIdLength int, maxActivityLength int, maxProjectLength int) { +func printTimeEntry(t *models.TimeEntry, maxIdLength, maxActivityLength, maxProjectLength int) { var parts []string - diff := maxIdLength - idLength(timeEntry.Id) - idStr := fmt.Sprintf("%s#%d", indent(diff), timeEntry.Id) - + diff := maxIdLength - idLength(t.Id) + idStr := fmt.Sprintf("%s#%d", indent(diff), t.Id) parts = append(parts, Red(idStr)) if maxActivityLength > 0 { - diff = maxActivityLength - len(timeEntry.Activity) - activityStr := Green(strings.ToUpper(timeEntry.Activity)) + indent(diff) + diff = maxActivityLength - len(t.Activity) + activityStr := Green(strings.ToUpper(t.Activity)) + indent(diff) parts = append(parts, activityStr) } - parts = append(parts, Cyan(timeEntry.SpentOn.Format("Mon Jan _2"))) - - hoursStr := fmt.Sprintf("%.2fh", timeEntry.Hours.Hours()) - parts = append(parts, hoursStr) + parts = append(parts, Cyan(t.SpentOn.Format("Mon Jan _2"))) + parts = append(parts, fmt.Sprintf("%.2fh", t.Hours.Hours())) if maxProjectLength > 0 { - diff = maxProjectLength - len(timeEntry.Project) - projectStr := Yellow(timeEntry.Project) + indent(diff) + diff = maxProjectLength - len(t.Project) + projectStr := Yellow(t.Project) + indent(diff) parts = append(parts, projectStr) } - parts = append(parts, Cyan(timeEntry.WorkPackage)) + parts = append(parts, Cyan(t.WorkPackage)) - if len(timeEntry.Comment) > 0 { - parts = append(parts, timeEntry.Comment) + if len(t.Comment) > 0 { + parts = append(parts, t.Comment) } - - if timeEntry.Ongoing { + if t.Ongoing { parts = append(parts, fmt.Sprintf("(%s)", Yellow("ongoing"))) } diff --git a/components/printer/types.go b/components/printer/types.go index a62bc1e..94ec183 100644 --- a/components/printer/types.go +++ b/components/printer/types.go @@ -1,31 +1,7 @@ package printer -import ( - "fmt" - "strings" - - "github.com/opf/openproject-cli/components/common" - "github.com/opf/openproject-cli/models" -) +import "github.com/opf/openproject-cli/models" func Types(types []*models.Type) { - var maxIdLength = 0 - for _, t := range types { - maxIdLength = common.Max(maxIdLength, idLength(t.Id)) - } - - for _, t := range types { - printType(t, maxIdLength) - } -} - -func printType(workPackageType *models.Type, maxIdLength int) { - var parts []string - - diff := maxIdLength - idLength(workPackageType.Id) - idStr := fmt.Sprintf("%s#%d", indent(diff), workPackageType.Id) - parts = append(parts, Red(idStr)) - - parts = append(parts, Cyan(workPackageType.Name)) - activePrinter.Println(strings.Join(parts, " ")) + activeRenderer.Types(types) } diff --git a/components/printer/users.go b/components/printer/users.go index 8cde6d5..0d41dd2 100644 --- a/components/printer/users.go +++ b/components/printer/users.go @@ -1,35 +1,11 @@ package printer -import ( - "fmt" - "strings" - - "github.com/opf/openproject-cli/components/common" - "github.com/opf/openproject-cli/models" -) +import "github.com/opf/openproject-cli/models" func Users(users []*models.User) { - var maxIdLength = 0 - for _, u := range users { - maxIdLength = common.Max(maxIdLength, idLength(u.Id)) - } - - for _, u := range users { - printUser(u, maxIdLength) - } + activeRenderer.Users(users) } func User(user *models.User) { - printUser(user, idLength(user.Id)) -} - -func printUser(user *models.User, maxIdLength int) { - var parts []string - - diff := maxIdLength - idLength(user.Id) - idStr := fmt.Sprintf("%s#%d", indent(diff), user.Id) - parts = append(parts, Red(idStr)) - - parts = append(parts, Cyan(user.Name)) - activePrinter.Println(strings.Join(parts, " ")) + activeRenderer.User(user) } diff --git a/components/printer/work_packages.go b/components/printer/work_packages.go index 9695df8..d5e5c0e 100644 --- a/components/printer/work_packages.go +++ b/components/printer/work_packages.go @@ -6,33 +6,16 @@ import ( "strings" "unicode/utf8" - "github.com/opf/openproject-cli/components/common" "github.com/opf/openproject-cli/components/routes" "github.com/opf/openproject-cli/models" ) -func WorkPackages(workPackages []*models.WorkPackage) { - var maxIdLength = 0 - var maxTypeLength = 0 - var maxStatusLength = 0 - for _, w := range workPackages { - maxIdLength = common.Max(maxIdLength, idLength(w.Id)) - maxTypeLength = common.Max(maxTypeLength, utf8.RuneCountInString(w.Type)) - maxStatusLength = common.Max(maxStatusLength, utf8.RuneCountInString(w.Status)) - } - - for _, workPackage := range workPackages { - printHeadline(workPackage, maxIdLength, maxStatusLength, maxTypeLength) - } +func WorkPackage(wp *models.WorkPackage) { + activeRenderer.WorkPackage(wp) } -func WorkPackage(workPackage *models.WorkPackage) { - printHeadline(workPackage, idLength(workPackage.Id), 0, utf8.RuneCountInString(workPackage.Type)) - printAttributes(workPackage) - activePrinter.Println() - printOpenLink(workPackage) - activePrinter.Println() - printDescription(workPackage) +func WorkPackages(wps []*models.WorkPackage) { + activeRenderer.WorkPackages(wps) } func idLength(id uint64) int { @@ -75,52 +58,40 @@ func printOpenLink(workPackage *models.WorkPackage) { } func printDescription(workPackage *models.WorkPackage) { - lines := splitIntoLines(workPackage.Description, 80) - for _, line := range lines { + for _, line := range splitIntoLines(workPackage.Description, 80) { activePrinter.Printf("%s\n", line) } } func splitWords(text string, lineLength int) []string { words := strings.Fields(text) - var lines []string var line string - for _, word := range words { if len(line)+len(word)+1 > lineLength { lines = append(lines, line) line = "" } - if len(line) > 0 { line += " " } - line += word } - if len(line) > 0 { lines = append(lines, line) } - return lines } func splitIntoLines(text string, lineLength int) []string { - paragraphs := strings.Split(text, "\n") - var lines []string - - for _, paragraph := range paragraphs { - splitParagraph := splitWords(paragraph, lineLength) - - if len(splitParagraph) == 0 { - lines = append(lines, "") // Append empty line + for _, paragraph := range strings.Split(text, "\n") { + split := splitWords(paragraph, lineLength) + if len(split) == 0 { + lines = append(lines, "") } else { - lines = append(lines, splitParagraph...) + lines = append(lines, split...) } } - return lines } From 91be87b6341bc5fb5258e3681a9f1e1a685d86c1 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Wed, 1 Apr 2026 11:31:44 +0200 Subject: [PATCH 06/39] Print error for unknown --format value instead of silently falling back to text --- components/printer/renderer.go | 9 ++++++++- components/printer/renderer_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 components/printer/renderer_test.go diff --git a/components/printer/renderer.go b/components/printer/renderer.go index 369feaf..bf14c31 100644 --- a/components/printer/renderer.go +++ b/components/printer/renderer.go @@ -1,6 +1,10 @@ package printer -import "github.com/opf/openproject-cli/models" +import ( + "fmt" + + "github.com/opf/openproject-cli/models" +) type Renderer interface { WorkPackage(*models.WorkPackage) @@ -27,7 +31,10 @@ func InitRenderer(format string) { switch format { case "json": activeRenderer = &JsonRenderer{} + case "text", "": + activeRenderer = &TextRenderer{} default: activeRenderer = &TextRenderer{} + ErrorText(fmt.Sprintf("unknown output format %q, falling back to text", format)) } } diff --git a/components/printer/renderer_test.go b/components/printer/renderer_test.go new file mode 100644 index 0000000..12b1d02 --- /dev/null +++ b/components/printer/renderer_test.go @@ -0,0 +1,26 @@ +package printer_test + +import ( + "strings" + "testing" + + "github.com/opf/openproject-cli/components/printer" +) + +func TestInitRenderer_UnknownFormat_PrintsError(t *testing.T) { + testingPrinter.Reset() + printer.InitRenderer("xml") + if !strings.Contains(testingPrinter.Result, "xml") { + t.Errorf("expected error mentioning unknown format 'xml', got: %q", testingPrinter.Result) + } +} + +func TestInitRenderer_KnownFormats_NoError(t *testing.T) { + for _, format := range []string{"text", "json"} { + testingPrinter.Reset() + printer.InitRenderer(format) + if strings.Contains(testingPrinter.Result, "[ERROR]") { + t.Errorf("unexpected error output for known format %q: %s", format, testingPrinter.Result) + } + } +} From 6fc5b0d6562a2e41b722968fde5d0a9fd6c49e02 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Wed, 1 Apr 2026 11:33:33 +0200 Subject: [PATCH 07/39] Fix sort.Search predicate in Activities user lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The predicate (Id == target) is not monotone — sort.Search requires >=. Also, len(users)-1 as the upper bound excluded the last user from the search. Together, these bugs caused the wrong user name to be resolved when the target user appeared before the last position in the slice. --- components/printer/activities_test.go | 35 +++++++++++++++++++++++++++ components/printer/json_renderer.go | 4 +-- components/printer/renderer_test.go | 1 + components/printer/text_renderer.go | 6 +++-- 4 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 components/printer/activities_test.go diff --git a/components/printer/activities_test.go b/components/printer/activities_test.go new file mode 100644 index 0000000..a7a6b7d --- /dev/null +++ b/components/printer/activities_test.go @@ -0,0 +1,35 @@ +package printer_test + +import ( + "strings" + "testing" + + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/models" +) + +// TestActivities_UserLookup_FirstUser reproduces the sort.Search bug: +// the old predicate (Id == target) is not monotone, and len(users)-1 as the +// upper bound means the last user is never searched. Together they cause +// sort.Search to return the wrong index when the target is the first user. +func TestActivities_UserLookup_FirstUser(t *testing.T) { + testingPrinter.Reset() + printer.InitRenderer("text") + + users := []*models.User{ + {Id: 1, Name: "Alice"}, + {Id: 3, Name: "Bob"}, + {Id: 5, Name: "Charlie"}, + } + activity := &models.Activity{ + Id: 42, + UserId: 1, // Alice — first in the slice + UpdatedAt: "2024-01-01", + } + + printer.Activities([]*models.Activity{activity}, users) + + if !strings.Contains(testingPrinter.Result, printer.Green("Alice")) { + t.Errorf("expected user 'Alice' in output, got: %q", testingPrinter.Result) + } +} diff --git a/components/printer/json_renderer.go b/components/printer/json_renderer.go index 0651e02..cc81db2 100644 --- a/components/printer/json_renderer.go +++ b/components/printer/json_renderer.go @@ -190,8 +190,8 @@ func (r *JsonRenderer) Activities(activities []*models.Activity, users []*models for i, a := range activities { userName := "" if a.UserId > 0 { - idx := sort.Search(len(users)-1, func(j int) bool { return users[j].Id == a.UserId }) - if idx < len(users) { + idx := sort.Search(len(users), func(j int) bool { return users[j].Id >= a.UserId }) + if idx < len(users) && users[idx].Id == a.UserId { userName = users[idx].Name } } diff --git a/components/printer/renderer_test.go b/components/printer/renderer_test.go index 12b1d02..b21aec4 100644 --- a/components/printer/renderer_test.go +++ b/components/printer/renderer_test.go @@ -16,6 +16,7 @@ func TestInitRenderer_UnknownFormat_PrintsError(t *testing.T) { } func TestInitRenderer_KnownFormats_NoError(t *testing.T) { + defer printer.InitRenderer("text") for _, format := range []string{"text", "json"} { testingPrinter.Reset() printer.InitRenderer(format) diff --git a/components/printer/text_renderer.go b/components/printer/text_renderer.go index af839f0..19846a8 100644 --- a/components/printer/text_renderer.go +++ b/components/printer/text_renderer.go @@ -123,8 +123,10 @@ func (r *TextRenderer) Activities(activities []*models.Activity, users []*models for _, activity := range activities { user := &models.User{} if activity.UserId > 0 { - idx := sort.Search(len(users)-1, func(i int) bool { return users[i].Id == activity.UserId }) - user = users[idx] + idx := sort.Search(len(users), func(i int) bool { return users[i].Id >= activity.UserId }) + if idx < len(users) && users[idx].Id == activity.UserId { + user = users[idx] + } } printActivityHeadline(activity, user) printActivityBody(activity) From 362f2a17ae04afe317948bffcc8c4557387bf86a Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Wed, 1 Apr 2026 13:56:44 +0200 Subject: [PATCH 08/39] Use noun-first hyphenated command names (work-package, time-entry) Cobra supports hyphens in command names; aligning with common CLI conventions (docker, kubectl, gh). Package names remain unhyphenated as Go requires. Also updates README and CLAUDE.md to reflect noun-first syntax throughout. --- CLAUDE.md | 2 +- README.md | 41 ++++++++++++++--------------- cmd/timeentry/timeentry.go | 2 +- cmd/timeentry/timeentry_test.go | 15 +++++++++++ cmd/workpackage/workpackage.go | 2 +- cmd/workpackage/workpackage_test.go | 15 +++++++++++ 6 files changed, 53 insertions(+), 24 deletions(-) create mode 100644 cmd/timeentry/timeentry_test.go create mode 100644 cmd/workpackage/workpackage_test.go diff --git a/CLAUDE.md b/CLAUDE.md index ede1631..d0d3819 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ API response → `parser.Parse[SomethingDto]()` → `dto.Convert()` → `models. ### Command conventions - Commands follow `op ` (noun-first), e.g. `op work-package list`, `op time-entry create` -- Each noun has its own package under `cmd/` (`workpackage`, `timeentry`, `project`, `user`, …) +- Each noun has its own package under `cmd/` (`workpackage`, `timeentry`, `project`, `user`, …) — package names use no hyphens even when the command name does (e.g. `work-package` → `package workpackage`) - Each noun package exposes a `RootCmd` registered in `cmd/root.go` - One file per verb within each noun package (e.g. `cmd/workpackage/list.go`, `cmd/workpackage/create.go`) - Flags: always define long flag names; add short flags (`-p`, `-o`) for frequently used ones diff --git a/README.md b/README.md index c6685be..de40a33 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Open a new shell and completion should work by using the `TAB` key as usual. ## Usage The OpenProject CLI commands are structured in a common, human-readable pattern. Every command is built -as `op VERB RESOURCE [additional information]`. You will see plenty of examples within this section. +as `op NOUN VERB [additional information]`. You will see plenty of examples within this section. ### Discoverability @@ -102,12 +102,11 @@ possibilities he can use from here. # General help op -h -# Help about what things I can list -op list -h +# Help about work package commands +op work-package -h -# Help about how can I narrow down my list of work -# packages to get exactly what I was looking for -op list workpackages -h +# Help about how can I narrow down my list of work packages +op work-package list -h ``` The second basement is the autocompletion. To set it up correctly, follow the steps explained in the `Completion` @@ -117,20 +116,20 @@ Once it is working, you can use it to discover possible commands and flags while ```shell # Chaining commands: hitting completion key after -op list +op work-package # returns -activities -- Lists activities for work package -notifications -- Lists notificationswork package -projects -- Lists projects -workpackages -- Lists work packages +create -- Create work package in project +inspect -- Inspect a work package +list -- Lists work packages +update -- Update a work package # Discover flags: hitting completion key after -op update workpackge 42 - +op work-package update 42 - # returns --action -a -- Executes a custom action on a work package --assignee -- Assign a user to the work package --attach -- Attach a file to the work package ---help -h -- help for workpackage +--help -h -- help for work-package --subject -- Change the subject of the work package --type -t -- Change the work package type ``` @@ -146,34 +145,34 @@ of examples, that might be useful for a great number of people. # Creating a work package in a project only by subject. # Work package is created with many default values (as for type and status), # very similar to how a work package is created inline in a work package table. -op create workpackge --project 11 'Document new CLI tool' +op work-package create --project 11 'Document new CLI tool' # Same command with shorthands and directly open it in a browser to continue working on it. -op create workpackge -p11 'Document new CLI tool' -o +op work-package create -p11 'Document new CLI tool' -o ``` #### Listing ```shell # Get a list of unread notifications and filter them by reason -op list notifications --reason mentioned +op notification list --reason mentioned # Get a list of all work packages assigned to me -op list workpackages --assignee me +op work-package list --assignee me ``` #### Updating ```shell # Executing a custom action on a work package -op update workpackage 42 --action Claim +op work-package update 42 --action Claim # Batch updating some properties of a work package # Valid input will get processed, while invalid (e.g. wrongly typed) input will get omitted -op update workpackage 42 --subject 'The new subject' --status 'In Progress' --type Implementation +op work-package update 42 --subject 'The new subject' --status 'In Progress' --type Implementation # Uploading an attachment to a work package -op update workpackage 42 --attach ./Downloads/Report.pdf +op work-package update 42 --attach ./Downloads/Report.pdf ``` #### Inspecting @@ -181,7 +180,7 @@ op update workpackage 42 --attach ./Downloads/Report.pdf ```shell # Inspecting a work package with more details, # then in the work package list command -op inspect workpackage 42 +op work-package inspect 42 ``` ## Creating a release diff --git a/cmd/timeentry/timeentry.go b/cmd/timeentry/timeentry.go index 27d2cc0..6990696 100644 --- a/cmd/timeentry/timeentry.go +++ b/cmd/timeentry/timeentry.go @@ -3,7 +3,7 @@ package timeentry import "github.com/spf13/cobra" var RootCmd = &cobra.Command{ - Use: "timeentry [verb]", + Use: "time-entry [verb]", Short: "Manage time entries", Long: "List time entries in OpenProject.", } diff --git a/cmd/timeentry/timeentry_test.go b/cmd/timeentry/timeentry_test.go new file mode 100644 index 0000000..c150e57 --- /dev/null +++ b/cmd/timeentry/timeentry_test.go @@ -0,0 +1,15 @@ +package timeentry_test + +import ( + "strings" + "testing" + + "github.com/opf/openproject-cli/cmd/timeentry" +) + +func TestRootCmd_UsesHyphenatedName(t *testing.T) { + use := timeentry.RootCmd.Use + if !strings.HasPrefix(use, "time-entry") { + t.Errorf("expected command name 'time-entry', got %q", use) + } +} diff --git a/cmd/workpackage/workpackage.go b/cmd/workpackage/workpackage.go index 56b53f7..8b558b7 100644 --- a/cmd/workpackage/workpackage.go +++ b/cmd/workpackage/workpackage.go @@ -3,7 +3,7 @@ package workpackage import "github.com/spf13/cobra" var RootCmd = &cobra.Command{ - Use: "workpackage [verb]", + Use: "work-package [verb]", Short: "Manage work packages", Long: "Create, list, update, inspect, and manage work packages in OpenProject.", } diff --git a/cmd/workpackage/workpackage_test.go b/cmd/workpackage/workpackage_test.go new file mode 100644 index 0000000..71ad06e --- /dev/null +++ b/cmd/workpackage/workpackage_test.go @@ -0,0 +1,15 @@ +package workpackage_test + +import ( + "strings" + "testing" + + "github.com/opf/openproject-cli/cmd/workpackage" +) + +func TestRootCmd_UsesHyphenatedName(t *testing.T) { + use := workpackage.RootCmd.Use + if !strings.HasPrefix(use, "work-package") { + t.Errorf("expected command name 'work-package', got %q", use) + } +} From 0a08f0a6d794e96c55dda1f3cc00304d1f944002 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Wed, 1 Apr 2026 14:02:43 +0200 Subject: [PATCH 09/39] Ignore op binary in git go build -o op produces a local binary that should not be versioned. --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 98569df..fe38f9e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,9 @@ .fleet .idea -# Compile output, if running `go build` on a linux/x64 +# Compile output openproject-cli +op # If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore From 160c0838413bd163921ff54415b7f8a81920514b Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Wed, 1 Apr 2026 14:04:49 +0200 Subject: [PATCH 10/39] Add op budget list command Lists budgets for a given project via GET /api/v3/projects/{id}/budgets. --- cmd/budget/budget.go | 23 +++++++++++ cmd/budget/list.go | 27 +++++++++++++ cmd/root.go | 2 + components/paths/paths.go | 12 ++++++ components/printer/budgets.go | 11 ++++++ components/printer/budgets_test.go | 47 +++++++++++++++++++++++ components/printer/json_renderer.go | 19 +++++++++ components/printer/renderer.go | 2 + components/printer/text_renderer.go | 20 ++++++++++ components/resources/budgets/functions.go | 20 ++++++++++ dtos/budget.go | 33 ++++++++++++++++ models/budget.go | 6 +++ 12 files changed, 222 insertions(+) create mode 100644 cmd/budget/budget.go create mode 100644 cmd/budget/list.go create mode 100644 components/printer/budgets.go create mode 100644 components/printer/budgets_test.go create mode 100644 components/resources/budgets/functions.go create mode 100644 dtos/budget.go create mode 100644 models/budget.go diff --git a/cmd/budget/budget.go b/cmd/budget/budget.go new file mode 100644 index 0000000..b370762 --- /dev/null +++ b/cmd/budget/budget.go @@ -0,0 +1,23 @@ +package budget + +import "github.com/spf13/cobra" + +var RootCmd = &cobra.Command{ + Use: "budget [verb]", + Short: "Manage budgets", + Long: "List and inspect budgets in OpenProject.", +} + +func init() { + listCmd.Flags().Uint64VarP( + &listProjectId, + "project", + "p", + 0, + "Project id", + ) + + _ = listCmd.MarkFlagRequired("project") + + RootCmd.AddCommand(listCmd) +} diff --git a/cmd/budget/list.go b/cmd/budget/list.go new file mode 100644 index 0000000..f1ab3f5 --- /dev/null +++ b/cmd/budget/list.go @@ -0,0 +1,27 @@ +package budget + +import ( + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/resources/budgets" +) + +var listProjectId uint64 + +var listCmd = &cobra.Command{ + Use: "list", + Short: "Lists budgets of a project", + Long: "Get a list of all budgets for a given project.", + Run: listBudgets, +} + +func listBudgets(_ *cobra.Command, _ []string) { + all, err := budgets.AllForProject(listProjectId) + if err != nil { + printer.Error(err) + return + } + + printer.Budgets(all) +} diff --git a/cmd/root.go b/cmd/root.go index 3faec4b..0079dc3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/opf/openproject-cli/cmd/activities" + "github.com/opf/openproject-cli/cmd/budget" "github.com/opf/openproject-cli/cmd/git" "github.com/opf/openproject-cli/cmd/notification" "github.com/opf/openproject-cli/cmd/project" @@ -109,6 +110,7 @@ func init() { loginCmd, // noun-first (new) activities.RootCmd, + budget.RootCmd, workpackage.RootCmd, project.RootCmd, user.RootCmd, diff --git a/components/paths/paths.go b/components/paths/paths.go index 7561e84..64149b6 100644 --- a/components/paths/paths.go +++ b/components/paths/paths.go @@ -26,6 +26,18 @@ func ProjectWorkPackages(projectId uint64) string { return Project(projectId) + "/work_packages" } +func Budget(id uint64) string { + return Budgets() + fmt.Sprintf("/%d", id) +} + +func Budgets() string { + return Root() + "/budgets" +} + +func ProjectBudgets(projectId uint64) string { + return Project(projectId) + "/budgets" +} + func Root() string { return "/api/v3" } diff --git a/components/printer/budgets.go b/components/printer/budgets.go new file mode 100644 index 0000000..83bd8b7 --- /dev/null +++ b/components/printer/budgets.go @@ -0,0 +1,11 @@ +package printer + +import "github.com/opf/openproject-cli/models" + +func Budget(budget *models.Budget) { + activeRenderer.Budget(budget) +} + +func Budgets(budgets []*models.Budget) { + activeRenderer.Budgets(budgets) +} diff --git a/components/printer/budgets_test.go b/components/printer/budgets_test.go new file mode 100644 index 0000000..00df42e --- /dev/null +++ b/components/printer/budgets_test.go @@ -0,0 +1,47 @@ +package printer_test + +import ( + "fmt" + "strconv" + "testing" + + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/models" +) + +func TestBudget(t *testing.T) { + testingPrinter.Reset() + + budget := models.Budget{Id: 7, Subject: "Conception"} + + idString := "#" + strconv.FormatUint(budget.Id, 10) + expected := fmt.Sprintf("%s %s\n", printer.Red(idString), printer.Cyan(budget.Subject)) + + printer.Budget(&budget) + + if testingPrinter.Result != expected { + t.Errorf("Expected %s, but got %s", expected, testingPrinter.Result) + } +} + +func TestBudgets_AlignsIds(t *testing.T) { + testingPrinter.Reset() + + budgets := []*models.Budget{ + {Id: 7, Subject: "Conception"}, + {Id: 42, Subject: "Développement"}, + {Id: 100, Subject: "Recette & déploiement"}, + } + + // IDs are right-aligned to the width of the longest ID (3 digits) + expected := "" + + fmt.Sprintf("%s %s\n", printer.Red(" #7"), printer.Cyan("Conception")) + + fmt.Sprintf("%s %s\n", printer.Red(" #42"), printer.Cyan("Développement")) + + fmt.Sprintf("%s %s\n", printer.Red("#100"), printer.Cyan("Recette & déploiement")) + + printer.Budgets(budgets) + + if testingPrinter.Result != expected { + t.Errorf("Expected %s, but got %s", expected, testingPrinter.Result) + } +} diff --git a/components/printer/json_renderer.go b/components/printer/json_renderer.go index cc81db2..88de5ed 100644 --- a/components/printer/json_renderer.go +++ b/components/printer/json_renderer.go @@ -10,6 +10,25 @@ import ( type JsonRenderer struct{} +func (r *JsonRenderer) Budget(b *models.Budget) { + printJson(struct { + Id uint64 `json:"id"` + Subject string `json:"subject"` + }{b.Id, b.Subject}) +} + +func (r *JsonRenderer) Budgets(bs []*models.Budget) { + type item struct { + Id uint64 `json:"id"` + Subject string `json:"subject"` + } + out := make([]item, len(bs)) + for i, b := range bs { + out[i] = item{b.Id, b.Subject} + } + printJson(out) +} + func (r *JsonRenderer) WorkPackage(wp *models.WorkPackage) { printJson(struct { Id uint64 `json:"id"` diff --git a/components/printer/renderer.go b/components/printer/renderer.go index bf14c31..6a2763a 100644 --- a/components/printer/renderer.go +++ b/components/printer/renderer.go @@ -7,6 +7,8 @@ import ( ) type Renderer interface { + Budget(*models.Budget) + Budgets([]*models.Budget) WorkPackage(*models.WorkPackage) WorkPackages([]*models.WorkPackage) Project(*models.Project) diff --git a/components/printer/text_renderer.go b/components/printer/text_renderer.go index 19846a8..acb247b 100644 --- a/components/printer/text_renderer.go +++ b/components/printer/text_renderer.go @@ -13,6 +13,20 @@ import ( type TextRenderer struct{} +func (r *TextRenderer) Budget(b *models.Budget) { + printBudget(b, idLength(b.Id)) +} + +func (r *TextRenderer) Budgets(bs []*models.Budget) { + var maxIdLength = 0 + for _, b := range bs { + maxIdLength = common.Max(maxIdLength, idLength(b.Id)) + } + for _, b := range bs { + printBudget(b, maxIdLength) + } +} + func (r *TextRenderer) WorkPackage(wp *models.WorkPackage) { printHeadline(wp, idLength(wp.Id), 0, utf8.RuneCountInString(wp.Type)) printAttributes(wp) @@ -144,6 +158,12 @@ func (r *TextRenderer) Number(n int64) { activePrinter.Printf("%s\n", Cyan(strconv.FormatInt(n, 10))) } +func printBudget(b *models.Budget, maxIdLength int) { + diff := maxIdLength - idLength(b.Id) + idStr := fmt.Sprintf("%s#%d", indent(diff), b.Id) + activePrinter.Printf("%s %s\n", Red(idStr), Cyan(b.Subject)) +} + func printProject(p *models.Project) { id := fmt.Sprintf("#%d", p.Id) activePrinter.Printf("%s %s\n", Red(id), Cyan(p.Name)) diff --git a/components/resources/budgets/functions.go b/components/resources/budgets/functions.go new file mode 100644 index 0000000..03f1390 --- /dev/null +++ b/components/resources/budgets/functions.go @@ -0,0 +1,20 @@ +package budgets + +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 AllForProject(projectId uint64) ([]*models.Budget, error) { + query := requests.NewPaginatedQuery(-1, nil) + response, err := requests.Get(paths.ProjectBudgets(projectId), &query) + if err != nil { + return nil, err + } + + element := parser.Parse[dtos.BudgetCollectionDto](response) + return element.Convert(), nil +} diff --git a/dtos/budget.go b/dtos/budget.go new file mode 100644 index 0000000..1107cbc --- /dev/null +++ b/dtos/budget.go @@ -0,0 +1,33 @@ +package dtos + +import "github.com/opf/openproject-cli/models" + +type BudgetDto struct { + Id int64 `json:"id"` + Subject string `json:"subject"` +} + +type budgetElements struct { + Elements []*BudgetDto `json:"elements"` +} + +type BudgetCollectionDto struct { + Embedded *budgetElements `json:"_embedded"` +} + +/////////////// MODEL CONVERSION /////////////// + +func (dto *BudgetCollectionDto) Convert() []*models.Budget { + budgets := make([]*models.Budget, len(dto.Embedded.Elements)) + for i, b := range dto.Embedded.Elements { + budgets[i] = b.Convert() + } + return budgets +} + +func (dto *BudgetDto) Convert() *models.Budget { + return &models.Budget{ + Id: uint64(dto.Id), + Subject: dto.Subject, + } +} diff --git a/models/budget.go b/models/budget.go new file mode 100644 index 0000000..fe734ba --- /dev/null +++ b/models/budget.go @@ -0,0 +1,6 @@ +package models + +type Budget struct { + Id uint64 + Subject string +} From 908202ebdd8182712030da11021b5e14e0190983 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Wed, 1 Apr 2026 14:16:13 +0200 Subject: [PATCH 11/39] Add op budget inspect command Fetches a single budget by ID via GET /api/v3/budgets/{id}. --- cmd/budget/budget.go | 2 +- cmd/budget/inspect.go | 39 +++++++++++++++++++++++ components/resources/budgets/functions.go | 10 ++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 cmd/budget/inspect.go diff --git a/cmd/budget/budget.go b/cmd/budget/budget.go index b370762..5e25334 100644 --- a/cmd/budget/budget.go +++ b/cmd/budget/budget.go @@ -19,5 +19,5 @@ func init() { _ = listCmd.MarkFlagRequired("project") - RootCmd.AddCommand(listCmd) + RootCmd.AddCommand(listCmd, inspectCmd) } diff --git a/cmd/budget/inspect.go b/cmd/budget/inspect.go new file mode 100644 index 0000000..b9db003 --- /dev/null +++ b/cmd/budget/inspect.go @@ -0,0 +1,39 @@ +package budget + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/resources/budgets" +) + +var inspectCmd = &cobra.Command{ + Use: "inspect [id]", + Short: "Show details about a budget", + Long: "Show detailed information of a budget referenced by its ID.", + Run: inspectBudget, +} + +func inspectBudget(_ *cobra.Command, args []string) { + if len(args) != 1 { + printer.ErrorText(fmt.Sprintf("Expected 1 argument [id], but got %d", len(args))) + return + } + + id, err := strconv.ParseUint(args[0], 10, 64) + if err != nil { + printer.ErrorText(fmt.Sprintf("'%s' is an invalid budget id. Must be a number.", args[0])) + return + } + + budget, err := budgets.Lookup(id) + if err != nil { + printer.Error(err) + return + } + + printer.Budget(budget) +} diff --git a/components/resources/budgets/functions.go b/components/resources/budgets/functions.go index 03f1390..13ae3dc 100644 --- a/components/resources/budgets/functions.go +++ b/components/resources/budgets/functions.go @@ -8,6 +8,16 @@ import ( "github.com/opf/openproject-cli/models" ) +func Lookup(id uint64) (*models.Budget, error) { + response, err := requests.Get(paths.Budget(id), nil) + if err != nil { + return nil, err + } + + element := parser.Parse[dtos.BudgetDto](response) + return element.Convert(), nil +} + func AllForProject(projectId uint64) ([]*models.Budget, error) { query := requests.NewPaginatedQuery(-1, nil) response, err := requests.Get(paths.ProjectBudgets(projectId), &query) From 335b21b5feafa35d7e528ac4122c14b954a9b876 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Wed, 1 Apr 2026 14:28:11 +0200 Subject: [PATCH 12/39] Hide --format and --verbose flags from completion command These flags are irrelevant for shell completion generation. --- cmd/root.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cmd/root.go b/cmd/root.go index 0079dc3..f750625 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -120,4 +120,12 @@ func init() { notification.RootCmd, git.RootCmd, ) + + rootCmd.InitDefaultCompletionCmd() + for _, cmd := range rootCmd.Commands() { + if cmd.Name() == "completion" { + _ = cmd.InheritedFlags().MarkHidden("format") + _ = cmd.InheritedFlags().MarkHidden("verbose") + } + } } From d0d3915457c94b1e0f01e5bcc62154bd0e3f9341 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 3 Apr 2026 15:02:37 +0200 Subject: [PATCH 13/39] Add --assignee flag to work-package create Assignee was already supported for update and display; create was missing it. Takes a user ID, consistent with the update command. --- cmd/workpackage/create.go | 5 +++++ cmd/workpackage/workpackage.go | 6 ++++++ components/resources/work_packages/create.go | 21 ++++++++++++++++++-- components/resources/work_packages/update.go | 5 ++++- 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/cmd/workpackage/create.go b/cmd/workpackage/create.go index d0ff233..cad3da1 100644 --- a/cmd/workpackage/create.go +++ b/cmd/workpackage/create.go @@ -2,6 +2,7 @@ package workpackage import ( "fmt" + "strconv" "github.com/spf13/cobra" @@ -14,6 +15,7 @@ import ( var createProjectId uint64 var createOpenInBrowser bool var createTypeFlag string +var createAssigneeFlag uint64 var createCmd = &cobra.Command{ Use: "create [subject]", @@ -51,5 +53,8 @@ func createOptions(subject string) map[work_packages.CreateOption]string { if len(createTypeFlag) > 0 { options[work_packages.CreateType] = createTypeFlag } + if createAssigneeFlag > 0 { + options[work_packages.CreateAssignee] = strconv.FormatUint(createAssigneeFlag, 10) + } return options } diff --git a/cmd/workpackage/workpackage.go b/cmd/workpackage/workpackage.go index 8b558b7..3126fd7 100644 --- a/cmd/workpackage/workpackage.go +++ b/cmd/workpackage/workpackage.go @@ -33,6 +33,12 @@ func init() { "", "Change the work package type", ) + createCmd.Flags().Uint64Var( + &createAssigneeFlag, + "assignee", + 0, + "Assign a user to the work package", + ) updateCmd.Flags().StringVarP( &updateActionFlag, diff --git a/components/resources/work_packages/create.go b/components/resources/work_packages/create.go index 65e5118..43ec31c 100644 --- a/components/resources/work_packages/create.go +++ b/components/resources/work_packages/create.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "strconv" "github.com/opf/openproject-cli/components/parser" "github.com/opf/openproject-cli/components/paths" @@ -18,11 +19,13 @@ type CreateOption int const ( CreateSubject CreateOption = iota CreateType + CreateAssignee ) var createMap = map[CreateOption]func(projectId uint64, workPackage *dtos.WorkPackageDto, input string) error{ - CreateSubject: subjectCreate, - CreateType: typeCreate, + CreateSubject: subjectCreate, + CreateType: typeCreate, + CreateAssignee: assigneeCreate, } func subjectCreate(_ uint64, workPackage *dtos.WorkPackageDto, input string) error { @@ -60,6 +63,20 @@ func typeCreate(projectId uint64, workPackage *dtos.WorkPackageDto, input string return nil } +func assigneeCreate(_ uint64, workPackage *dtos.WorkPackageDto, input string) error { + userId, err := strconv.ParseUint(input, 10, 64) + if err != nil { + return fmt.Errorf("invalid user id %q: must be a number", input) + } + + if workPackage.Links == nil { + workPackage.Links = &dtos.WorkPackageLinksDto{} + } + + workPackage.Links.Assignee = &dtos.LinkDto{Href: paths.User(userId)} + return nil +} + func Create(projectId uint64, options map[CreateOption]string) (*models.WorkPackage, error) { return create(projectId, options) } diff --git a/components/resources/work_packages/update.go b/components/resources/work_packages/update.go index 7cc751d..08730c4 100644 --- a/components/resources/work_packages/update.go +++ b/components/resources/work_packages/update.go @@ -151,7 +151,10 @@ func subjectPatch(patch, _ *dtos.WorkPackageDto, input string) (string, error) { } func assigneePatch(patch, _ *dtos.WorkPackageDto, input string) (string, error) { - userId, _ := strconv.ParseUint(input, 10, 64) + userId, err := strconv.ParseUint(input, 10, 64) + if err != nil { + return "", fmt.Errorf("invalid user id %q: must be a number", input) + } if patch.Links == nil { patch.Links = &dtos.WorkPackageLinksDto{} From 8197ca18d769ba9a8d1141a54894a503f44e4a09 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 3 Apr 2026 15:53:28 +0200 Subject: [PATCH 14/39] Add --description flag to work-package create and update Supports multi-line markdown descriptions. Format is hardcoded to markdown as textile is no longer supported in OpenProject. --- cmd/workpackage/create.go | 10 ++- cmd/workpackage/options_test.go | 67 ++++++++++++++++++++ cmd/workpackage/update.go | 10 ++- cmd/workpackage/workpackage.go | 12 ++++ components/resources/work_packages/create.go | 13 +++- components/resources/work_packages/update.go | 15 +++-- dtos/work_package.go | 24 +++++-- 7 files changed, 134 insertions(+), 17 deletions(-) create mode 100644 cmd/workpackage/options_test.go diff --git a/cmd/workpackage/create.go b/cmd/workpackage/create.go index cad3da1..34df402 100644 --- a/cmd/workpackage/create.go +++ b/cmd/workpackage/create.go @@ -16,6 +16,7 @@ var createProjectId uint64 var createOpenInBrowser bool var createTypeFlag string var createAssigneeFlag uint64 +var createDescriptionFlag string var createCmd = &cobra.Command{ Use: "create [subject]", @@ -24,14 +25,14 @@ var createCmd = &cobra.Command{ Run: createWorkPackage, } -func createWorkPackage(_ *cobra.Command, args []string) { +func createWorkPackage(cmd *cobra.Command, args []string) { if len(args) != 1 { printer.ErrorText(fmt.Sprintf("Expected 1 argument [subject], but got %d", len(args))) return } subject := args[0] - workPackage, err := work_packages.Create(createProjectId, createOptions(subject)) + workPackage, err := work_packages.Create(createProjectId, createOptions(cmd, subject)) if err != nil { printer.Error(err) return @@ -47,7 +48,7 @@ func createWorkPackage(_ *cobra.Command, args []string) { } } -func createOptions(subject string) map[work_packages.CreateOption]string { +func createOptions(cmd *cobra.Command, subject string) map[work_packages.CreateOption]string { options := make(map[work_packages.CreateOption]string) options[work_packages.CreateSubject] = subject if len(createTypeFlag) > 0 { @@ -56,5 +57,8 @@ func createOptions(subject string) map[work_packages.CreateOption]string { if createAssigneeFlag > 0 { options[work_packages.CreateAssignee] = strconv.FormatUint(createAssigneeFlag, 10) } + if cmd.Flags().Changed("description") { + options[work_packages.CreateDescription] = createDescriptionFlag + } return options } diff --git a/cmd/workpackage/options_test.go b/cmd/workpackage/options_test.go new file mode 100644 index 0000000..bb80103 --- /dev/null +++ b/cmd/workpackage/options_test.go @@ -0,0 +1,67 @@ +package workpackage + +import ( + "testing" + + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/resources/work_packages" +) + +func newCmdWithDescriptionFlag(flag *string) *cobra.Command { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().StringVar(flag, "description", "", "") + return cmd +} + +func TestCreateOptions_DescriptionOmitted(t *testing.T) { + createDescriptionFlag = "" + cmd := newCmdWithDescriptionFlag(&createDescriptionFlag) + _ = cmd.Flags().Parse([]string{}) + + options := createOptions(cmd, "subject") + + if _, ok := options[work_packages.CreateDescription]; ok { + t.Error("expected CreateDescription to be absent when flag not provided") + } +} + +func TestCreateOptions_DescriptionProvided(t *testing.T) { + createDescriptionFlag = "" + cmd := newCmdWithDescriptionFlag(&createDescriptionFlag) + _ = cmd.Flags().Parse([]string{"--description", "## Hello"}) + + options := createOptions(cmd, "subject") + + val, ok := options[work_packages.CreateDescription] + if !ok { + t.Error("expected CreateDescription to be present when flag provided") + } + if val != "## Hello" { + t.Errorf("expected %q, got %q", "## Hello", val) + } +} + +func TestUpdateOptions_DescriptionOmitted(t *testing.T) { + updateDescriptionFlag = "" + cmd := newCmdWithDescriptionFlag(&updateDescriptionFlag) + _ = cmd.Flags().Parse([]string{}) + + options := updateOptions(cmd) + + if _, ok := options[work_packages.UpdateDescription]; ok { + t.Error("expected UpdateDescription to be absent when flag not provided") + } +} + +func TestUpdateOptions_DescriptionProvided(t *testing.T) { + updateDescriptionFlag = "" + cmd := newCmdWithDescriptionFlag(&updateDescriptionFlag) + _ = cmd.Flags().Parse([]string{"--description", ""}) + + options := updateOptions(cmd) + + if _, ok := options[work_packages.UpdateDescription]; !ok { + t.Error("expected UpdateDescription to be present when flag explicitly provided with empty string") + } +} diff --git a/cmd/workpackage/update.go b/cmd/workpackage/update.go index 8b4f618..a7aa2b5 100644 --- a/cmd/workpackage/update.go +++ b/cmd/workpackage/update.go @@ -13,6 +13,7 @@ import ( var updateActionFlag string var updateAssigneeFlag uint64 var updateAttachFlag string +var updateDescriptionFlag string var updateSubjectFlag string var updateTypeFlag string @@ -24,7 +25,7 @@ provided by a flag is executed on its own.`, Run: updateWorkPackage, } -func updateWorkPackage(_ *cobra.Command, args []string) { +func updateWorkPackage(cmd *cobra.Command, args []string) { if len(args) != 1 { printer.ErrorText(fmt.Sprintf("Expected 1 argument [id], but got %d", len(args))) return @@ -36,7 +37,7 @@ func updateWorkPackage(_ *cobra.Command, args []string) { return } - if workPackage, err := work_packages.Update(id, updateOptions()); err == nil { + if workPackage, err := work_packages.Update(id, updateOptions(cmd)); err == nil { printer.Info("-- ") printer.WorkPackage(workPackage) } else { @@ -44,7 +45,7 @@ func updateWorkPackage(_ *cobra.Command, args []string) { } } -func updateOptions() map[work_packages.UpdateOption]string { +func updateOptions(cmd *cobra.Command) map[work_packages.UpdateOption]string { options := make(map[work_packages.UpdateOption]string) if len(updateActionFlag) > 0 { options[work_packages.UpdateCustomAction] = updateActionFlag @@ -55,6 +56,9 @@ func updateOptions() map[work_packages.UpdateOption]string { if len(updateAttachFlag) > 0 { options[work_packages.UpdateAttachment] = updateAttachFlag } + if cmd.Flags().Changed("description") { + options[work_packages.UpdateDescription] = updateDescriptionFlag + } if len(updateSubjectFlag) > 0 { options[work_packages.UpdateSubject] = updateSubjectFlag } diff --git a/cmd/workpackage/workpackage.go b/cmd/workpackage/workpackage.go index 3126fd7..31a0fcd 100644 --- a/cmd/workpackage/workpackage.go +++ b/cmd/workpackage/workpackage.go @@ -39,6 +39,12 @@ func init() { 0, "Assign a user to the work package", ) + createCmd.Flags().StringVar( + &createDescriptionFlag, + "description", + "", + "Description of the work package (markdown)", + ) updateCmd.Flags().StringVarP( &updateActionFlag, @@ -59,6 +65,12 @@ func init() { "", "Attach a file to the work package", ) + updateCmd.Flags().StringVar( + &updateDescriptionFlag, + "description", + "", + "Description of the work package (markdown)", + ) updateCmd.Flags().StringVar( &updateSubjectFlag, "subject", diff --git a/components/resources/work_packages/create.go b/components/resources/work_packages/create.go index 43ec31c..92079c3 100644 --- a/components/resources/work_packages/create.go +++ b/components/resources/work_packages/create.go @@ -20,12 +20,14 @@ const ( CreateSubject CreateOption = iota CreateType CreateAssignee + CreateDescription ) var createMap = map[CreateOption]func(projectId uint64, workPackage *dtos.WorkPackageDto, input string) error{ - CreateSubject: subjectCreate, - CreateType: typeCreate, - CreateAssignee: assigneeCreate, + CreateSubject: subjectCreate, + CreateType: typeCreate, + CreateAssignee: assigneeCreate, + CreateDescription: descriptionCreate, } func subjectCreate(_ uint64, workPackage *dtos.WorkPackageDto, input string) error { @@ -77,6 +79,11 @@ func assigneeCreate(_ uint64, workPackage *dtos.WorkPackageDto, input string) er return nil } +func descriptionCreate(_ uint64, workPackage *dtos.WorkPackageDto, input string) error { + workPackage.Description = &dtos.LongTextDto{Format: "markdown", Raw: input} + return nil +} + func Create(projectId uint64, options map[CreateOption]string) (*models.WorkPackage, error) { return create(projectId, options) } diff --git a/components/resources/work_packages/update.go b/components/resources/work_packages/update.go index 08730c4..e6644f1 100644 --- a/components/resources/work_packages/update.go +++ b/components/resources/work_packages/update.go @@ -21,16 +21,18 @@ const ( UpdateCustomAction UpdateOption = iota UpdateAssignee UpdateAttachment + UpdateDescription UpdateSubject UpdateType ) -var patchableUpdates = []UpdateOption{UpdateSubject, UpdateType, UpdateAssignee} +var patchableUpdates = []UpdateOption{UpdateSubject, UpdateType, UpdateAssignee, UpdateDescription} var patchMap = map[UpdateOption]func(patch, workPackage *dtos.WorkPackageDto, input string) (string, error){ - UpdateAssignee: assigneePatch, - UpdateType: typePatch, - UpdateSubject: subjectPatch, + UpdateAssignee: assigneePatch, + UpdateDescription: descriptionPatch, + UpdateType: typePatch, + UpdateSubject: subjectPatch, } func Update(id uint64, options map[UpdateOption]string) (*models.WorkPackage, error) { @@ -150,6 +152,11 @@ func subjectPatch(patch, _ *dtos.WorkPackageDto, input string) (string, error) { return fmt.Sprintf("Subject -> %s", input), nil } +func descriptionPatch(patch, _ *dtos.WorkPackageDto, input string) (string, error) { + patch.Description = &dtos.LongTextDto{Format: "markdown", Raw: input} + return "Description updated", nil +} + func assigneePatch(patch, _ *dtos.WorkPackageDto, input string) (string, error) { userId, err := strconv.ParseUint(input, 10, 64) if err != nil { diff --git a/dtos/work_package.go b/dtos/work_package.go index 8110746..b3345b0 100644 --- a/dtos/work_package.go +++ b/dtos/work_package.go @@ -48,13 +48,29 @@ 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 + } + } + if dto.Description != nil { + description = dto.Description.Raw + } + return &models.WorkPackage{ Id: uint64(dto.Id), Subject: dto.Subject, - Type: dto.Links.Type.Title, - Assignee: dto.Links.Assignee.Title, - Status: dto.Links.Status.Title, - Description: dto.Description.Raw, + Type: wpType, + Assignee: assignee, + Status: status, + Description: description, LockVersion: dto.LockVersion, } } From d785b77cb87b6d70c6df366cba2ed5b718383592 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 3 Apr 2026 16:41:50 +0200 Subject: [PATCH 15/39] Add whoami command Shows configured server URL and current user info. Handles missing config (not logged in) and 401 (invalid token) gracefully. --- cmd/root.go | 1 + cmd/whoami.go | 47 +++++++++++++++++++++++++++++ components/printer/json_renderer.go | 8 +++++ components/printer/renderer.go | 1 + components/printer/text_renderer.go | 5 +++ components/printer/whoami.go | 7 +++++ 6 files changed, 69 insertions(+) create mode 100644 cmd/whoami.go create mode 100644 components/printer/whoami.go diff --git a/cmd/root.go b/cmd/root.go index f750625..7c4d8be 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -108,6 +108,7 @@ func init() { rootCmd.AddCommand( loginCmd, + whoamiCmd, // noun-first (new) activities.RootCmd, budget.RootCmd, diff --git a/cmd/whoami.go b/cmd/whoami.go new file mode 100644 index 0000000..56e4bcb --- /dev/null +++ b/cmd/whoami.go @@ -0,0 +1,47 @@ +package cmd + +import ( + stderrors "errors" + "net/http" + + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/configuration" + openerrors "github.com/opf/openproject-cli/components/errors" + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/resources/users" +) + +var whoamiCmd = &cobra.Command{ + Use: "whoami", + Short: "Show current user and server", + Long: "Display the configured OpenProject server and the currently authenticated user.", + Run: whoami, +} + +func whoami(_ *cobra.Command, _ []string) { + host, _, err := configuration.ReadConfig() + if err != nil { + printer.Error(err) + return + } + + if host == "" { + printer.ErrorText("Not logged in. Run `op login` to authenticate.") + return + } + + user, err := users.Me() + if err != nil { + printer.Info("Server: " + host) + var responseErr *openerrors.ResponseError + if stderrors.As(err, &responseErr) && responseErr.Status() == http.StatusUnauthorized { + printer.ErrorText("Invalid or expired token. Run `op login` to re-authenticate.") + } else { + printer.Error(err) + } + return + } + + printer.Whoami(host, user) +} diff --git a/components/printer/json_renderer.go b/components/printer/json_renderer.go index 88de5ed..52dfd6d 100644 --- a/components/printer/json_renderer.go +++ b/components/printer/json_renderer.go @@ -231,6 +231,14 @@ func (r *JsonRenderer) CustomActions(actions []*models.CustomAction) { printJson(out) } +func (r *JsonRenderer) Whoami(host string, user *models.User) { + printJson(struct { + Server string `json:"server"` + Id uint64 `json:"id"` + Name string `json:"name"` + }{host, user.Id, user.Name}) +} + func (r *JsonRenderer) Number(n int64) { printJson(struct { Total int64 `json:"total"` diff --git a/components/printer/renderer.go b/components/printer/renderer.go index 6a2763a..f2825a0 100644 --- a/components/printer/renderer.go +++ b/components/printer/renderer.go @@ -25,6 +25,7 @@ type Renderer interface { Activities([]*models.Activity, []*models.User) CustomActions([]*models.CustomAction) Number(int64) + Whoami(host string, user *models.User) } var activeRenderer Renderer = &TextRenderer{} diff --git a/components/printer/text_renderer.go b/components/printer/text_renderer.go index acb247b..0247223 100644 --- a/components/printer/text_renderer.go +++ b/components/printer/text_renderer.go @@ -154,6 +154,11 @@ func (r *TextRenderer) CustomActions(actions []*models.CustomAction) { } } +func (r *TextRenderer) Whoami(host string, user *models.User) { + activePrinter.Printf("Server: %s\n", Cyan(host)) + activePrinter.Printf("User: %s %s\n", Red(fmt.Sprintf("#%d", user.Id)), Cyan(user.Name)) +} + func (r *TextRenderer) Number(n int64) { activePrinter.Printf("%s\n", Cyan(strconv.FormatInt(n, 10))) } diff --git a/components/printer/whoami.go b/components/printer/whoami.go new file mode 100644 index 0000000..c8e35e1 --- /dev/null +++ b/components/printer/whoami.go @@ -0,0 +1,7 @@ +package printer + +import "github.com/opf/openproject-cli/models" + +func Whoami(host string, user *models.User) { + activeRenderer.Whoami(host, user) +} From 3a61c8b262387323bc9d4f6ef240e651f298498f Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 3 Apr 2026 16:55:20 +0200 Subject: [PATCH 16/39] Add op time-entry create command Supports --work-package (required), --hours (required), --activity (optional, looked up by name), --spent-on (default: today), --user, --comment flags. Also fixes nil pointer dereferences in TimeEntryDto.Convert() for optional linked resources (project, work package, user, activity, comment). --- cmd/timeentry/create.go | 59 +++++++ cmd/timeentry/create_flags.go | 13 ++ cmd/timeentry/timeentry.go | 5 +- components/paths/paths.go | 8 + components/resources/time_entries/create.go | 170 ++++++++++++++++++++ dtos/time_entry.go | 39 +++-- dtos/time_entry_activity.go | 48 ++++++ models/time_entry_activity.go | 8 + 8 files changed, 339 insertions(+), 11 deletions(-) create mode 100644 cmd/timeentry/create.go create mode 100644 cmd/timeentry/create_flags.go create mode 100644 components/resources/time_entries/create.go create mode 100644 dtos/time_entry_activity.go create mode 100644 models/time_entry_activity.go diff --git a/cmd/timeentry/create.go b/cmd/timeentry/create.go new file mode 100644 index 0000000..1ee569f --- /dev/null +++ b/cmd/timeentry/create.go @@ -0,0 +1,59 @@ +package timeentry + +import ( + "strconv" + "time" + + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/resources/time_entries" +) + +var createWorkPackageId uint64 +var createHours float64 +var createActivity string +var createSpentOn string +var createUserId uint64 +var createComment string + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a time entry", + Long: "Log time spent on a work package.", + Run: createTimeEntry, +} + +func createTimeEntry(cmd *cobra.Command, _ []string) { + options := map[time_entries.CreateOption]string{ + time_entries.CreateWorkPackage: strconv.FormatUint(createWorkPackageId, 10), + time_entries.CreateHours: strconv.FormatFloat(createHours, 'f', -1, 64), + } + + if cmd.Flags().Changed("activity") { + options[time_entries.CreateActivity] = createActivity + } + + if cmd.Flags().Changed("spent-on") { + options[time_entries.CreateSpentOn] = createSpentOn + } else { + options[time_entries.CreateSpentOn] = time.Now().Format(time.DateOnly) + } + + if createUserId > 0 { + options[time_entries.CreateUser] = strconv.FormatUint(createUserId, 10) + } + + if cmd.Flags().Changed("comment") { + options[time_entries.CreateComment] = createComment + } + + entry, err := time_entries.Create(options) + if err != nil { + printer.Error(err) + return + } + + printer.TimeEntry(entry) + printer.Done() +} diff --git a/cmd/timeentry/create_flags.go b/cmd/timeentry/create_flags.go new file mode 100644 index 0000000..2a19b9a --- /dev/null +++ b/cmd/timeentry/create_flags.go @@ -0,0 +1,13 @@ +package timeentry + +func initCreateFlags() { + createCmd.Flags().Uint64VarP(&createWorkPackageId, "work-package", "w", 0, "Work package ID to log time on") + createCmd.Flags().Float64VarP(&createHours, "hours", "", 0, "Number of hours spent (e.g. 1.5 for 1h30m)") + createCmd.Flags().StringVarP(&createActivity, "activity", "", "", "Activity name (e.g. Development, Design)") + createCmd.Flags().StringVarP(&createSpentOn, "spent-on", "", "", "Date of the time entry (YYYY-MM-DD, default: today)") + createCmd.Flags().Uint64VarP(&createUserId, "user", "u", 0, "User ID (default: current user)") + createCmd.Flags().StringVarP(&createComment, "comment", "", "", "Comment for the time entry") + + _ = createCmd.MarkFlagRequired("work-package") + _ = createCmd.MarkFlagRequired("hours") +} diff --git a/cmd/timeentry/timeentry.go b/cmd/timeentry/timeentry.go index 6990696..c6bc42f 100644 --- a/cmd/timeentry/timeentry.go +++ b/cmd/timeentry/timeentry.go @@ -5,11 +5,12 @@ import "github.com/spf13/cobra" var RootCmd = &cobra.Command{ Use: "time-entry [verb]", Short: "Manage time entries", - Long: "List time entries in OpenProject.", + Long: "List and create time entries in OpenProject.", } func init() { initListFlags() + initCreateFlags() - RootCmd.AddCommand(listCmd) + RootCmd.AddCommand(listCmd, createCmd) } diff --git a/components/paths/paths.go b/components/paths/paths.go index 64149b6..ef8d5b5 100644 --- a/components/paths/paths.go +++ b/components/paths/paths.go @@ -50,6 +50,14 @@ func TimeEntries() string { return Root() + "/time_entries" } +func TimeEntry(id uint64) string { + return TimeEntries() + fmt.Sprintf("/%d", id) +} + +func TimeEntryActivities() string { + return TimeEntries() + "/activities" +} + func Types() string { return Root() + "/types" } diff --git a/components/resources/time_entries/create.go b/components/resources/time_entries/create.go new file mode 100644 index 0000000..f4c07c1 --- /dev/null +++ b/components/resources/time_entries/create.go @@ -0,0 +1,170 @@ +package time_entries + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "strings" + "time" + + "github.com/opf/openproject-cli/components/parser" + "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/dtos" + "github.com/opf/openproject-cli/models" +) + +type CreateOption int + +const ( + CreateWorkPackage CreateOption = iota + CreateHours + CreateActivity + CreateSpentOn + CreateUser + CreateComment +) + +var createMap = map[CreateOption]func(entry *dtos.TimeEntryDto, input string) error{ + CreateWorkPackage: workPackageCreate, + CreateHours: hoursCreate, + CreateActivity: activityCreate, + CreateSpentOn: spentOnCreate, + CreateUser: userCreate, + CreateComment: commentCreate, +} + +func workPackageCreate(entry *dtos.TimeEntryDto, input string) error { + var id uint64 + if _, err := fmt.Sscanf(input, "%d", &id); err != nil { + return fmt.Errorf("invalid work package id %q: must be a number", input) + } + if entry.Links == nil { + entry.Links = &dtos.TimeEntryLinksDto{} + } + entry.Links.WorkPackage = &dtos.LinkDto{Href: paths.WorkPackage(id)} + return nil +} + +func hoursCreate(entry *dtos.TimeEntryDto, input string) error { + var hours float64 + if _, err := fmt.Sscanf(input, "%f", &hours); err != nil { + return fmt.Errorf("invalid hours %q: must be a number", input) + } + if hours <= 0 { + return fmt.Errorf("hours must be greater than 0") + } + entry.Hours = hoursToISO8601(hours) + return nil +} + +func hoursToISO8601(hours float64) string { + totalMinutes := int(math.Round(hours * 60)) + h := totalMinutes / 60 + m := totalMinutes % 60 + if m == 0 { + return fmt.Sprintf("PT%dH", h) + } + return fmt.Sprintf("PT%dH%dM", h, m) +} + +func activityCreate(entry *dtos.TimeEntryDto, input string) error { + activities, err := AllActivities() + if err != nil { + return err + } + + found := findActivity(input, activities) + if found == nil { + printer.ErrorText(fmt.Sprintf( + "No activity matching %q found. Available activities:", + input, + )) + for _, a := range activities { + printer.Info(fmt.Sprintf(" - %s", a.Name)) + } + return fmt.Errorf("activity %q not found", input) + } + + if entry.Links == nil { + entry.Links = &dtos.TimeEntryLinksDto{} + } + entry.Links.Activity = &dtos.LinkDto{Href: found.Href} + return nil +} + +func findActivity(input string, activities []*models.TimeEntryActivity) *models.TimeEntryActivity { + lower := strings.ToLower(input) + for _, a := range activities { + if strings.ToLower(a.Name) == lower { + return a + } + } + // partial match fallback + for _, a := range activities { + if strings.HasPrefix(strings.ToLower(a.Name), lower) { + return a + } + } + return nil +} + +func spentOnCreate(entry *dtos.TimeEntryDto, input string) error { + if _, err := time.Parse(time.DateOnly, input); err != nil { + return fmt.Errorf("invalid date %q: expected format YYYY-MM-DD", input) + } + entry.SpentOn = input + return nil +} + +func userCreate(entry *dtos.TimeEntryDto, input string) error { + var id uint64 + if _, err := fmt.Sscanf(input, "%d", &id); err != nil { + return fmt.Errorf("invalid user id %q: must be a number", input) + } + if entry.Links == nil { + entry.Links = &dtos.TimeEntryLinksDto{} + } + entry.Links.User = &dtos.LinkDto{Href: paths.User(id)} + return nil +} + +func commentCreate(entry *dtos.TimeEntryDto, input string) error { + entry.Comment = &dtos.LongTextDto{Format: "plain", Raw: input} + return nil +} + +func AllActivities() ([]*models.TimeEntryActivity, error) { + response, err := requests.Get(paths.TimeEntryActivities(), nil) + if err != nil { + return nil, err + } + element := parser.Parse[dtos.TimeEntryActivityCollectionDto](response) + return element.Convert(), nil +} + +func Create(options map[CreateOption]string) (*models.TimeEntry, error) { + entry := &dtos.TimeEntryDto{} + + for option, value := range options { + if err := createMap[option](entry, value); err != nil { + return nil, err + } + } + + data, err := json.Marshal(entry) + if err != nil { + return nil, err + } + + requestData := requests.RequestData{ContentType: "application/json", Body: bytes.NewReader(data)} + response, err := requests.Post(paths.TimeEntries(), &requestData) + if err != nil { + return nil, err + } + + result := parser.Parse[dtos.TimeEntryDto](response) + return result.Convert(), nil +} diff --git a/dtos/time_entry.go b/dtos/time_entry.go index 851677f..ec9b9d1 100644 --- a/dtos/time_entry.go +++ b/dtos/time_entry.go @@ -15,7 +15,7 @@ type TimeEntryDto struct { Ongoing bool `json:"ongoing,omitempty"` CreatedAt string `json:"createdAt,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"` - Links *timeEntryLinksDto `json:"_links,omitempty"` + Links *TimeEntryLinksDto `json:"_links,omitempty"` } type timeEntryElements struct { @@ -29,10 +29,10 @@ type TimeEntryCollectionDto struct { Count uint64 `json:"count"` } -type timeEntryLinksDto struct { - Project *LinkDto `json:"project"` +type TimeEntryLinksDto struct { + Project *LinkDto `json:"project,omitempty"` WorkPackage *LinkDto `json:"workPackage,omitempty"` - User *LinkDto `json:"user"` + User *LinkDto `json:"user,omitempty"` Activity *LinkDto `json:"activity,omitempty"` } @@ -42,16 +42,37 @@ func (dto *TimeEntryDto) Convert() *models.TimeEntry { hours, _ := duration.Parse(dto.Hours) spentOn, _ := time.Parse(time.DateOnly, dto.SpentOn) + comment := "" + if dto.Comment != nil { + comment = dto.Comment.Raw + } + + project, workPackage, user, activity := "", "", "", "" + if dto.Links != nil { + if dto.Links.Project != nil { + project = dto.Links.Project.Title + } + if dto.Links.WorkPackage != nil { + workPackage = dto.Links.WorkPackage.Title + } + if dto.Links.User != nil { + user = dto.Links.User.Title + } + if dto.Links.Activity != nil { + activity = dto.Links.Activity.Title + } + } + return &models.TimeEntry{ Id: uint64(dto.Id), - Comment: dto.Comment.Raw, - Project: dto.Links.Project.Title, - WorkPackage: dto.Links.WorkPackage.Title, + Comment: comment, + Project: project, + WorkPackage: workPackage, SpentOn: spentOn, Hours: hours.ToTimeDuration(), Ongoing: dto.Ongoing, - User: dto.Links.User.Title, - Activity: dto.Links.Activity.Title, + User: user, + Activity: activity, CreatedAt: dto.CreatedAt, UpdatedAt: dto.UpdatedAt, } diff --git a/dtos/time_entry_activity.go b/dtos/time_entry_activity.go new file mode 100644 index 0000000..70d438d --- /dev/null +++ b/dtos/time_entry_activity.go @@ -0,0 +1,48 @@ +package dtos + +import "github.com/opf/openproject-cli/models" + +type TimeEntryActivityDto struct { + Id uint64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Default bool `json:"default,omitempty"` + Links *timeEntryActivityLinksDto `json:"_links,omitempty"` +} + +type timeEntryActivityElements struct { + Elements []*TimeEntryActivityDto `json:"elements"` +} + +type TimeEntryActivityCollectionDto struct { + Embedded timeEntryActivityElements `json:"_embedded"` + Type string `json:"_type"` + Total uint64 `json:"total"` + Count uint64 `json:"count"` +} + +type timeEntryActivityLinksDto struct { + Self *LinkDto `json:"self,omitempty"` +} + +/////////////// MODEL CONVERSION /////////////// + +func (dto *TimeEntryActivityDto) Convert() *models.TimeEntryActivity { + self := "" + if dto.Links != nil && dto.Links.Self != nil { + self = dto.Links.Self.Href + } + return &models.TimeEntryActivity{ + Id: dto.Id, + Name: dto.Name, + Default: dto.Default, + Href: self, + } +} + +func (dto *TimeEntryActivityCollectionDto) Convert() []*models.TimeEntryActivity { + activities := make([]*models.TimeEntryActivity, len(dto.Embedded.Elements)) + for i, a := range dto.Embedded.Elements { + activities[i] = a.Convert() + } + return activities +} diff --git a/models/time_entry_activity.go b/models/time_entry_activity.go new file mode 100644 index 0000000..133e9ad --- /dev/null +++ b/models/time_entry_activity.go @@ -0,0 +1,8 @@ +package models + +type TimeEntryActivity struct { + Id uint64 + Name string + Default bool + Href string +} From 2764dea4dcc777501338a7205af2c7429fcaf32a Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Sat, 4 Apr 2026 08:10:22 +0200 Subject: [PATCH 17/39] Improve error message when activity lookup fails in time-entry create When GET /api/v3/time_entries/activities returns an error (endpoint not available in all OpenProject versions), show a clear message instead of the raw API error. When the endpoint works but the name doesn't match, list available activities. --- TODO.md | 19 +++++++++++++++++++ components/resources/time_entries/create.go | 16 +++++++++------- 2 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..42c5429 --- /dev/null +++ b/TODO.md @@ -0,0 +1,19 @@ +# TODO + +Issues identified during code review — non-blocking, to address in future iterations. + +## Code quality + +- [ ] **Extract uint64 argument validation helper** — `inspect` and `update` commands all repeat the same pattern (check `len(args) == 1`, parse uint64). Extract to `cmd/common.go` to reduce duplication. + +- [ ] **Replace `fmt.Println/Printf` with `printer.*` in `root.go` and `login.go`** — Both files use `fmt` directly for terminal output, violating the convention that all output goes through `printer/`. `root.go` uses it for the version string, `login.go` for the token prompt and error messages. + +## Known limitations + +- [ ] **`op time-entry create --activity` cannot list available activities** — `GET /api/v3/time_entries/activities` returns 404 in some OpenProject instances (version or permission issue). Investigate using the form endpoint `GET /api/v3/time_entries/form` which may include allowed activities in its schema. + +## CLI UX + +- [ ] **Add `-w` short flag to `activities --work-package`** — `--work-package` has no short option, inconsistent with similar flags elsewhere. Add `-w` or document why it's intentionally omitted. + +- [ ] **`--format` flag has no short option** — inconsistent with the convention of adding short flags for frequently used ones. Consider `-f` if it doesn't conflict. diff --git a/components/resources/time_entries/create.go b/components/resources/time_entries/create.go index f4c07c1..1aba000 100644 --- a/components/resources/time_entries/create.go +++ b/components/resources/time_entries/create.go @@ -73,17 +73,19 @@ func hoursToISO8601(hours float64) string { func activityCreate(entry *dtos.TimeEntryDto, input string) error { activities, err := AllActivities() if err != nil { - return err + printer.ErrorText(fmt.Sprintf("Could not fetch available activities: %s", err)) + printer.Info("Use `op time-entry list` to see existing entries and their activities.") + return fmt.Errorf("activity lookup failed") } found := findActivity(input, activities) if found == nil { - printer.ErrorText(fmt.Sprintf( - "No activity matching %q found. Available activities:", - input, - )) - for _, a := range activities { - printer.Info(fmt.Sprintf(" - %s", a.Name)) + printer.ErrorText(fmt.Sprintf("No activity matching %q found.", input)) + if len(activities) > 0 { + printer.Info("Available activities:") + for _, a := range activities { + printer.Info(fmt.Sprintf(" - %s", a.Name)) + } } return fmt.Errorf("activity %q not found", input) } From 8a22b4b069b3c31e382a3819af4a0e7fcbb9ad7e Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 21 Apr 2026 12:10:57 +0200 Subject: [PATCH 18/39] Add multi-profile support for managing multiple OpenProject instances Previously the CLI stored a single host+token pair in a flat config file, making it awkward to switch between instances (community, self-hosted, staging, etc.). Users had to re-run `op login` each time, overwriting their credentials. This commit introduces named profiles so multiple instances can be configured and used side-by-side. Config format The config file (~/.config/openproject/config, or $XDG_CONFIG_HOME) is now stored as INI with one section per profile: [default] host = https://community.openproject.org token = abc123 [work] host = https://work.example.com token = xyz789 Existing single-line files ("host token") are silently migrated to [default] on the first read. Profile names Only letters, digits, - and _ are allowed, with no leading/trailing hyphens. The interactive prompt sanitizes invalid input and re-prompts with the corrected name as the new default. op login - Prompts "Profile name? [default]" before asking for credentials. - --profile skips the prompt and uses that name directly (validated upfront; errors immediately if the name is invalid). - OP_CLI_PROFILE env var behaves the same as --profile for login. - If the profile already exists the user is asked to confirm overwrite. op logout (new command) - Removes a profile from the config file. - Defaults to "default"; use --profile to target another. - Idempotent: no error if the profile does not exist. - Always asks for confirmation before deleting. op whoami - Without --profile: shows every configured profile (server + user), each block separated by a blank line. - With --profile : shows only that profile. - Output now includes a "Profile:" header line per entry. All other commands - Accept a global --profile flag (persistent, default "default"). - OP_CLI_PROFILE env var sets the default profile; --profile overrides it. - OP_CLI_HOST / OP_CLI_TOKEN still override everything (highest priority). - If an explicitly named profile does not exist, an error is shown and nothing is done. --- CLAUDE.md | 15 +- README.md | 78 +++++ cmd/login.go | 106 ++++++- cmd/logout.go | 52 +++ cmd/root.go | 75 ++++- cmd/whoami.go | 49 ++- components/configuration/profiles.go | 270 ++++++++++++++++ components/configuration/profiles_test.go | 365 ++++++++++++++++++++++ components/configuration/util.go | 47 --- components/printer/json_renderer.go | 11 +- components/printer/renderer.go | 2 +- components/printer/text_renderer.go | 7 +- components/printer/whoami.go | 4 +- components/printer/whoami_test.go | 28 ++ 14 files changed, 1023 insertions(+), 86 deletions(-) create mode 100644 cmd/logout.go create mode 100644 components/configuration/profiles.go create mode 100644 components/configuration/profiles_test.go create mode 100644 components/printer/whoami_test.go diff --git a/CLAUDE.md b/CLAUDE.md index d0d3819..c281bc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ components/ printer/ # Terminal output formatting routes/ # Browser URL generation common/ # Shared utilities (string, slice, math) - configuration/ # Config file reading, CLI version + configuration/ # Multi-profile config (INI), CLI version launch/ # Browser launcher models/ # Domain models (plain structs, no logic) dtos/ # JSON DTOs with Convert() to models @@ -65,6 +65,16 @@ API response → `parser.Parse[SomethingDto]()` → `dto.Convert()` → `models. - Operations are dispatched via a `map[Option]func(...)` — add new options by extending the map - Public API: `Create(...)`, `Lookup(id)`, `All(filters, query, ...)`, `Update(id, options)` +### Configuration conventions + +- Config stored as INI at `~/.config/openproject/config` (or `$XDG_CONFIG_HOME/openproject/config`) +- Each profile is an INI section: `[name]` with `host` and `token` keys +- Profile names: letters, digits, `-`, `_` only; no leading/trailing hyphens; validated by `ValidateProfileName`, sanitized by `SanitizeProfileName` +- Key constants: `DefaultProfile = "default"`, `EnvProfile = "OP_CLI_PROFILE"` +- Key functions: `ReadConfig(profile)`, `WriteConfigForProfile(profile, host, token)`, `DeleteProfile(profile)`, `AllProfiles()` +- `OP_CLI_HOST` / `OP_CLI_TOKEN` env vars override all profiles; `OP_CLI_PROFILE` selects a profile (overridden by `--profile` flag) +- Old single-line format (`host token`) is auto-migrated to `[default]` on first read + ### Paths conventions - All API paths are defined in `components/paths/paths.go` @@ -83,8 +93,9 @@ API response → `parser.Parse[SomethingDto]()` → `dto.Convert()` → `models. - Test files use external test packages: `package printer_test`, `package requests_test` - `TestMain` in `printer_test` initializes shared state (routes, printer) for the package - Tests use plain `t.Errorf` — no test framework, no assertions library -- Tests only exist for `printer`, `requests`, and `common` — no tests on `cmd/` or `resources/` +- Tests only exist for `printer`, `requests`, `common`, and `configuration` — no tests on `cmd/` or `resources/` - When adding a new printer function, add a corresponding test in `components/printer/` +- When adding a new configuration function, add a corresponding test in `components/configuration/` ## Dependencies diff --git a/README.md b/README.md index de40a33..390db19 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,84 @@ op work-package update 42 - --type -t -- Change the work package type ``` +### Authentication + +#### Single instance (default) + +Run `op login` to authenticate. You will be prompted for the host URL, an API +token, and a profile name (defaults to `default`): + +```shell +op login +# Profile name? [default] +# OpenProject host URL: https://community.openproject.org +# OpenProject API Token: ... +``` + +#### Multiple instances + +Use named profiles to work with more than one OpenProject instance: + +```shell +# Create a profile named "work" +op login --profile work + +# Create a profile named "staging" +op login --profile staging +``` + +All commands accept a `--profile` flag to select which instance to use: + +```shell +op work-package list --profile work +op project list --profile staging +``` + +The `OP_CLI_PROFILE` environment variable is an alternative to `--profile`, +useful in scripts or shell sessions where you always want the same instance: + +```shell +export OP_CLI_PROFILE=work +op work-package list # uses the "work" profile +``` + +When both are set, `--profile` takes precedence. The explicit credential +variables `OP_CLI_HOST` and `OP_CLI_TOKEN` override everything. + +#### Inspecting and removing profiles + +`op whoami` shows all configured profiles together with their server URL and +authenticated user: + +```shell +op whoami +# Profile: default +# Server: https://community.openproject.org +# User: #42 Jane Doe +# +# Profile: work +# Server: https://work.example.com +# User: #7 John Smith +``` + +Pass `--profile` to inspect a single profile: + +```shell +op whoami --profile work +``` + +`op logout` removes a profile (defaults to `default`): + +```shell +op logout # removes the "default" profile +op logout --profile work +``` + +#### Profile names + +Profile names may only contain letters, digits, `-` and `_`. When prompted +interactively the CLI suggests a sanitized version of any invalid input. + ### Prominent examples There are a couple of use cases, you might want to execute from the command line. In this section we provide a handful diff --git a/cmd/login.go b/cmd/login.go index 7e74747..2549f76 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -37,7 +37,13 @@ const ( tokenInputError = "There was a problem parsing the token input. Please try again." ) -func login(_ *cobra.Command, _ []string) { +func login(cmd *cobra.Command, _ []string) { + profile, err := resolveLoginProfile(cmd) + if err != nil { + printer.Error(err) + return + } + var hostUrl *url.URL var token string @@ -67,7 +73,7 @@ func login(_ *cobra.Command, _ []string) { fmt.Printf("OpenProject API Token (Visit %s/my/access_tokens to generate one): ", hostUrl) ok, t := requestApiToken() if !ok { - fmt.Println(tokenInputError) + printer.ErrorText(tokenInputError) continue } @@ -88,7 +94,64 @@ func login(_ *cobra.Command, _ []string) { break } - storeLoginData(hostUrl, token) + storeLoginData(profile, hostUrl, token) +} + +// resolveLoginProfile determines the profile name for the login command. +// +// - If --profile was explicitly passed: validate the value immediately, +// display it, and use it without prompting. +// - If OP_CLI_PROFILE is set (but --profile was not): display the value +// and use it without prompting. +// - Otherwise: prompt the user interactively. +func resolveLoginProfile(cmd *cobra.Command) (string, error) { + if cmd.Root().PersistentFlags().Changed("profile") { + if err := configuration.ValidateProfileName(profileName); err != nil { + return "", err + } + printer.Info(fmt.Sprintf("Profile: %s", profileName)) + return profileName, nil + } + + if env := os.Getenv(configuration.EnvProfile); env != "" { + if err := configuration.ValidateProfileName(env); err != nil { + return "", err + } + printer.Info(fmt.Sprintf("Profile: %s", env)) + return env, nil + } + + return promptProfileName(configuration.DefaultProfile) +} + +// promptProfileName shows an interactive prompt, re-prompting on invalid input +// and offering the sanitized form as the next default. +func promptProfileName(defaultName string) (string, error) { + reader := bufio.NewReader(os.Stdin) + + for { + printer.Input(fmt.Sprintf("Profile name? [%s] ", defaultName)) + input, err := reader.ReadString('\n') + if err != nil { + return "", err + } + + input = common.SanitizeLineBreaks(strings.TrimSpace(input)) + if input == "" { + return defaultName, nil + } + + if err := configuration.ValidateProfileName(input); err != nil { + sanitized := configuration.SanitizeProfileName(input) + printer.ErrorText( + "Invalid profile name. Only letters, numbers, - and _ are allowed (no leading/trailing hyphens).", + ) + defaultName = sanitized + continue + } + + return input, nil + } } func parseHostUrl() (ok bool, errMessage string, host *url.URL) { @@ -165,9 +228,42 @@ func requestApiToken() (ok bool, token string) { return true, input } -func storeLoginData(host *url.URL, token string) { - err := configuration.WriteConfigFile(host.String(), token) +func confirmOverwrite(profile string) (bool, error) { + reader := bufio.NewReader(os.Stdin) + printer.Input(fmt.Sprintf("Profile %q already exists, overwrite? [y/N] ", profile)) + input, err := reader.ReadString('\n') if err != nil { + return false, err + } + answer := strings.ToLower(strings.TrimSpace(common.SanitizeLineBreaks(input))) + return answer == "y" || answer == "yes", nil +} + +func storeLoginData(profile string, host *url.URL, token string) { + profiles, err := configuration.AllProfiles() + if err != nil { + printer.Error(err) + return + } + + for _, p := range profiles { + if p.Name == profile { + ok, err := confirmOverwrite(profile) + if err != nil { + printer.Error(err) + return + } + if !ok { + printer.Info("Login cancelled.") + return + } + break + } + } + + if err := configuration.WriteConfigForProfile(profile, host.String(), token); err != nil { printer.Error(err) + return } + printer.Done() } diff --git a/cmd/logout.go b/cmd/logout.go new file mode 100644 index 0000000..1f5cb50 --- /dev/null +++ b/cmd/logout.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/common" + "github.com/opf/openproject-cli/components/configuration" + "github.com/opf/openproject-cli/components/printer" +) + +var logoutCmd = &cobra.Command{ + Use: "logout", + Short: "Removes a stored profile", + Long: "Removes the credentials for the given profile (defaults to 'default').", + Run: logout, +} + +func logout(cmd *cobra.Command, _ []string) { + profile, _ := resolvedProfile(cmd) + + ok, err := confirmRemove(profile) + if err != nil { + printer.Error(err) + return + } + if !ok { + return + } + + if err := configuration.DeleteProfile(profile); err != nil { + printer.Error(err) + return + } + + printer.Done() +} + +func confirmRemove(profile string) (bool, error) { + reader := bufio.NewReader(os.Stdin) + printer.Input(fmt.Sprintf("Remove profile %q? [y/N] ", profile)) + input, err := reader.ReadString('\n') + if err != nil { + return false, err + } + answer := strings.ToLower(strings.TrimSpace(common.SanitizeLineBreaks(input))) + return answer == "y" || answer == "yes", nil +} diff --git a/cmd/root.go b/cmd/root.go index 7c4d8be..a9aea2b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -20,6 +20,7 @@ import ( "github.com/opf/openproject-cli/cmd/workpackage" "github.com/opf/openproject-cli/cmd/wptype" "github.com/opf/openproject-cli/components/configuration" + openerrors "github.com/opf/openproject-cli/components/errors" "github.com/opf/openproject-cli/components/printer" "github.com/opf/openproject-cli/components/requests" "github.com/opf/openproject-cli/components/routes" @@ -28,6 +29,7 @@ import ( var Verbose bool var showVersionFlag bool var outputFormat string +var profileName string var rootCmd = &cobra.Command{ Use: os.Args[0], @@ -35,8 +37,41 @@ var rootCmd = &cobra.Command{ Long: `OpenProject CLI is a fast, reliable and easy-to-use tool to manage your work packages, notifications and projects of your OpenProject instance.`, - PersistentPreRun: func(_ *cobra.Command, _ []string) { + SilenceErrors: true, + SilenceUsage: true, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { printer.InitRenderer(outputFormat) + + // login and logout manage their own profile and requests setup + if cmd.Name() == "login" || cmd.Name() == "logout" { + return nil + } + + profile, explicit := resolvedProfile(cmd) + + if err := configuration.ValidateProfileName(profile); err != nil { + printer.Error(err) + os.Exit(1) + } + + host, token, err := configuration.ReadConfig(profile) + if err != nil { + printer.Error(err) + os.Exit(1) + } + + if host == "" && explicit { + printer.Error(openerrors.Custom(fmt.Sprintf( + "Profile %q not found. Run 'op login --profile %s' to create it.", + profile, profile, + ))) + os.Exit(1) + } + + parse, _ := url.Parse(host) + requests.Init(parse, token, Verbose) + routes.Init(parse) + return nil }, Run: func(cmd *cobra.Command, args []string) { if showVersionFlag { @@ -49,7 +84,7 @@ projects of your OpenProject instance.`, runtime.Version(), ) - fmt.Println(printer.Yellow(versionText)) + printer.Info(printer.Yellow(versionText)) return } @@ -58,6 +93,19 @@ projects of your OpenProject instance.`, }, } +// resolvedProfile returns the effective profile name for the current +// invocation, and whether it was explicitly specified by the user (flag or +// OP_CLI_PROFILE env var) vs falling through to the "default" fallback. +func resolvedProfile(cmd *cobra.Command) (profile string, explicit bool) { + if cmd.Root().PersistentFlags().Changed("profile") { + return profileName, true + } + if env := os.Getenv(configuration.EnvProfile); env != "" { + return env, true + } + return configuration.DefaultProfile, false +} + func Execute(version *configuration.Version) error { configuration.Init(version) @@ -68,20 +116,6 @@ func init() { activePrinter := &printer.ConsolePrinter{} printer.Init(activePrinter) - host, token, err := configuration.ReadConfig() - if err != nil { - printer.Error(err) - return - } - - parse, err := url.Parse(host) - if err != nil { - printer.Error(err) - } - - requests.Init(parse, token, Verbose) - routes.Init(parse) - rootCmd.Flags().BoolVarP( &showVersionFlag, "version", @@ -106,8 +140,17 @@ func init() { `Output format. Accepted values: text, json`, ) + rootCmd.PersistentFlags().StringVarP( + &profileName, + "profile", + "", + configuration.DefaultProfile, + "Profile name to use (overrides OP_CLI_PROFILE env var)", + ) + rootCmd.AddCommand( loginCmd, + logoutCmd, whoamiCmd, // noun-first (new) activities.RootCmd, diff --git a/cmd/whoami.go b/cmd/whoami.go index 56e4bcb..fabb73b 100644 --- a/cmd/whoami.go +++ b/cmd/whoami.go @@ -3,13 +3,16 @@ package cmd import ( stderrors "errors" "net/http" + "net/url" "github.com/spf13/cobra" "github.com/opf/openproject-cli/components/configuration" openerrors "github.com/opf/openproject-cli/components/errors" "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/requests" "github.com/opf/openproject-cli/components/resources/users" + "github.com/opf/openproject-cli/components/routes" ) var whoamiCmd = &cobra.Command{ @@ -19,29 +22,65 @@ var whoamiCmd = &cobra.Command{ Run: whoami, } -func whoami(_ *cobra.Command, _ []string) { - host, _, err := configuration.ReadConfig() +func whoami(cmd *cobra.Command, _ []string) { + profile, explicit := resolvedProfile(cmd) + + if explicit { + whoamiOne(profile) + return + } + + // No profile specified: show all profiles + profiles, err := configuration.AllProfiles() + if err != nil { + printer.Error(err) + return + } + + if len(profiles) == 0 { + printer.ErrorText("No profiles configured. Run `op login` to authenticate.") + return + } + + for i, p := range profiles { + if i > 0 { + printer.Info("") + } + whoamiOne(p.Name) + } +} + +func whoamiOne(profile string) { + host, token, err := configuration.ReadConfig(profile) if err != nil { printer.Error(err) return } if host == "" { - printer.ErrorText("Not logged in. Run `op login` to authenticate.") + printer.ErrorText("Profile \"" + profile + "\" is not configured. Run `op login --profile " + profile + "` to authenticate.") + return + } + + parse, err := url.Parse(host) + if err != nil { + printer.Error(err) return } + requests.Init(parse, token, Verbose) + routes.Init(parse) user, err := users.Me() if err != nil { printer.Info("Server: " + host) var responseErr *openerrors.ResponseError if stderrors.As(err, &responseErr) && responseErr.Status() == http.StatusUnauthorized { - printer.ErrorText("Invalid or expired token. Run `op login` to re-authenticate.") + printer.ErrorText("Invalid or expired token. Run `op login --profile " + profile + "` to re-authenticate.") } else { printer.Error(err) } return } - printer.Whoami(host, user) + printer.Whoami(profile, host, user) } diff --git a/components/configuration/profiles.go b/components/configuration/profiles.go new file mode 100644 index 0000000..bac3163 --- /dev/null +++ b/components/configuration/profiles.go @@ -0,0 +1,270 @@ +package configuration + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/opf/openproject-cli/components/common" + "github.com/opf/openproject-cli/components/errors" +) + +const ( + EnvProfile = "OP_CLI_PROFILE" + DefaultProfile = "default" +) + +// Profile holds credentials for a named OpenProject instance. +type Profile struct { + Name string + Host string + Token string +} + +var invalidChars = regexp.MustCompile(`[^a-zA-Z0-9_]`) +var multiHyphen = regexp.MustCompile(`-{2,}`) + +// SanitizeProfileName replaces invalid characters with hyphens, collapses +// consecutive hyphens, strips leading/trailing hyphens, and falls back to +// "default" when the result is empty. +func SanitizeProfileName(name string) string { + result := invalidChars.ReplaceAllString(name, "-") + result = multiHyphen.ReplaceAllString(result, "-") + result = strings.Trim(result, "-") + if result == "" { + return DefaultProfile + } + return result +} + +// ValidateProfileName returns an error when name is not already in sanitized +// form (only letters, digits, - and _, no leading/trailing hyphens, non-empty). +func ValidateProfileName(name string) error { + if name == "" { + return errors.Custom("profile name cannot be empty") + } + if SanitizeProfileName(name) != name { + return errors.Custom(fmt.Sprintf( + "invalid profile name %q: only letters, numbers, - and _ are allowed (no leading/trailing hyphens)", + name, + )) + } + return nil +} + +// ReadConfig returns host and token for profile. +// OP_CLI_HOST and OP_CLI_TOKEN always take precedence over the file. +func ReadConfig(profile string) (host, token string, err error) { + if err = ensureConfigDir(); err != nil { + return "", "", err + } + if ok, h, t := readEnvironment(); ok { + return h, t, nil + } + return readConfigForProfile(profile) +} + +// WriteConfigForProfile writes or updates profile in the config file. +func WriteConfigForProfile(profile, host, token string) error { + if err := ensureConfigDir(); err != nil { + return err + } + return writeProfile(profile, host, token) +} + +// DeleteProfile removes profile from the config file. +// It is idempotent: returns nil even when the profile does not exist. +func DeleteProfile(profile string) error { + if err := ensureConfigDir(); err != nil { + return err + } + return deleteProfile(profile) +} + +// AllProfiles returns every profile stored in the config file. +func AllProfiles() ([]*Profile, error) { + if err := ensureConfigDir(); err != nil { + return nil, err + } + return readAllProfiles() +} + +// --- internal helpers -------------------------------------------------------- + +type iniSection struct { + name string + kv map[string]string +} + +type iniFile struct { + sections []*iniSection + index map[string]int +} + +func newIniFile() *iniFile { + return &iniFile{index: make(map[string]int)} +} + +func parseIni(data []byte) *iniFile { + f := newIniFile() + var current *iniSection + + for _, raw := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + name := line[1 : len(line)-1] + s := &iniSection{name: name, kv: make(map[string]string)} + f.index[name] = len(f.sections) + f.sections = append(f.sections, s) + current = s + continue + } + if current != nil { + if idx := strings.Index(line, "="); idx > 0 { + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+1:]) + current.kv[key] = val + } + } + } + return f +} + +func (f *iniFile) get(section, key string) (string, bool) { + idx, ok := f.index[section] + if !ok { + return "", false + } + v, ok := f.sections[idx].kv[key] + return v, ok +} + +func (f *iniFile) set(section, key, val string) { + idx, ok := f.index[section] + if !ok { + s := &iniSection{name: section, kv: make(map[string]string)} + f.index[section] = len(f.sections) + f.sections = append(f.sections, s) + idx = len(f.sections) - 1 + } + f.sections[idx].kv[key] = val +} + +func (f *iniFile) delete(section string) { + idx, ok := f.index[section] + if !ok { + return + } + f.sections = append(f.sections[:idx], f.sections[idx+1:]...) + delete(f.index, section) + for i, s := range f.sections { + f.index[s.name] = i + } +} + +func (f *iniFile) marshal() []byte { + var sb strings.Builder + for i, s := range f.sections { + if i > 0 { + sb.WriteString("\n") + } + sb.WriteString(fmt.Sprintf("[%s]\n", s.name)) + for _, key := range []string{"host", "token"} { + if v, ok := s.kv[key]; ok { + sb.WriteString(fmt.Sprintf("%s = %s\n", key, v)) + } + } + } + return []byte(sb.String()) +} + +// readOrMigrate reads the config file and migrates it from the old +// single-line "host token" format when needed. Returns (ini, migrated, error). +func readOrMigrate(data []byte) (*iniFile, bool) { + content := strings.TrimSpace(string(data)) + + // Old format: no section headers + if !strings.Contains(content, "[") && content != "" { + clean := common.SanitizeLineBreaks(content) + parts := strings.SplitN(clean, " ", 2) + if len(parts) == 2 && parts[0] != "" && parts[1] != "" { + f := newIniFile() + f.set(DefaultProfile, "host", parts[0]) + f.set(DefaultProfile, "token", parts[1]) + return f, true + } + } + + return parseIni(data), false +} + +func readOrMigrateFile() (*iniFile, error) { + data, err := os.ReadFile(configFile()) + if os.IsNotExist(err) { + return newIniFile(), nil + } + if err != nil { + return nil, err + } + + f, migrated := readOrMigrate(data) + if migrated { + if err := os.WriteFile(configFile(), f.marshal(), 0644); err != nil { + return nil, err + } + } + return f, nil +} + +func readConfigForProfile(profile string) (host, token string, err error) { + f, err := readOrMigrateFile() + if err != nil { + return "", "", err + } + host, _ = f.get(profile, "host") + token, _ = f.get(profile, "token") + return host, token, nil +} + +func readAllProfiles() ([]*Profile, error) { + f, err := readOrMigrateFile() + if err != nil { + return nil, err + } + profiles := make([]*Profile, 0, len(f.sections)) + for _, s := range f.sections { + profiles = append(profiles, &Profile{ + Name: s.name, + Host: s.kv["host"], + Token: s.kv["token"], + }) + } + return profiles, nil +} + +func writeProfile(profile, host, token string) error { + f, err := readOrMigrateFile() + if err != nil { + return err + } + f.set(profile, "host", host) + f.set(profile, "token", token) + return os.WriteFile(configFile(), f.marshal(), 0644) +} + +func deleteProfile(profile string) error { + data, err := os.ReadFile(configFile()) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + f, _ := readOrMigrate(data) + f.delete(profile) + return os.WriteFile(configFile(), f.marshal(), 0644) +} diff --git a/components/configuration/profiles_test.go b/components/configuration/profiles_test.go new file mode 100644 index 0000000..bfe46cc --- /dev/null +++ b/components/configuration/profiles_test.go @@ -0,0 +1,365 @@ +package configuration_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/opf/openproject-cli/components/configuration" +) + +// setupTempConfig points XDG_CONFIG_HOME at a temp dir and unsets credential +// env vars so every test starts from a clean slate. +func setupTempConfig(t *testing.T) { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + unsetEnv(t, "OP_CLI_HOST") + unsetEnv(t, "OP_CLI_TOKEN") +} + +// unsetEnv removes an env var for the duration of the test and restores the +// original value (or removes it again) in cleanup. +func unsetEnv(t *testing.T, key string) { + t.Helper() + old, existed := os.LookupEnv(key) + os.Unsetenv(key) + t.Cleanup(func() { + if existed { + os.Setenv(key, old) + } else { + os.Unsetenv(key) + } + }) +} + +// writeRaw writes arbitrary bytes directly to the config file, bypassing the +// profile API – used to seed old-format files for migration tests. +func writeRaw(t *testing.T, content string) { + t.Helper() + dir := filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "openproject") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "config"), []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +// ---------- SanitizeProfileName ---------- + +func TestSanitizeProfileName(t *testing.T) { + cases := []struct { + input string + want string + }{ + {"default", "default"}, + {"work", "work"}, + {"my-profile", "my-profile"}, + {"my_profile", "my_profile"}, + {"MyProfile123", "MyProfile123"}, + {"my profile", "my-profile"}, + {"my@work", "my-work"}, + {"my--work", "my-work"}, + {"-mywork-", "mywork"}, + {"--", "default"}, + {"!@#$%", "default"}, + {"", "default"}, + } + for _, c := range cases { + got := configuration.SanitizeProfileName(c.input) + if got != c.want { + t.Errorf("SanitizeProfileName(%q) = %q, want %q", c.input, got, c.want) + } + } +} + +// ---------- ValidateProfileName ---------- + +func TestValidateProfileName_valid(t *testing.T) { + valid := []string{ + "default", "work", "my-profile", "my_profile", + "MyProfile123", "a", "1", "a1", "a-b", "a_b", + } + for _, name := range valid { + if err := configuration.ValidateProfileName(name); err != nil { + t.Errorf("ValidateProfileName(%q) should be valid, got: %v", name, err) + } + } +} + +func TestValidateProfileName_invalid(t *testing.T) { + invalid := []string{ + "", + "my profile", + "my@work", + "!@#", + "-leading", + "trailing-", + "my--work", + "--", + } + for _, name := range invalid { + if err := configuration.ValidateProfileName(name); err == nil { + t.Errorf("ValidateProfileName(%q) should be invalid, got no error", name) + } + } +} + +// ---------- WriteConfigForProfile / ReadConfig ---------- + +func TestWriteAndReadConfigForProfile(t *testing.T) { + setupTempConfig(t) + + if err := configuration.WriteConfigForProfile("default", "https://example.com", "token123"); err != nil { + t.Fatal(err) + } + + host, token, err := configuration.ReadConfig("default") + if err != nil { + t.Fatal(err) + } + if host != "https://example.com" { + t.Errorf("host = %q, want %q", host, "https://example.com") + } + if token != "token123" { + t.Errorf("token = %q, want %q", token, "token123") + } +} + +func TestWriteMultipleProfilesAndReadBack(t *testing.T) { + setupTempConfig(t) + + if err := configuration.WriteConfigForProfile("default", "https://default.example.com", "tok-default"); err != nil { + t.Fatal(err) + } + if err := configuration.WriteConfigForProfile("work", "https://work.example.com", "tok-work"); err != nil { + t.Fatal(err) + } + + host, token, err := configuration.ReadConfig("work") + if err != nil { + t.Fatal(err) + } + if host != "https://work.example.com" { + t.Errorf("host = %q, want %q", host, "https://work.example.com") + } + if token != "tok-work" { + t.Errorf("token = %q, want %q", token, "tok-work") + } + + host, token, err = configuration.ReadConfig("default") + if err != nil { + t.Fatal(err) + } + if host != "https://default.example.com" { + t.Errorf("host = %q, want %q", host, "https://default.example.com") + } + if token != "tok-default" { + t.Errorf("token = %q, want %q", token, "tok-default") + } +} + +func TestReadConfig_missingProfile_returnsEmpty(t *testing.T) { + setupTempConfig(t) + + // Write one profile but read a different one + if err := configuration.WriteConfigForProfile("default", "https://example.com", "tok"); err != nil { + t.Fatal(err) + } + + host, token, err := configuration.ReadConfig("nonexistent") + if err != nil { + t.Fatal(err) + } + if host != "" || token != "" { + t.Errorf("expected empty credentials for missing profile, got host=%q token=%q", host, token) + } +} + +func TestReadConfig_noFile_returnsEmpty(t *testing.T) { + setupTempConfig(t) + + host, token, err := configuration.ReadConfig("default") + if err != nil { + t.Fatal(err) + } + if host != "" || token != "" { + t.Errorf("expected empty credentials when no config file, got host=%q token=%q", host, token) + } +} + +func TestReadConfig_envVarsOverrideProfile(t *testing.T) { + setupTempConfig(t) + + if err := configuration.WriteConfigForProfile("default", "https://file.example.com", "file-token"); err != nil { + t.Fatal(err) + } + + t.Setenv("OP_CLI_HOST", "https://env.example.com") + t.Setenv("OP_CLI_TOKEN", "env-token") + + host, token, err := configuration.ReadConfig("default") + if err != nil { + t.Fatal(err) + } + if host != "https://env.example.com" { + t.Errorf("host = %q, want env var value", host) + } + if token != "env-token" { + t.Errorf("token = %q, want env var value", token) + } +} + +func TestWriteConfigForProfile_overwritesExisting(t *testing.T) { + setupTempConfig(t) + + if err := configuration.WriteConfigForProfile("work", "https://old.example.com", "old-token"); err != nil { + t.Fatal(err) + } + if err := configuration.WriteConfigForProfile("work", "https://new.example.com", "new-token"); err != nil { + t.Fatal(err) + } + + host, token, err := configuration.ReadConfig("work") + if err != nil { + t.Fatal(err) + } + if host != "https://new.example.com" { + t.Errorf("host = %q, want %q", host, "https://new.example.com") + } + if token != "new-token" { + t.Errorf("token = %q, want %q", token, "new-token") + } +} + +// ---------- AllProfiles ---------- + +func TestAllProfiles(t *testing.T) { + setupTempConfig(t) + + if err := configuration.WriteConfigForProfile("default", "https://a.example.com", "tok-a"); err != nil { + t.Fatal(err) + } + if err := configuration.WriteConfigForProfile("work", "https://b.example.com", "tok-b"); err != nil { + t.Fatal(err) + } + + profiles, err := configuration.AllProfiles() + if err != nil { + t.Fatal(err) + } + if len(profiles) != 2 { + t.Fatalf("expected 2 profiles, got %d", len(profiles)) + } + + byName := make(map[string]*configuration.Profile) + for _, p := range profiles { + byName[p.Name] = p + } + + if p, ok := byName["default"]; !ok { + t.Error("missing 'default' profile") + } else { + if p.Host != "https://a.example.com" { + t.Errorf("default host = %q", p.Host) + } + } + if p, ok := byName["work"]; !ok { + t.Error("missing 'work' profile") + } else { + if p.Host != "https://b.example.com" { + t.Errorf("work host = %q", p.Host) + } + } +} + +func TestAllProfiles_noFile_returnsEmpty(t *testing.T) { + setupTempConfig(t) + + profiles, err := configuration.AllProfiles() + if err != nil { + t.Fatal(err) + } + if len(profiles) != 0 { + t.Errorf("expected 0 profiles, got %d", len(profiles)) + } +} + +// ---------- DeleteProfile ---------- + +func TestDeleteProfile_removesProfile(t *testing.T) { + setupTempConfig(t) + + if err := configuration.WriteConfigForProfile("default", "https://a.example.com", "tok-a"); err != nil { + t.Fatal(err) + } + if err := configuration.WriteConfigForProfile("work", "https://b.example.com", "tok-b"); err != nil { + t.Fatal(err) + } + + if err := configuration.DeleteProfile("default"); err != nil { + t.Fatal(err) + } + + profiles, err := configuration.AllProfiles() + if err != nil { + t.Fatal(err) + } + if len(profiles) != 1 { + t.Fatalf("expected 1 profile after delete, got %d", len(profiles)) + } + if profiles[0].Name != "work" { + t.Errorf("remaining profile name = %q, want %q", profiles[0].Name, "work") + } +} + +func TestDeleteProfile_idempotent(t *testing.T) { + setupTempConfig(t) + + // Delete on non-existent profile must not error + if err := configuration.DeleteProfile("nonexistent"); err != nil { + t.Errorf("DeleteProfile on missing profile should not error, got: %v", err) + } + + // Delete on missing file must not error + if err := configuration.DeleteProfile("default"); err != nil { + t.Errorf("DeleteProfile with no config file should not error, got: %v", err) + } +} + +// ---------- Migration ---------- + +func TestMigration_oldFormatMigratedToDefault(t *testing.T) { + setupTempConfig(t) + writeRaw(t, "https://legacy.example.com legacytoken") + + host, token, err := configuration.ReadConfig("default") + if err != nil { + t.Fatal(err) + } + if host != "https://legacy.example.com" { + t.Errorf("host = %q, want %q", host, "https://legacy.example.com") + } + if token != "legacytoken" { + t.Errorf("token = %q, want %q", token, "legacytoken") + } +} + +func TestMigration_oldFormatRewrittenAsIni(t *testing.T) { + setupTempConfig(t) + writeRaw(t, "https://legacy.example.com legacytoken") + + // Trigger migration by reading + if _, _, err := configuration.ReadConfig("default"); err != nil { + t.Fatal(err) + } + + // Now AllProfiles should work correctly + profiles, err := configuration.AllProfiles() + if err != nil { + t.Fatal(err) + } + if len(profiles) != 1 || profiles[0].Name != "default" { + t.Errorf("after migration: expected [default], got %v", profiles) + } +} diff --git a/components/configuration/util.go b/components/configuration/util.go index ea4f695..30c4dbd 100644 --- a/components/configuration/util.go +++ b/components/configuration/util.go @@ -1,13 +1,8 @@ package configuration import ( - "fmt" "os" "path/filepath" - "strings" - - "github.com/opf/openproject-cli/components/common" - "github.com/opf/openproject-cli/components/errors" ) const ( @@ -17,47 +12,10 @@ const ( configFileName = "config" ) -func WriteConfigFile(host, token string) error { - err := ensureConfigDir() - if err != nil { - return err - } - - bytes := []byte(fmt.Sprintf("%s %s", host, token)) - return os.WriteFile(configFile(), bytes, 0644) -} - -func ReadConfig() (host, token string, err error) { - err = ensureConfigDir() - if err != nil { - return "", "", err - } - - ok, h, t := readEnvironment() - if ok { - return h, t, nil - } - - file, err := os.ReadFile(configFile()) - if os.IsNotExist(err) { - // Empty config file is no error, - // user just has to run login command first - return "", "", nil - } - - parts := strings.Split(common.SanitizeLineBreaks(string(file)), " ") - if len(parts) != 2 { - return "", "", errors.Custom(fmt.Sprintf("Invalid config file at %s. Please remove the file and run `op login` again.", configFile())) - } - - return parts[0], parts[1], nil -} - func readEnvironment() (ok bool, host, token string) { host, hasHost := os.LookupEnv(envHost) token, hasToken := os.LookupEnv(envToken) ok = hasHost && hasToken - return } @@ -68,7 +26,6 @@ func ensureConfigDir() error { return err } } - return nil } @@ -81,7 +38,6 @@ func configFileDir() string { if present { return filepath.Join(xdgConfigDir, configDirName) } - return filepath.Join(homeDir(), ".config", configDirName) } @@ -89,8 +45,5 @@ func homeDir() string { if home, ok := os.LookupEnv("HOME"); ok { return home } - - // On Windows `$HOME` is not set per default, but it is - // constructed from `$HOMEDRIVE` and `$HOMEPATH`. return filepath.Join(os.Getenv("HOMEDRIVE"), os.Getenv("HOMEPATH")) } diff --git a/components/printer/json_renderer.go b/components/printer/json_renderer.go index 52dfd6d..b12b726 100644 --- a/components/printer/json_renderer.go +++ b/components/printer/json_renderer.go @@ -231,12 +231,13 @@ func (r *JsonRenderer) CustomActions(actions []*models.CustomAction) { printJson(out) } -func (r *JsonRenderer) Whoami(host string, user *models.User) { +func (r *JsonRenderer) Whoami(profile, host string, user *models.User) { printJson(struct { - Server string `json:"server"` - Id uint64 `json:"id"` - Name string `json:"name"` - }{host, user.Id, user.Name}) + Profile string `json:"profile"` + Server string `json:"server"` + Id uint64 `json:"id"` + Name string `json:"name"` + }{profile, host, user.Id, user.Name}) } func (r *JsonRenderer) Number(n int64) { diff --git a/components/printer/renderer.go b/components/printer/renderer.go index f2825a0..8c242ef 100644 --- a/components/printer/renderer.go +++ b/components/printer/renderer.go @@ -25,7 +25,7 @@ type Renderer interface { Activities([]*models.Activity, []*models.User) CustomActions([]*models.CustomAction) Number(int64) - Whoami(host string, user *models.User) + Whoami(profile, host string, user *models.User) } var activeRenderer Renderer = &TextRenderer{} diff --git a/components/printer/text_renderer.go b/components/printer/text_renderer.go index 0247223..fddccf7 100644 --- a/components/printer/text_renderer.go +++ b/components/printer/text_renderer.go @@ -154,9 +154,10 @@ func (r *TextRenderer) CustomActions(actions []*models.CustomAction) { } } -func (r *TextRenderer) Whoami(host string, user *models.User) { - activePrinter.Printf("Server: %s\n", Cyan(host)) - activePrinter.Printf("User: %s %s\n", Red(fmt.Sprintf("#%d", user.Id)), Cyan(user.Name)) +func (r *TextRenderer) Whoami(profile, host string, user *models.User) { + activePrinter.Printf("Profile: %s\n", Yellow(profile)) + activePrinter.Printf("Server: %s\n", Cyan(host)) + activePrinter.Printf("User: %s %s\n", Red(fmt.Sprintf("#%d", user.Id)), Cyan(user.Name)) } func (r *TextRenderer) Number(n int64) { diff --git a/components/printer/whoami.go b/components/printer/whoami.go index c8e35e1..9ab7ee9 100644 --- a/components/printer/whoami.go +++ b/components/printer/whoami.go @@ -2,6 +2,6 @@ package printer import "github.com/opf/openproject-cli/models" -func Whoami(host string, user *models.User) { - activeRenderer.Whoami(host, user) +func Whoami(profile, host string, user *models.User) { + activeRenderer.Whoami(profile, host, user) } diff --git a/components/printer/whoami_test.go b/components/printer/whoami_test.go new file mode 100644 index 0000000..f065ceb --- /dev/null +++ b/components/printer/whoami_test.go @@ -0,0 +1,28 @@ +package printer_test + +import ( + "fmt" + "testing" + + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/models" +) + +func TestWhoami(t *testing.T) { + testingPrinter.Reset() + + user := &models.User{Id: 42, Name: "Jane Doe"} + printer.Whoami("default", "https://example.com", user) + + expected := fmt.Sprintf( + "Profile: %s\nServer: %s\nUser: %s %s\n", + printer.Yellow("default"), + printer.Cyan("https://example.com"), + printer.Red("#42"), + printer.Cyan("Jane Doe"), + ) + + if testingPrinter.Result != expected { + t.Errorf("Whoami output mismatch\ngot: %q\nwant: %q", testingPrinter.Result, expected) + } +} From f435c2719952684022c0acd2a48ebe4092e97834 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 24 Apr 2026 17:00:24 +0200 Subject: [PATCH 19/39] Show global flags in sub-command help Hiding flags via InheritedFlags().MarkHidden() mutates the shared *Flag structs, accidentally hiding --format and --verbose from all sub-commands. Removing the loop lets them appear correctly under "Global Flags" everywhere. --- cmd/root.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index a9aea2b..5373cc9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -166,10 +166,4 @@ func init() { ) rootCmd.InitDefaultCompletionCmd() - for _, cmd := range rootCmd.Commands() { - if cmd.Name() == "completion" { - _ = cmd.InheritedFlags().MarkHidden("format") - _ = cmd.InheritedFlags().MarkHidden("verbose") - } - } } From ed53fa179a4dae2375f1ba18fddbafba8b98dea2 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 28 Apr 2026 12:17:16 +0200 Subject: [PATCH 20/39] Add --parent-id flag to op work-package list Allows listing the direct children of a work package by passing its ID: op work-package list --parent-id 42 The flag translates to a parent filter on the OpenProject API v3 (operator "="), which is ANDed with any other active filters such as --project-id, --status, or --type. A pre-flight lookup verifies that the given work package exists before issuing the filter query. If it does not, the command exits with a clear error naming the flag: --parent-id: work package #42 not found. Only direct children are returned (depth 1). Listing a full subtree would require multiple API calls and is out of scope. --- README.md | 3 +++ cmd/workpackage/list.go | 12 ++++++++++++ cmd/workpackage/list_flags.go | 7 +++++++ components/resources/work_packages/filters.go | 11 +++++++++++ components/resources/work_packages/read.go | 2 ++ 5 files changed, 35 insertions(+) diff --git a/README.md b/README.md index 390db19..23453bd 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,9 @@ op notification list --reason mentioned # Get a list of all work packages assigned to me op work-package list --assignee me + +# Get a list of direct children of a work package +op work-package list --parent-id 42 ``` #### Updating diff --git a/cmd/workpackage/list.go b/cmd/workpackage/list.go index b9394f5..7b0066b 100644 --- a/cmd/workpackage/list.go +++ b/cmd/workpackage/list.go @@ -19,6 +19,7 @@ import ( ) var listAssignee string +var listParentId uint64 var listProjectId uint64 var listShowTotal bool var listStatusFilter string @@ -46,6 +47,13 @@ func listWorkPackages(_ *cobra.Command, _ []string) { return } + if listParentId > 0 { + if _, err := work_packages.Lookup(listParentId); err != nil { + printer.ErrorText(fmt.Sprintf("--parent-id: work package #%d not found.", listParentId)) + return + } + } + query, err := buildQuery() if err != nil { printer.ErrorText(err.Error()) @@ -104,6 +112,10 @@ func filterOptions() *map[work_packages.FilterOption]string { options[work_packages.IncludeSubProjects] = strconv.FormatBool(listIncludeSubProjects) + if listParentId > 0 { + options[work_packages.Parent] = strconv.FormatUint(listParentId, 10) + } + if listProjectId > 0 { options[work_packages.Project] = strconv.FormatUint(listProjectId, 10) } diff --git a/cmd/workpackage/list_flags.go b/cmd/workpackage/list_flags.go index fb51fff..8373487 100644 --- a/cmd/workpackage/list_flags.go +++ b/cmd/workpackage/list_flags.go @@ -9,6 +9,13 @@ func initListFlags() { "Assignee of the work package (can be ID or 'me')", ) + listCmd.Flags().Uint64VarP( + &listParentId, + "parent-id", + "", + 0, + "Show only direct children of the specified work package ID") + listCmd.Flags().Uint64VarP( &listProjectId, "project-id", diff --git a/components/resources/work_packages/filters.go b/components/resources/work_packages/filters.go index 19a5cc3..dec6189 100644 --- a/components/resources/work_packages/filters.go +++ b/components/resources/work_packages/filters.go @@ -14,6 +14,7 @@ const ( Status Type IncludeSubProjects + Parent ) var InputValidationExpression = map[FilterOption]string{ @@ -33,11 +34,21 @@ func (f FilterOption) String() string { return "type" case IncludeSubProjects: return "include-sub-projects" + case Parent: + return "parent-id" default: return "filter" } } +func ParentFilter(id string) requests.Filter { + return requests.Filter{ + Operator: "=", + Name: "parent", + Values: []string{id}, + } +} + func AssigneeFilter(name string) requests.Filter { return requests.Filter{ Operator: "=", diff --git a/components/resources/work_packages/read.go b/components/resources/work_packages/read.go index 03b9357..b3f5a03 100644 --- a/components/resources/work_packages/read.go +++ b/components/resources/work_packages/read.go @@ -37,6 +37,8 @@ func All(filterOptions *map[FilterOption]string, query requests.Query, showOnlyT case Project: n, _ := strconv.ParseUint(value, 10, 64) projectId = &n + case Parent: + filters = append(filters, ParentFilter(value)) } } From 5d97eaace25ac98e1e2bd6d147ea24fd63157ab5 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Wed, 29 Apr 2026 15:55:53 +0200 Subject: [PATCH 21/39] Accept project identifier (slug) in addition to numeric ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All commands that reference a project — op project inspect, op work-package list, op work-package create, and op budget list — now accept both the numeric project ID (e.g. 42) and the human-readable project identifier found in the URL (e.g. "foobar" from /projects/foobar/work_packages). Changes: - --project-id flag on op work-package list renamed to --project; the old name is kept as a hidden alias for backward compatibility - op project inspect updated to accept [id|identifier] as its argument; the previous hard rejection of non-numeric input is removed - op budget list --project flag updated to accept string values - op work-package create --project flag updated to accept string values - Project identifier field added to models, DTOs, and JSON output so it is visible when listing or inspecting projects - Project display format updated to "#14 MyProject (my-project)" so users can discover the identifier to reuse in flags - routes.ProjectUrl now builds the human-readable URL (projects/my-project) instead of the numeric one (projects/14) - Input validated client-side: only alphanumeric characters, -, _, and + are accepted; invalid input is rejected before any API call with a clear error message pointing to the offending flag - A friendly 404 error is shown when the project is not found instead of the raw API error response --- CLAUDE.md | 1 + README.md | 2 + TODO.md | 4 ++ cmd/budget/budget.go | 6 +-- cmd/budget/list.go | 10 ++++- cmd/project/inspect.go | 14 +++--- cmd/workpackage/create.go | 8 +++- cmd/workpackage/list.go | 41 ++++++++++++------ cmd/workpackage/list_flags.go | 8 ++-- cmd/workpackage/workpackage.go | 6 +-- components/paths/paths.go | 10 ++--- components/printer/json_renderer.go | 14 +++--- components/printer/projects_test.go | 12 +++--- components/printer/text_renderer.go | 2 +- components/resources/budgets/functions.go | 2 +- components/resources/projects/functions.go | 7 ++- components/resources/projects/validate.go | 23 ++++++++++ .../resources/projects/validate_test.go | 43 +++++++++++++++++++ components/resources/projects/versions.go | 2 +- components/resources/work_packages/create.go | 16 +++---- components/resources/work_packages/read.go | 7 +-- components/routes/routes.go | 2 +- dtos/project.go | 12 +++--- models/project.go | 5 ++- 24 files changed, 182 insertions(+), 75 deletions(-) create mode 100644 components/resources/projects/validate.go create mode 100644 components/resources/projects/validate_test.go diff --git a/CLAUDE.md b/CLAUDE.md index c281bc0..78053d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,7 @@ API response → `parser.Parse[SomethingDto]()` → `dto.Convert()` → `models. - All API paths are defined in `components/paths/paths.go` - Functions are named after the resource: `WorkPackage(id)`, `WorkPackages()`, `ProjectWorkPackages(projectId)` - All paths are relative (no host), starting with `/api/v3` +- Project path functions (`Project`, `ProjectWorkPackages`, `ProjectVersions`, `ProjectBudgets`) take a `string` that may be either a numeric ID (`"42"`) or a human-readable identifier (`"my-project"`); the OpenProject API accepts both forms at the same endpoints ### Printer conventions diff --git a/README.md b/README.md index 23453bd..fb2ee38 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,9 @@ of examples, that might be useful for a great number of people. # Creating a work package in a project only by subject. # Work package is created with many default values (as for type and status), # very similar to how a work package is created inline in a work package table. +# --project accepts either a numeric ID or the project identifier from the URL. op work-package create --project 11 'Document new CLI tool' +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 diff --git a/TODO.md b/TODO.md index 42c5429..1b2868f 100644 --- a/TODO.md +++ b/TODO.md @@ -12,6 +12,10 @@ Issues identified during code review — non-blocking, to address in future iter - [ ] **`op time-entry create --activity` cannot list available activities** — `GET /api/v3/time_entries/activities` returns 404 in some OpenProject instances (version or permission issue). Investigate using the form endpoint `GET /api/v3/time_entries/form` which may include allowed activities in its schema. +## Project identifier refactoring + +- [ ] **`validatedVersionId()` in `cmd/workpackage/list.go` swallows errors** — after `printer.Error(err)` the function continues with a nil `project`/`versions` instead of returning early. Should `return ""` immediately after the error print to avoid fragile control flow. + ## CLI UX - [ ] **Add `-w` short flag to `activities --work-package`** — `--work-package` has no short option, inconsistent with similar flags elsewhere. Add `-w` or document why it's intentionally omitted. diff --git a/cmd/budget/budget.go b/cmd/budget/budget.go index 5e25334..d3f4fb2 100644 --- a/cmd/budget/budget.go +++ b/cmd/budget/budget.go @@ -9,12 +9,12 @@ var RootCmd = &cobra.Command{ } func init() { - listCmd.Flags().Uint64VarP( + listCmd.Flags().StringVarP( &listProjectId, "project", "p", - 0, - "Project id", + "", + "Project numeric ID or identifier", ) _ = listCmd.MarkFlagRequired("project") diff --git a/cmd/budget/list.go b/cmd/budget/list.go index f1ab3f5..1c208df 100644 --- a/cmd/budget/list.go +++ b/cmd/budget/list.go @@ -1,13 +1,16 @@ package budget import ( + "fmt" + "github.com/spf13/cobra" "github.com/opf/openproject-cli/components/printer" "github.com/opf/openproject-cli/components/resources/budgets" + "github.com/opf/openproject-cli/components/resources/projects" ) -var listProjectId uint64 +var listProjectId string var listCmd = &cobra.Command{ Use: "list", @@ -17,6 +20,11 @@ var listCmd = &cobra.Command{ } func listBudgets(_ *cobra.Command, _ []string) { + if err := projects.ValidateIdentifier(listProjectId); err != nil { + printer.ErrorText(fmt.Sprintf("--project: %s", err.Error())) + return + } + all, err := budgets.AllForProject(listProjectId) if err != nil { printer.Error(err) diff --git a/cmd/project/inspect.go b/cmd/project/inspect.go index 8c7b5fd..56358d3 100644 --- a/cmd/project/inspect.go +++ b/cmd/project/inspect.go @@ -2,7 +2,6 @@ package project import ( "fmt" - "strconv" "github.com/spf13/cobra" @@ -15,25 +14,24 @@ import ( var openInBrowser bool var inspectCmd = &cobra.Command{ - Use: "inspect [id]", + Use: "inspect [id|identifier]", Short: "Show details about a project", - Long: "Show detailed information of a project referenced by it's ID.", + Long: "Show detailed information of a project referenced by its numeric ID or identifier.", Run: inspectProject, } func inspectProject(_ *cobra.Command, args []string) { if len(args) != 1 { - printer.ErrorText(fmt.Sprintf("Expected 1 argument [id], but got %d", len(args))) + printer.ErrorText(fmt.Sprintf("Expected 1 argument [id|identifier], but got %d", len(args))) return } - id, err := strconv.ParseUint(args[0], 10, 64) - if err != nil { - printer.ErrorText(fmt.Sprintf("'%s' is an invalid project id. Must be a number.", args[0])) + if err := projects.ValidateIdentifier(args[0]); err != nil { + printer.ErrorText(err.Error()) return } - project, err := projects.Lookup(id) + project, err := projects.Lookup(args[0]) if err != nil { printer.Error(err) return diff --git a/cmd/workpackage/create.go b/cmd/workpackage/create.go index 34df402..63e0bf5 100644 --- a/cmd/workpackage/create.go +++ b/cmd/workpackage/create.go @@ -8,11 +8,12 @@ import ( "github.com/opf/openproject-cli/components/launch" "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/resources/projects" "github.com/opf/openproject-cli/components/resources/work_packages" "github.com/opf/openproject-cli/components/routes" ) -var createProjectId uint64 +var createProjectId string var createOpenInBrowser bool var createTypeFlag string var createAssigneeFlag uint64 @@ -32,6 +33,11 @@ func createWorkPackage(cmd *cobra.Command, args []string) { } subject := args[0] + if err := projects.ValidateIdentifier(createProjectId); err != nil { + printer.ErrorText(fmt.Sprintf("--project: %s", err.Error())) + return + } + workPackage, err := work_packages.Create(createProjectId, createOptions(cmd, subject)) if err != nil { printer.Error(err) diff --git a/cmd/workpackage/list.go b/cmd/workpackage/list.go index 7b0066b..b3f05e4 100644 --- a/cmd/workpackage/list.go +++ b/cmd/workpackage/list.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" + opErrors "github.com/opf/openproject-cli/components/errors" "github.com/opf/openproject-cli/components/common" "github.com/opf/openproject-cli/components/printer" "github.com/opf/openproject-cli/components/requests" @@ -20,7 +21,7 @@ import ( var listAssignee string var listParentId uint64 -var listProjectId uint64 +var listProjectId string var listShowTotal bool var listStatusFilter string var listTypeFilter string @@ -47,6 +48,13 @@ func listWorkPackages(_ *cobra.Command, _ []string) { return } + if len(listProjectId) > 0 { + if err := projects.ValidateIdentifier(listProjectId); err != nil { + printer.ErrorText(fmt.Sprintf("--project: %s", err.Error())) + return + } + } + if listParentId > 0 { if _, err := work_packages.Lookup(listParentId); err != nil { printer.ErrorText(fmt.Sprintf("--parent-id: work package #%d not found.", listParentId)) @@ -66,6 +74,8 @@ func listWorkPackages(_ *cobra.Command, _ []string) { printer.Number(collection.Total) case err == nil: printer.WorkPackages(collection.Items) + case isNotFound(err) && len(listProjectId) > 0: + printer.ErrorText(fmt.Sprintf("--project: no project found with identifier or ID '%s'", listProjectId)) default: printer.Error(err) } @@ -73,15 +83,15 @@ func listWorkPackages(_ *cobra.Command, _ []string) { func validateCommandFlagComposition() (errorText string) { switch { - case len(activeFilters["version"].Value()) != 0 && listProjectId == 0: - return "Version flag (--version) can only be used in conjunction with projectId flag (-p or --project-id)." - case len(activeFilters["notVersion"].Value()) != 0 && listProjectId == 0: - return "Not version filter flag (--not-version) can only be used in conjunction with projectId flag (-p or --project-id)." + case len(activeFilters["version"].Value()) != 0 && len(listProjectId) == 0: + return "Version flag (--version) can only be used in conjunction with project flag (-p or --project)." + case len(activeFilters["notVersion"].Value()) != 0 && len(listProjectId) == 0: + return "Not version filter flag (--not-version) can only be used in conjunction with project flag (-p or --project)." case len(activeFilters["subProject"].Value()) > 0 || len(activeFilters["notSubProject"].Value()) > 0: - if !listIncludeSubProjects || listProjectId == 0 { + if !listIncludeSubProjects || len(listProjectId) == 0 { return `Sub project filter flags (--sub-project or --not-sub-project) can only be used in conjunction with setting the flag --include-sub-projects and setting a -project with the projectId flag (-p or --project-id).` +project with the project flag (-p or --project).` } } @@ -116,8 +126,8 @@ func filterOptions() *map[work_packages.FilterOption]string { options[work_packages.Parent] = strconv.FormatUint(listParentId, 10) } - if listProjectId > 0 { - options[work_packages.Project] = strconv.FormatUint(listProjectId, 10) + if len(listProjectId) > 0 { + options[work_packages.Project] = listProjectId } if len(listAssignee) > 0 { @@ -141,7 +151,7 @@ func validatedVersionId(version string) string { printer.Error(err) } - versions, err := projects.AvailableVersions(project.Id) + versions, err := projects.AvailableVersions(project.Identifier) if err != nil { printer.Error(err) } @@ -152,9 +162,9 @@ func validatedVersionId(version string) string { if len(filteredVersions) != 1 { printer.Info(fmt.Sprintf( - "No unique available version from input %s found for projectId %s. Please use one of the versions listed below.", + "No unique available version from input %s found for project %s. Please use one of the versions listed below.", printer.Cyan(version), - printer.Red(fmt.Sprintf("#%d", project.Id)), + printer.Red(project.Identifier), )) printer.Versions(versions) @@ -165,6 +175,13 @@ func validatedVersionId(version string) string { return strconv.FormatUint(filteredVersions[0].Id, 10) } +func isNotFound(err error) bool { + if respErr, ok := err.(*opErrors.ResponseError); ok { + return respErr.Status() == 404 + } + return false +} + func validateFilterValue(filter work_packages.FilterOption, value string) string { matched, err := regexp.Match(work_packages.InputValidationExpression[filter], []byte(value)) if err != nil { diff --git a/cmd/workpackage/list_flags.go b/cmd/workpackage/list_flags.go index 8373487..930af20 100644 --- a/cmd/workpackage/list_flags.go +++ b/cmd/workpackage/list_flags.go @@ -16,12 +16,12 @@ func initListFlags() { 0, "Show only direct children of the specified work package ID") - listCmd.Flags().Uint64VarP( + listCmd.Flags().StringVarP( &listProjectId, - "project-id", + "project", "p", - 0, - "Show only work packages within the specified projectId") + "", + "Show only work packages within the specified project (numeric ID or identifier)") listCmd.Flags().StringVarP( &listStatusFilter, diff --git a/cmd/workpackage/workpackage.go b/cmd/workpackage/workpackage.go index 31a0fcd..4d32896 100644 --- a/cmd/workpackage/workpackage.go +++ b/cmd/workpackage/workpackage.go @@ -11,12 +11,12 @@ var RootCmd = &cobra.Command{ func init() { initListFlags() - createCmd.Flags().Uint64VarP( + createCmd.Flags().StringVarP( &createProjectId, "project", "p", - 0, - "Project ID to create the work package in", + "", + "Project numeric ID or identifier to create the work package in", ) _ = createCmd.MarkFlagRequired("project") createCmd.Flags().BoolVarP( diff --git a/components/paths/paths.go b/components/paths/paths.go index ef8d5b5..258de3b 100644 --- a/components/paths/paths.go +++ b/components/paths/paths.go @@ -10,19 +10,19 @@ func Principals() string { return Root() + "/principals" } -func Project(id uint64) string { - return Projects() + fmt.Sprintf("/%d", id) +func Project(id string) string { + return Projects() + "/" + id } func Projects() string { return Root() + "/projects" } -func ProjectVersions(projectId uint64) string { +func ProjectVersions(projectId string) string { return Project(projectId) + "/versions" } -func ProjectWorkPackages(projectId uint64) string { +func ProjectWorkPackages(projectId string) string { return Project(projectId) + "/work_packages" } @@ -34,7 +34,7 @@ func Budgets() string { return Root() + "/budgets" } -func ProjectBudgets(projectId uint64) string { +func ProjectBudgets(projectId string) string { return Project(projectId) + "/budgets" } diff --git a/components/printer/json_renderer.go b/components/printer/json_renderer.go index b12b726..442ce9c 100644 --- a/components/printer/json_renderer.go +++ b/components/printer/json_renderer.go @@ -57,19 +57,21 @@ func (r *JsonRenderer) WorkPackages(wps []*models.WorkPackage) { func (r *JsonRenderer) Project(p *models.Project) { printJson(struct { - Id uint64 `json:"id"` - Name string `json:"name"` - }{p.Id, p.Name}) + Id uint64 `json:"id"` + Identifier string `json:"identifier"` + Name string `json:"name"` + }{p.Id, p.Identifier, p.Name}) } func (r *JsonRenderer) Projects(ps []*models.Project) { type item struct { - Id uint64 `json:"id"` - Name string `json:"name"` + Id uint64 `json:"id"` + Identifier string `json:"identifier"` + Name string `json:"name"` } out := make([]item, len(ps)) for i, p := range ps { - out[i] = item{p.Id, p.Name} + out[i] = item{p.Id, p.Identifier, p.Name} } printJson(out) } diff --git a/components/printer/projects_test.go b/components/printer/projects_test.go index a5ac51a..8d17875 100644 --- a/components/printer/projects_test.go +++ b/components/printer/projects_test.go @@ -13,10 +13,10 @@ import ( func TestProject(t *testing.T) { testingPrinter.Reset() - project := models.Project{Id: 42, Name: "Example"} + project := models.Project{Id: 42, Identifier: "example", Name: "Example"} idString := "#" + strconv.FormatUint(project.Id, 10) - expected := fmt.Sprintf("%s %s\n", printer.Red(idString), printer.Cyan(project.Name)) + expected := fmt.Sprintf("%s %s (%s)\n", printer.Red(idString), printer.Cyan(project.Name), project.Identifier) printer.Project(&project) @@ -29,9 +29,9 @@ func TestProjects(t *testing.T) { testingPrinter.Reset() projects := []*models.Project{ - {Id: 42, Name: "Foo"}, - {Id: 45, Name: "Bar"}, - {Id: 123, Name: "Baz"}, + {Id: 42, Identifier: "foo", Name: "Foo"}, + {Id: 45, Identifier: "bar", Name: "Bar"}, + {Id: 123, Identifier: "baz", Name: "Baz"}, } expected := common.Reduce[*models.Project, string]( @@ -39,7 +39,7 @@ func TestProjects(t *testing.T) { func(state string, project *models.Project) string { idString := "#" + strconv.FormatUint(project.Id, 10) - return state + fmt.Sprintf("%s %s\n", printer.Red(idString), printer.Cyan(project.Name)) + return state + fmt.Sprintf("%s %s (%s)\n", printer.Red(idString), printer.Cyan(project.Name), project.Identifier) }, "") diff --git a/components/printer/text_renderer.go b/components/printer/text_renderer.go index fddccf7..a160a75 100644 --- a/components/printer/text_renderer.go +++ b/components/printer/text_renderer.go @@ -172,7 +172,7 @@ func printBudget(b *models.Budget, maxIdLength int) { func printProject(p *models.Project) { id := fmt.Sprintf("#%d", p.Id) - activePrinter.Printf("%s %s\n", Red(id), Cyan(p.Name)) + activePrinter.Printf("%s %s (%s)\n", Red(id), Cyan(p.Name), p.Identifier) } func printUser(u *models.User, maxIdLength int) { diff --git a/components/resources/budgets/functions.go b/components/resources/budgets/functions.go index 13ae3dc..b55358d 100644 --- a/components/resources/budgets/functions.go +++ b/components/resources/budgets/functions.go @@ -18,7 +18,7 @@ func Lookup(id uint64) (*models.Budget, error) { return element.Convert(), nil } -func AllForProject(projectId uint64) ([]*models.Budget, error) { +func AllForProject(projectId string) ([]*models.Budget, error) { query := requests.NewPaginatedQuery(-1, nil) response, err := requests.Get(paths.ProjectBudgets(projectId), &query) if err != nil { diff --git a/components/resources/projects/functions.go b/components/resources/projects/functions.go index fbe631b..6208717 100644 --- a/components/resources/projects/functions.go +++ b/components/resources/projects/functions.go @@ -19,8 +19,11 @@ func All() ([]*models.Project, error) { return element.Convert(), nil } -func Lookup(id uint64) (*models.Project, error) { - response, err := requests.Get(paths.Project(id), nil) +func Lookup(identifier string) (*models.Project, error) { + if err := ValidateIdentifier(identifier); err != nil { + return nil, err + } + response, err := requests.Get(paths.Project(identifier), nil) if err != nil { return nil, err } diff --git a/components/resources/projects/validate.go b/components/resources/projects/validate.go new file mode 100644 index 0000000..c54841d --- /dev/null +++ b/components/resources/projects/validate.go @@ -0,0 +1,23 @@ +package projects + +import ( + "fmt" + "regexp" + + "github.com/opf/openproject-cli/components/errors" +) + +var invalidIdentifierChars = regexp.MustCompile(`[^a-zA-Z0-9\-_+]`) + +func ValidateIdentifier(identifier string) error { + if identifier == "" { + return errors.Custom("project identifier cannot be empty") + } + if invalidIdentifierChars.MatchString(identifier) { + return errors.Custom(fmt.Sprintf( + "invalid project %q: only letters, numbers, -, _, and + are allowed", + identifier, + )) + } + return nil +} diff --git a/components/resources/projects/validate_test.go b/components/resources/projects/validate_test.go new file mode 100644 index 0000000..29e5a59 --- /dev/null +++ b/components/resources/projects/validate_test.go @@ -0,0 +1,43 @@ +package projects_test + +import ( + "testing" + + "github.com/opf/openproject-cli/components/resources/projects" +) + +func TestValidateIdentifier(t *testing.T) { + valid := []string{ + "devops", + "my-project", + "project_name", + "project+extra", + "42", + "ABC123", + "a-b_c+d", + } + + for _, id := range valid { + if err := projects.ValidateIdentifier(id); err != nil { + t.Errorf("ValidateIdentifier(%q) returned unexpected error: %v", id, err) + } + } + + invalid := []struct { + input string + desc string + }{ + {"", "empty string"}, + {"my project", "space"}, + {"project/path", "slash"}, + {"proj@name", "at sign"}, + {"proj.name", "dot"}, + {"proj!name", "exclamation mark"}, + } + + for _, tc := range invalid { + if err := projects.ValidateIdentifier(tc.input); err == nil { + t.Errorf("ValidateIdentifier(%q) (%s) expected error but got nil", tc.input, tc.desc) + } + } +} diff --git a/components/resources/projects/versions.go b/components/resources/projects/versions.go index 875d991..ef84a57 100644 --- a/components/resources/projects/versions.go +++ b/components/resources/projects/versions.go @@ -8,7 +8,7 @@ import ( "github.com/opf/openproject-cli/models" ) -func AvailableVersions(projectId uint64) ([]*models.Version, error) { +func AvailableVersions(projectId string) ([]*models.Version, error) { response, err := requests.Get(paths.ProjectVersions(projectId), nil) if err != nil { return nil, err diff --git a/components/resources/work_packages/create.go b/components/resources/work_packages/create.go index 92079c3..e17e25a 100644 --- a/components/resources/work_packages/create.go +++ b/components/resources/work_packages/create.go @@ -23,20 +23,20 @@ const ( CreateDescription ) -var createMap = map[CreateOption]func(projectId uint64, workPackage *dtos.WorkPackageDto, input string) error{ +var createMap = map[CreateOption]func(projectId string, workPackage *dtos.WorkPackageDto, input string) error{ CreateSubject: subjectCreate, CreateType: typeCreate, CreateAssignee: assigneeCreate, CreateDescription: descriptionCreate, } -func subjectCreate(_ uint64, workPackage *dtos.WorkPackageDto, input string) error { +func subjectCreate(_ string, workPackage *dtos.WorkPackageDto, input string) error { workPackage.Subject = input return nil } -func typeCreate(projectId uint64, workPackage *dtos.WorkPackageDto, input string) error { +func typeCreate(projectId string, workPackage *dtos.WorkPackageDto, input string) error { types, err := availableTypes(&dtos.LinkDto{Href: paths.Project(projectId)}) if err != nil { return err @@ -48,7 +48,7 @@ func typeCreate(projectId uint64, workPackage *dtos.WorkPackageDto, input string printer.Info(fmt.Sprintf( "No unique available type from input %s found for project %s. Please use one of the types listed below.", printer.Cyan(input), - printer.Red(fmt.Sprintf("#%d", projectId)), + printer.Red(projectId), )) printer.Types(types.Convert()) @@ -65,7 +65,7 @@ func typeCreate(projectId uint64, workPackage *dtos.WorkPackageDto, input string return nil } -func assigneeCreate(_ uint64, workPackage *dtos.WorkPackageDto, input string) error { +func assigneeCreate(_ string, workPackage *dtos.WorkPackageDto, input string) error { userId, err := strconv.ParseUint(input, 10, 64) if err != nil { return fmt.Errorf("invalid user id %q: must be a number", input) @@ -79,16 +79,16 @@ func assigneeCreate(_ uint64, workPackage *dtos.WorkPackageDto, input string) er return nil } -func descriptionCreate(_ uint64, workPackage *dtos.WorkPackageDto, input string) error { +func descriptionCreate(_ string, workPackage *dtos.WorkPackageDto, input string) error { workPackage.Description = &dtos.LongTextDto{Format: "markdown", Raw: input} return nil } -func Create(projectId uint64, options map[CreateOption]string) (*models.WorkPackage, error) { +func Create(projectId string, options map[CreateOption]string) (*models.WorkPackage, error) { return create(projectId, options) } -func create(projectId uint64, options map[CreateOption]string) (*models.WorkPackage, error) { +func create(projectId string, options map[CreateOption]string) (*models.WorkPackage, error) { workPackage := dtos.WorkPackageDto{} for option, value := range options { diff --git a/components/resources/work_packages/read.go b/components/resources/work_packages/read.go index b3f5a03..e22bb1f 100644 --- a/components/resources/work_packages/read.go +++ b/components/resources/work_packages/read.go @@ -1,8 +1,6 @@ package work_packages import ( - "strconv" - "github.com/opf/openproject-cli/components/parser" "github.com/opf/openproject-cli/components/paths" "github.com/opf/openproject-cli/components/requests" @@ -21,7 +19,7 @@ func Lookup(id uint64) (*models.WorkPackage, error) { func All(filterOptions *map[FilterOption]string, query requests.Query, showOnlyTotal bool) (*models.WorkPackageCollection, error) { var filters []requests.Filter - var projectId *uint64 + var projectId *string var queryAttributes = make(map[string]string) for updateOpt, value := range *filterOptions { @@ -35,8 +33,7 @@ func All(filterOptions *map[FilterOption]string, query requests.Query, showOnlyT case Type: filters = append(filters, TypeFilter(value)) case Project: - n, _ := strconv.ParseUint(value, 10, 64) - projectId = &n + projectId = &value case Parent: filters = append(filters, ParentFilter(value)) } diff --git a/components/routes/routes.go b/components/routes/routes.go index a761ecd..194a86e 100644 --- a/components/routes/routes.go +++ b/components/routes/routes.go @@ -21,6 +21,6 @@ func WorkPackageUrl(workPackage *models.WorkPackage) *url.URL { func ProjectUrl(project *models.Project) *url.URL { routeUrl := *host - routeUrl.Path = fmt.Sprintf("projects/%d", project.Id) + routeUrl.Path = fmt.Sprintf("projects/%s", project.Identifier) return &routeUrl } diff --git a/dtos/project.go b/dtos/project.go index e660c34..e9d27bd 100644 --- a/dtos/project.go +++ b/dtos/project.go @@ -3,9 +3,10 @@ package dtos import "github.com/opf/openproject-cli/models" type ProjectDto struct { - Id int64 `json:"id"` - Name string `json:"name"` - Links *projectLinks `json:"_links"` + Id int64 `json:"id"` + Identifier string `json:"identifier"` + Name string `json:"name"` + Links *projectLinks `json:"_links"` } type projectLinks struct { @@ -36,7 +37,8 @@ func (dto *ProjectCollectionDto) Convert() []*models.Project { func (dto *ProjectDto) Convert() *models.Project { return &models.Project{ - Id: uint64(dto.Id), - Name: dto.Name, + Id: uint64(dto.Id), + Identifier: dto.Identifier, + Name: dto.Name, } } diff --git a/models/project.go b/models/project.go index 964d214..9ae9a9d 100644 --- a/models/project.go +++ b/models/project.go @@ -1,6 +1,7 @@ package models type Project struct { - Id uint64 - Name string + Id uint64 + Identifier string + Name string } From b3c23e70b490dcbe8018d3434082b3f1aae7235f Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 2 Jun 2026 22:20:50 +0200 Subject: [PATCH 22/39] Accept project-based semantic work package identifiers (e.g. PROJ-123) OpenProject community now supports Jira-style identifiers introduced in https://community.openproject.org/work_packages/72427. All commands that accept a work package ID (inspect, update, list --parent-id, activities --work-package, git start workpackage) now accept both numeric IDs for backward compatibility and project-based identifiers (PROJECTID-NUMBER). Validation enforces the identifier rules: uppercase letters, digits, or underscores; max 10 characters; must start with a letter; followed by a hyphen and a numeric sequence number. --- CLAUDE.md | 1 + TODO.md | 2 - cmd/activities/activities.go | 6 +-- cmd/activities/list.go | 8 +++- cmd/git/start/work_package.go | 14 +++--- cmd/timeentry/create.go | 10 +++- cmd/timeentry/create_flags.go | 2 +- cmd/workpackage/inspect.go | 11 ++--- cmd/workpackage/list.go | 14 ++++-- cmd/workpackage/list_flags.go | 6 +-- cmd/workpackage/update.go | 8 ++-- components/paths/paths.go | 6 +-- components/resources/time_entries/create.go | 7 +-- .../resources/work_packages/activities.go | 2 +- components/resources/work_packages/read.go | 6 +-- components/resources/work_packages/update.go | 2 +- .../resources/work_packages/validate.go | 20 ++++++++ .../resources/work_packages/validate_test.go | 47 +++++++++++++++++++ 18 files changed, 123 insertions(+), 49 deletions(-) create mode 100644 components/resources/work_packages/validate.go create mode 100644 components/resources/work_packages/validate_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 78053d7..1f2aab5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,7 @@ API response → `parser.Parse[SomethingDto]()` → `dto.Convert()` → `models. - Functions are named after the resource: `WorkPackage(id)`, `WorkPackages()`, `ProjectWorkPackages(projectId)` - All paths are relative (no host), starting with `/api/v3` - Project path functions (`Project`, `ProjectWorkPackages`, `ProjectVersions`, `ProjectBudgets`) take a `string` that may be either a numeric ID (`"42"`) or a human-readable identifier (`"my-project"`); the OpenProject API accepts both forms at the same endpoints +- `WorkPackage(id)` and `WorkPackageActivities(id)` take a `string` that may be either a numeric ID (`"12345"`) or a project-based semantic identifier (`"PROJ-123"`); validated by `work_packages.ValidateIdentifier` ### Printer conventions diff --git a/TODO.md b/TODO.md index 1b2868f..a177a60 100644 --- a/TODO.md +++ b/TODO.md @@ -4,8 +4,6 @@ Issues identified during code review — non-blocking, to address in future iter ## Code quality -- [ ] **Extract uint64 argument validation helper** — `inspect` and `update` commands all repeat the same pattern (check `len(args) == 1`, parse uint64). Extract to `cmd/common.go` to reduce duplication. - - [ ] **Replace `fmt.Println/Printf` with `printer.*` in `root.go` and `login.go`** — Both files use `fmt` directly for terminal output, violating the convention that all output goes through `printer/`. `root.go` uses it for the version string, `login.go` for the token prompt and error messages. ## Known limitations diff --git a/cmd/activities/activities.go b/cmd/activities/activities.go index 48a7777..6b0429e 100644 --- a/cmd/activities/activities.go +++ b/cmd/activities/activities.go @@ -9,12 +9,12 @@ var RootCmd = &cobra.Command{ } func init() { - listCmd.Flags().Uint64VarP( + listCmd.Flags().StringVarP( &listWpId, "work-package", + "w", "", - 0, - "Work package ID to list activities for", + "Work package ID or identifier to list activities for", ) _ = listCmd.MarkFlagRequired("work-package") diff --git a/cmd/activities/list.go b/cmd/activities/list.go index f033d29..ccc0934 100644 --- a/cmd/activities/list.go +++ b/cmd/activities/list.go @@ -8,7 +8,7 @@ import ( "github.com/opf/openproject-cli/components/resources/work_packages" ) -var listWpId uint64 +var listWpId string var listCmd = &cobra.Command{ Use: "list", @@ -18,10 +18,14 @@ var listCmd = &cobra.Command{ } func listActivities(_ *cobra.Command, _ []string) { + if err := work_packages.ValidateIdentifier(listWpId); err != nil { + printer.ErrorText(err.Error()) + return + } listWorkPackageActivities(listWpId) } -func listWorkPackageActivities(wpId uint64) { +func listWorkPackageActivities(wpId string) { acts, err := work_packages.Activities(wpId) if err != nil { printer.ErrorText(err.Error()) diff --git a/cmd/git/start/work_package.go b/cmd/git/start/work_package.go index 50baa1e..069a818 100644 --- a/cmd/git/start/work_package.go +++ b/cmd/git/start/work_package.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "regexp" - "strconv" "strings" "github.com/go-git/go-git/v5" @@ -18,7 +17,7 @@ import ( var startWorkPackageCmd = &cobra.Command{ Use: "workpackage [id]", Short: "Starts the work on a work package", - Long: "Creates a branch based on the workpackage id, subject and type. Switches to the branch after creation.", + Long: "Creates a branch based on the work package identifier (numeric ID or project-based, e.g. PROJ-123), subject and type. Switches to the branch after creation.", Run: startWorkPackage, } @@ -54,19 +53,18 @@ func startWorkPackage(_ *cobra.Command, args []string) { printer.Info(fmt.Sprintf("Switched to a new branch: %s", branchName)) } -func checkArgumentsForId(args []string) uint64 { +func checkArgumentsForId(args []string) string { if len(args) != 1 { printer.ErrorText(fmt.Sprintf("Expected 1 argument [id], but got %d", len(args))) os.Exit(1) } - id, err := strconv.ParseUint(args[0], 10, 64) - if err != nil { - printer.ErrorText(fmt.Sprintf("'%s' is an invalid work package id. Must be a number.", args[0])) + if err := work_packages.ValidateIdentifier(args[0]); err != nil { + printer.ErrorText(err.Error()) os.Exit(1) } - return id + return args[0] } func handleGitError(err error) { @@ -76,7 +74,7 @@ func handleGitError(err error) { } } -func deriveBranchName(id uint64) (string, error) { +func deriveBranchName(id string) (string, error) { workPackage, err := work_packages.Lookup(id) if err != nil { return "", err diff --git a/cmd/timeentry/create.go b/cmd/timeentry/create.go index 1ee569f..7d44757 100644 --- a/cmd/timeentry/create.go +++ b/cmd/timeentry/create.go @@ -8,9 +8,10 @@ import ( "github.com/opf/openproject-cli/components/printer" "github.com/opf/openproject-cli/components/resources/time_entries" + "github.com/opf/openproject-cli/components/resources/work_packages" ) -var createWorkPackageId uint64 +var createWorkPackageId string var createHours float64 var createActivity string var createSpentOn string @@ -25,8 +26,13 @@ var createCmd = &cobra.Command{ } func createTimeEntry(cmd *cobra.Command, _ []string) { + if err := work_packages.ValidateIdentifier(createWorkPackageId); err != nil { + printer.ErrorText(err.Error()) + return + } + options := map[time_entries.CreateOption]string{ - time_entries.CreateWorkPackage: strconv.FormatUint(createWorkPackageId, 10), + time_entries.CreateWorkPackage: createWorkPackageId, time_entries.CreateHours: strconv.FormatFloat(createHours, 'f', -1, 64), } diff --git a/cmd/timeentry/create_flags.go b/cmd/timeentry/create_flags.go index 2a19b9a..78df5b8 100644 --- a/cmd/timeentry/create_flags.go +++ b/cmd/timeentry/create_flags.go @@ -1,7 +1,7 @@ package timeentry func initCreateFlags() { - createCmd.Flags().Uint64VarP(&createWorkPackageId, "work-package", "w", 0, "Work package ID to log time on") + createCmd.Flags().StringVarP(&createWorkPackageId, "work-package", "w", "", "Work package ID or identifier to log time on") createCmd.Flags().Float64VarP(&createHours, "hours", "", 0, "Number of hours spent (e.g. 1.5 for 1h30m)") createCmd.Flags().StringVarP(&createActivity, "activity", "", "", "Activity name (e.g. Development, Design)") createCmd.Flags().StringVarP(&createSpentOn, "spent-on", "", "", "Date of the time entry (YYYY-MM-DD, default: today)") diff --git a/cmd/workpackage/inspect.go b/cmd/workpackage/inspect.go index e3d437f..1c68fe7 100644 --- a/cmd/workpackage/inspect.go +++ b/cmd/workpackage/inspect.go @@ -2,7 +2,6 @@ package workpackage import ( "fmt" - "strconv" "github.com/spf13/cobra" @@ -18,7 +17,7 @@ var inspectListAvailableTypes bool var inspectCmd = &cobra.Command{ Use: "inspect [id]", Short: "Show details about a work package", - Long: "Show detailed information of a work package referenced by it's ID.", + Long: "Show detailed information of a work package referenced by its numeric ID (e.g. 12345) or project-based identifier (e.g. PROJ-123).", Run: inspectWorkPackage, } @@ -28,9 +27,9 @@ func inspectWorkPackage(_ *cobra.Command, args []string) { return } - id, err := strconv.ParseUint(args[0], 10, 64) - if err != nil { - printer.ErrorText(fmt.Sprintf("'%s' is an invalid work package id. Must be a number.", args[0])) + id := args[0] + if err := work_packages.ValidateIdentifier(id); err != nil { + printer.ErrorText(err.Error()) return } @@ -58,7 +57,7 @@ func inspectWorkPackage(_ *cobra.Command, args []string) { } } -func inspectAvailableTypes(id uint64) { +func inspectAvailableTypes(id string) { types, err := work_packages.AvailableTypes(id) if err != nil { printer.Error(err) diff --git a/cmd/workpackage/list.go b/cmd/workpackage/list.go index b3f05e4..b571cf1 100644 --- a/cmd/workpackage/list.go +++ b/cmd/workpackage/list.go @@ -20,7 +20,7 @@ import ( ) var listAssignee string -var listParentId uint64 +var listParentId string var listProjectId string var listShowTotal bool var listStatusFilter string @@ -55,9 +55,13 @@ func listWorkPackages(_ *cobra.Command, _ []string) { } } - if listParentId > 0 { + if len(listParentId) > 0 { + if err := work_packages.ValidateIdentifier(listParentId); err != nil { + printer.ErrorText(fmt.Sprintf("--parent-id: %s", err.Error())) + return + } if _, err := work_packages.Lookup(listParentId); err != nil { - printer.ErrorText(fmt.Sprintf("--parent-id: work package #%d not found.", listParentId)) + printer.ErrorText(fmt.Sprintf("--parent-id: work package %s not found.", listParentId)) return } } @@ -122,8 +126,8 @@ func filterOptions() *map[work_packages.FilterOption]string { options[work_packages.IncludeSubProjects] = strconv.FormatBool(listIncludeSubProjects) - if listParentId > 0 { - options[work_packages.Parent] = strconv.FormatUint(listParentId, 10) + if len(listParentId) > 0 { + options[work_packages.Parent] = listParentId } if len(listProjectId) > 0 { diff --git a/cmd/workpackage/list_flags.go b/cmd/workpackage/list_flags.go index 930af20..d7510b1 100644 --- a/cmd/workpackage/list_flags.go +++ b/cmd/workpackage/list_flags.go @@ -9,12 +9,12 @@ func initListFlags() { "Assignee of the work package (can be ID or 'me')", ) - listCmd.Flags().Uint64VarP( + listCmd.Flags().StringVarP( &listParentId, "parent-id", "", - 0, - "Show only direct children of the specified work package ID") + "", + "Show only direct children of the specified work package ID or identifier") listCmd.Flags().StringVarP( &listProjectId, diff --git a/cmd/workpackage/update.go b/cmd/workpackage/update.go index a7aa2b5..2ba6442 100644 --- a/cmd/workpackage/update.go +++ b/cmd/workpackage/update.go @@ -20,7 +20,7 @@ var updateTypeFlag string var updateCmd = &cobra.Command{ Use: "update [id]", Short: "Updates the work package", - Long: `Update a work package. Each update + Long: `Update a work package referenced by its numeric ID (e.g. 12345) or project-based identifier (e.g. PROJ-123). Each update provided by a flag is executed on its own.`, Run: updateWorkPackage, } @@ -31,9 +31,9 @@ func updateWorkPackage(cmd *cobra.Command, args []string) { return } - id, err := strconv.ParseUint(args[0], 10, 64) - if err != nil { - printer.ErrorText(fmt.Sprintf("'%s' is an invalid work package id. Must be a number.", args[0])) + id := args[0] + if err := work_packages.ValidateIdentifier(id); err != nil { + printer.ErrorText(err.Error()) return } diff --git a/components/paths/paths.go b/components/paths/paths.go index 258de3b..70be27a 100644 --- a/components/paths/paths.go +++ b/components/paths/paths.go @@ -74,14 +74,14 @@ func Users() string { return Root() + "/users" } -func WorkPackage(id uint64) string { - return WorkPackages() + fmt.Sprintf("/%d", id) +func WorkPackage(id string) string { + return WorkPackages() + "/" + id } func WorkPackages() string { return Root() + "/work_packages" } -func WorkPackageActivities(id uint64) string { +func WorkPackageActivities(id string) string { return WorkPackage(id) + "/activities" } diff --git a/components/resources/time_entries/create.go b/components/resources/time_entries/create.go index 1aba000..e12690d 100644 --- a/components/resources/time_entries/create.go +++ b/components/resources/time_entries/create.go @@ -36,15 +36,12 @@ var createMap = map[CreateOption]func(entry *dtos.TimeEntryDto, input string) er CreateComment: commentCreate, } +// input is validated by the caller via work_packages.ValidateIdentifier before reaching here func workPackageCreate(entry *dtos.TimeEntryDto, input string) error { - var id uint64 - if _, err := fmt.Sscanf(input, "%d", &id); err != nil { - return fmt.Errorf("invalid work package id %q: must be a number", input) - } if entry.Links == nil { entry.Links = &dtos.TimeEntryLinksDto{} } - entry.Links.WorkPackage = &dtos.LinkDto{Href: paths.WorkPackage(id)} + entry.Links.WorkPackage = &dtos.LinkDto{Href: paths.WorkPackage(input)} return nil } diff --git a/components/resources/work_packages/activities.go b/components/resources/work_packages/activities.go index a273982..3a843c3 100644 --- a/components/resources/work_packages/activities.go +++ b/components/resources/work_packages/activities.go @@ -9,7 +9,7 @@ import ( "github.com/opf/openproject-cli/models" ) -func Activities(id uint64) (activites []*models.Activity, err error) { +func Activities(id string) (activites []*models.Activity, err error) { response, err := requests.Get(paths.WorkPackageActivities(id), nil) if err != nil { printer.Error(err) diff --git a/components/resources/work_packages/read.go b/components/resources/work_packages/read.go index e22bb1f..cb90400 100644 --- a/components/resources/work_packages/read.go +++ b/components/resources/work_packages/read.go @@ -8,7 +8,7 @@ import ( "github.com/opf/openproject-cli/models" ) -func Lookup(id uint64) (*models.WorkPackage, error) { +func Lookup(id string) (*models.WorkPackage, error) { workPackage, err := fetch(id) if err != nil { return nil, err @@ -63,7 +63,7 @@ func All(filterOptions *map[FilterOption]string, query requests.Query, showOnlyT return workPackageCollection.Convert(), nil } -func AvailableTypes(id uint64) ([]*models.Type, error) { +func AvailableTypes(id string) ([]*models.Type, error) { workPackageDto, err := fetch(id) if err != nil { return nil, err @@ -77,7 +77,7 @@ func AvailableTypes(id uint64) ([]*models.Type, error) { return types.Convert(), nil } -func fetch(id uint64) (*dtos.WorkPackageDto, error) { +func fetch(id string) (*dtos.WorkPackageDto, error) { response, err := requests.Get(paths.WorkPackage(id), nil) if err != nil { return nil, err diff --git a/components/resources/work_packages/update.go b/components/resources/work_packages/update.go index e6644f1..8400bf0 100644 --- a/components/resources/work_packages/update.go +++ b/components/resources/work_packages/update.go @@ -35,7 +35,7 @@ var patchMap = map[UpdateOption]func(patch, workPackage *dtos.WorkPackageDto, in UpdateSubject: subjectPatch, } -func Update(id uint64, options map[UpdateOption]string) (*models.WorkPackage, error) { +func Update(id string, options map[UpdateOption]string) (*models.WorkPackage, error) { workPackage, err := fetch(id) if err != nil { return nil, err diff --git a/components/resources/work_packages/validate.go b/components/resources/work_packages/validate.go new file mode 100644 index 0000000..e3d68e6 --- /dev/null +++ b/components/resources/work_packages/validate.go @@ -0,0 +1,20 @@ +package work_packages + +import ( + "fmt" + "regexp" +) + +// matches plain numeric IDs like 12345 +var numericIdPattern = regexp.MustCompile(`^\d+$`) + +// matches project-based identifiers like PROJ-123: uppercase letter start, up to 10 chars +// (letters/digits/underscores), hyphen, numeric sequence — per OpenProject identifier rules +var semanticIdPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,9}-\d+$`) + +func ValidateIdentifier(id string) error { + if numericIdPattern.MatchString(id) || semanticIdPattern.MatchString(id) { + return nil + } + return fmt.Errorf("'%s' is an invalid work package identifier: must be a numeric ID (e.g. '12345') or a project-based identifier (e.g. 'PROJ-123')", id) +} diff --git a/components/resources/work_packages/validate_test.go b/components/resources/work_packages/validate_test.go new file mode 100644 index 0000000..7680408 --- /dev/null +++ b/components/resources/work_packages/validate_test.go @@ -0,0 +1,47 @@ +package work_packages_test + +import ( + "testing" + + "github.com/opf/openproject-cli/components/resources/work_packages" +) + +func TestValidateIdentifier(t *testing.T) { + valid := []string{ + "1", + "72427", + "SJF-13", + "A-1", + "MYPROJECT-100", + "MY_PROJ-5", + "ABCDEFGHIJ-1", + } + + for _, id := range valid { + if err := work_packages.ValidateIdentifier(id); err != nil { + t.Errorf("ValidateIdentifier(%q) returned unexpected error: %v", id, err) + } + } + + invalid := []struct { + input string + desc string + }{ + {"", "empty string"}, + {"abc", "lowercase letters only"}, + {"sjf-13", "lowercase project identifier"}, + {"1234-5", "project identifier starts with digit"}, + {"SJF-abc", "non-numeric sequence number"}, + {"SJF-", "missing sequence number"}, + {"-13", "missing project identifier"}, + {"SJF_13", "underscore instead of hyphen separator"}, + {"ABCDEFGHIJK-1", "project identifier exceeds 10 characters"}, + {"SJF 13", "space"}, + } + + for _, tc := range invalid { + if err := work_packages.ValidateIdentifier(tc.input); err == nil { + t.Errorf("ValidateIdentifier(%q) (%s) expected error but got nil", tc.input, tc.desc) + } + } +} From 1624174d1b99b029d711e334ae2dabf29a034635 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 2 Jun 2026 23:00:11 +0200 Subject: [PATCH 23/39] Display semantic work package identifier (e.g. SJF-13) from API displayId field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenProject API returns a displayId field alongside the numeric id. When project-based identifiers are enabled, this is the semantic key (e.g. SJF-13); on instances without the feature it matches the numeric id and the display falls back to the conventional #N format. Wire displayId through DTO → model → printer (text and JSON renderers). Column alignment in list output handles mixed numeric/#N and semantic IDs. --- CLAUDE.md | 3 ++ README.md | 6 +++ components/printer/json_renderer.go | 16 ++++--- components/printer/text_renderer.go | 4 +- components/printer/work_packages.go | 17 ++++++- components/printer/work_packages_test.go | 56 ++++++++++++++++++------ components/routes/routes.go | 2 +- dtos/work_package.go | 2 + models/work_package.go | 1 + 9 files changed, 82 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1f2aab5..2504f52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,7 @@ API response → `parser.Parse[SomethingDto]()` → `dto.Convert()` → `models. - Every DTO implements `Convert() *models.Something` to produce a domain model - Collections follow the pattern: `CollectionDto` with `Embedded.Elements` - `omitempty` on all JSON fields in DTOs used for POST/PATCH bodies +- `WorkPackageDto` includes a `displayId` field (camelCase, from the API) mapped to `DisplayId` on the model; always present — holds the semantic identifier (e.g. `PROJ-123`) when project-based identifiers are enabled, or the numeric id as a string otherwise ### Command conventions @@ -89,6 +90,8 @@ API response → `parser.Parse[SomethingDto]()` → `dto.Convert()` → `models. - Color scheme: Red = ID, Green = type, Cyan = subject/name, Yellow = status - `printer.Error(err)` for errors, `printer.ErrorText(msg)` for plain error strings - `printer.Info(msg)` for progress messages, `printer.Done()` after successful mutations +- Work packages display `DisplayId` from the API: semantic form (e.g. `PROJ-123`) when project-based identifiers are enabled, `#N` for numeric-only systems (where `displayId` equals the numeric id) +- Work package browser URLs use the short `wp/` form (e.g. `wp/PROJ-123` or `wp/42`) ## Testing conventions diff --git a/README.md b/README.md index fb2ee38..962af48 100644 --- a/README.md +++ b/README.md @@ -241,14 +241,18 @@ op notification list --reason mentioned op work-package list --assignee me # Get a list of direct children of a work package +# --parent-id accepts either a numeric ID or a project-based identifier (e.g. PROJ-123) op work-package list --parent-id 42 +op work-package list --parent-id PROJ-123 ``` #### Updating ```shell # Executing a custom action on a work package +# The ID argument accepts either a numeric ID or a project-based identifier (e.g. PROJ-123) op work-package update 42 --action Claim +op work-package update PROJ-123 --action Claim # Batch updating some properties of a work package # Valid input will get processed, while invalid (e.g. wrongly typed) input will get omitted @@ -263,7 +267,9 @@ op work-package update 42 --attach ./Downloads/Report.pdf ```shell # Inspecting a work package with more details, # then in the work package list command +# 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 ``` ## Creating a release diff --git a/components/printer/json_renderer.go b/components/printer/json_renderer.go index 442ce9c..c584ad4 100644 --- a/components/printer/json_renderer.go +++ b/components/printer/json_renderer.go @@ -32,25 +32,27 @@ func (r *JsonRenderer) Budgets(bs []*models.Budget) { func (r *JsonRenderer) WorkPackage(wp *models.WorkPackage) { printJson(struct { Id uint64 `json:"id"` + DisplayId string `json:"display_id,omitempty"` Subject string `json:"subject"` Type string `json:"type"` Status string `json:"status"` Assignee string `json:"assignee"` Description string `json:"description"` - }{wp.Id, wp.Subject, wp.Type, wp.Status, wp.Assignee, wp.Description}) + }{wp.Id, wp.DisplayId, wp.Subject, wp.Type, wp.Status, wp.Assignee, wp.Description}) } func (r *JsonRenderer) WorkPackages(wps []*models.WorkPackage) { type item struct { - Id uint64 `json:"id"` - Subject string `json:"subject"` - Type string `json:"type"` - Status string `json:"status"` - Assignee string `json:"assignee"` + Id uint64 `json:"id"` + DisplayId string `json:"display_id,omitempty"` + Subject string `json:"subject"` + Type string `json:"type"` + Status string `json:"status"` + Assignee string `json:"assignee"` } out := make([]item, len(wps)) for i, wp := range wps { - out[i] = item{wp.Id, wp.Subject, wp.Type, wp.Status, wp.Assignee} + out[i] = item{wp.Id, wp.DisplayId, wp.Subject, wp.Type, wp.Status, wp.Assignee} } printJson(out) } diff --git a/components/printer/text_renderer.go b/components/printer/text_renderer.go index a160a75..6e5ae9a 100644 --- a/components/printer/text_renderer.go +++ b/components/printer/text_renderer.go @@ -28,7 +28,7 @@ func (r *TextRenderer) Budgets(bs []*models.Budget) { } func (r *TextRenderer) WorkPackage(wp *models.WorkPackage) { - printHeadline(wp, idLength(wp.Id), 0, utf8.RuneCountInString(wp.Type)) + printHeadline(wp, displayIdLength(wp), 0, utf8.RuneCountInString(wp.Type)) printAttributes(wp) activePrinter.Println() printOpenLink(wp) @@ -41,7 +41,7 @@ func (r *TextRenderer) WorkPackages(wps []*models.WorkPackage) { var maxTypeLength = 0 var maxStatusLength = 0 for _, w := range wps { - maxIdLength = common.Max(maxIdLength, idLength(w.Id)) + maxIdLength = common.Max(maxIdLength, displayIdLength(w)) maxTypeLength = common.Max(maxTypeLength, utf8.RuneCountInString(w.Type)) maxStatusLength = common.Max(maxStatusLength, utf8.RuneCountInString(w.Status)) } diff --git a/components/printer/work_packages.go b/components/printer/work_packages.go index d5e5c0e..274608e 100644 --- a/components/printer/work_packages.go +++ b/components/printer/work_packages.go @@ -22,11 +22,24 @@ func idLength(id uint64) int { return len(strconv.FormatUint(id, 10)) + 1 } +// formatDisplayId returns the display identifier: "SJF-13" for semantic IDs, +// "#42" for numeric-only systems (where displayId equals the numeric id). +func formatDisplayId(wp *models.WorkPackage) string { + if wp.DisplayId == strconv.FormatUint(wp.Id, 10) { + return fmt.Sprintf("#%d", wp.Id) + } + return wp.DisplayId +} + +func displayIdLength(wp *models.WorkPackage) int { + return utf8.RuneCountInString(formatDisplayId(wp)) +} + func printHeadline(workPackage *models.WorkPackage, maxIdLength, maxStatusLength, maxTypeLength int) { var parts []string - diff := maxIdLength - idLength(workPackage.Id) - idStr := fmt.Sprintf("%s#%d", indent(diff), workPackage.Id) + diff := maxIdLength - displayIdLength(workPackage) + idStr := fmt.Sprintf("%s%s", indent(diff), formatDisplayId(workPackage)) parts = append(parts, Red(idStr)) diff = maxTypeLength - utf8.RuneCountInString(workPackage.Type) diff --git a/components/printer/work_packages_test.go b/components/printer/work_packages_test.go index da09dab..40ace11 100644 --- a/components/printer/work_packages_test.go +++ b/components/printer/work_packages_test.go @@ -14,11 +14,12 @@ func TestWorkPackage(t *testing.T) { testingPrinter.Reset() workPackage := models.WorkPackage{ - Id: 42, - Subject: "Test", - Type: "TASK", - Assignee: "Aaron", - Status: "New", + Id: 42, + DisplayId: "42", + Subject: "Test", + Type: "TASK", + Assignee: "Aaron", + Status: "New", Description: `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam non est sed nunc euismod luctus. Donec vehicula scelerisque efficitur. Nunc arcu ligula, dictum maximus consequat id, tincidunt vitae augue. Vestibulum ut tellus id nisi faucibus efficitur id eu tortor. Vestibulum sed vehicula turpis, sit amet eleifend massa. Proin eu quam justo. Nulla id libero sit amet turpis venenatis mollis. Quisque iaculis lectus non ligula faucibus, ut pellentesque velit sodales. Vivamus nibh est, molestie at laoreet nec, lacinia porttitor nisl. Nulla eget urna in enim porttitor tempus. Nullam velit nunc, ultrices eget molestie vitae, tincidunt vitae felis. Integer augue purus, mollis a vestibulum quis, sagittis ac lacus. Ut vitae tempor tellus. Cras neque turpis, malesuada nec tincidunt vel, mattis vel dolor. Ut hendrerit magna ac suscipit convallis. Ut quis nisi vel metus facilisis sagittis eu eget orci. Pellentesque laoreet metus vitae nulla fringilla, sed lacinia sem laoreet. Maecenas velit erat, luctus ac metus eget, hendrerit tincidunt dolor. Cras mattis orci sem, sed convallis arcu venenatis nec. Donec imperdiet mattis ante, quis euismod lorem viverra ac. Pellentesque in efficitur magna, at ullamcorper ipsum. Vivamus vulputate, tellus et blandit mollis, elit nisl posuere dui, nec molestie metus arcu in lectus. Vivamus eget congue libero, ut congue dolor. Interdum et malesuada fames ac ante ipsum primis in faucibus. Suspendisse blandit, velit quis euismod tincidunt, nunc lectus rutrum nisi, et commodo enim ligula nec sem. Pellentesque nec tincidunt sapien.`, @@ -69,6 +70,7 @@ func TestWorkPackage_Assignee_With_Empty_String(t *testing.T) { workPackage := models.WorkPackage{ Id: 42, + DisplayId: "42", Subject: "Test", Type: "TASK", Assignee: "", @@ -103,16 +105,18 @@ func TestWorkPackages_WithAccentedCharacters(t *testing.T) { // gets one space too few with the broken code (diff=10-6=4 instead of correct 10-5=5). workPackages := []*models.WorkPackage{ { - Id: 2, - Subject: "Subject A", - Type: "Tâche", - Status: "En cours", + Id: 2, + DisplayId: "2", + Subject: "Subject A", + Type: "Tâche", + Status: "En cours", }, { - Id: 10, - Subject: "Subject B", - Type: "User Story", - Status: "En cours de spécification", + Id: 10, + DisplayId: "10", + Subject: "Subject B", + Type: "User Story", + Status: "En cours de spécification", }, } @@ -137,6 +141,7 @@ func TestWorkPackages(t *testing.T) { workPackages := []*models.WorkPackage{ { Id: 42, + DisplayId: "42", Subject: "Test 1", Type: "PHASE", Assignee: "Obi-Wan", @@ -146,6 +151,7 @@ func TestWorkPackages(t *testing.T) { }, { Id: 43, + DisplayId: "43", Subject: "Test 2", Type: "TASK", Assignee: "Anakin", @@ -168,3 +174,27 @@ func TestWorkPackages(t *testing.T) { t.Errorf("\nExpected:\n%sbut got:\n%s", expected, testingPrinter.Result) } } + +func TestWorkPackages_WithSemanticDisplayId(t *testing.T) { + testingPrinter.Reset() + + // Mix of semantic (SJF-13, 6 chars) and numeric (#10, 3 chars) display IDs. + // Numeric WP has DisplayId == strconv(Id) so it falls back to #10. + workPackages := []*models.WorkPackage{ + {Id: 41855, DisplayId: "SJF-13", Subject: "Semantic WP", Type: "EPIC", Status: "Open"}, + {Id: 10, DisplayId: "10", Subject: "Numeric WP", Type: "TASK", Status: "New"}, + } + + var expected string + // maxIdLength = max(6, 3) = 6 → "#10" gets 3 leading spaces + // maxTypeLength = max(4, 4) = 4 + // maxStatusLength = max(4, 3) = 4 + expected += fmt.Sprintf("%s %s [%s] %s\n", printer.Red("SJF-13"), printer.Green("EPIC"), printer.Yellow("Open"), printer.Cyan("Semantic WP")) + expected += fmt.Sprintf("%s %s [%s] %s\n", printer.Red(" #10"), printer.Green("TASK"), printer.Yellow("New"), printer.Cyan("Numeric WP")) + + printer.WorkPackages(workPackages) + + if testingPrinter.Result != expected { + t.Errorf("\nExpected:\n%sbut got:\n%s", expected, testingPrinter.Result) + } +} diff --git a/components/routes/routes.go b/components/routes/routes.go index 194a86e..e013114 100644 --- a/components/routes/routes.go +++ b/components/routes/routes.go @@ -15,7 +15,7 @@ func Init(h *url.URL) { func WorkPackageUrl(workPackage *models.WorkPackage) *url.URL { routeUrl := *host - routeUrl.Path = fmt.Sprintf("work_packages/%d", workPackage.Id) + routeUrl.Path = fmt.Sprintf("wp/%s", workPackage.DisplayId) return &routeUrl } diff --git a/dtos/work_package.go b/dtos/work_package.go index b3345b0..3b3891f 100644 --- a/dtos/work_package.go +++ b/dtos/work_package.go @@ -17,6 +17,7 @@ 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"` @@ -66,6 +67,7 @@ func (dto *WorkPackageDto) Convert() *models.WorkPackage { return &models.WorkPackage{ Id: uint64(dto.Id), + DisplayId: dto.DisplayId, Subject: dto.Subject, Type: wpType, Assignee: assignee, diff --git a/models/work_package.go b/models/work_package.go index 4799c13..629f919 100644 --- a/models/work_package.go +++ b/models/work_package.go @@ -2,6 +2,7 @@ package models type WorkPackage struct { Id uint64 + DisplayId string Subject string Type string Assignee string From 9dd1da69977f4829a1162546b6914c1aaa727d0c Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Thu, 4 Jun 2026 17:05:47 +0200 Subject: [PATCH 24/39] Fix issues raised in Copilot review - Handle url.Parse error in PersistentPreRunE instead of silently ignoring it - Route printJson output through activePrinter instead of fmt directly - Use printer.Input for API token prompt in login instead of fmt.Printf - Add comment explaining DisplayId is always non-empty (API guarantees identifier.presence || id) --- cmd/login.go | 2 +- cmd/root.go | 6 +++++- components/printer/json_renderer.go | 5 ++--- components/routes/routes.go | 2 ++ 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/cmd/login.go b/cmd/login.go index 2549f76..70b2d3b 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -70,7 +70,7 @@ func login(cmd *cobra.Command, _ []string) { } for { - fmt.Printf("OpenProject API Token (Visit %s/my/access_tokens to generate one): ", hostUrl) + printer.Input(fmt.Sprintf("OpenProject API Token (Visit %s/my/access_tokens to generate one): ", hostUrl)) ok, t := requestApiToken() if !ok { printer.ErrorText(tokenInputError) diff --git a/cmd/root.go b/cmd/root.go index 5373cc9..f5707e7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -68,7 +68,11 @@ projects of your OpenProject instance.`, os.Exit(1) } - parse, _ := url.Parse(host) + parse, err := url.Parse(host) + if err != nil { + printer.ErrorText(fmt.Sprintf("invalid host URL %q: %s", host, err)) + os.Exit(1) + } requests.Init(parse, token, Verbose) routes.Init(parse) return nil diff --git a/components/printer/json_renderer.go b/components/printer/json_renderer.go index c584ad4..7fdfde6 100644 --- a/components/printer/json_renderer.go +++ b/components/printer/json_renderer.go @@ -2,7 +2,6 @@ package printer import ( "encoding/json" - "fmt" "sort" "github.com/opf/openproject-cli/models" @@ -253,8 +252,8 @@ func (r *JsonRenderer) Number(n int64) { func printJson(v any) { b, err := json.MarshalIndent(v, "", " ") if err != nil { - fmt.Printf("{\"error\": \"failed to serialize output: %s\"}\n", err) + activePrinter.Printf("{\"error\": \"failed to serialize output: %s\"}\n", err) return } - fmt.Println(string(b)) + activePrinter.Println(string(b)) } diff --git a/components/routes/routes.go b/components/routes/routes.go index e013114..7c8ad63 100644 --- a/components/routes/routes.go +++ b/components/routes/routes.go @@ -15,6 +15,8 @@ func Init(h *url.URL) { func WorkPackageUrl(workPackage *models.WorkPackage) *url.URL { routeUrl := *host + // DisplayId is always non-empty: the server code sets it to `identifier.presence || id` + // (in WorkPackage::SemanticIdentifier), so it falls back to the numeric id automatically. routeUrl.Path = fmt.Sprintf("wp/%s", workPackage.DisplayId) return &routeUrl } From 7abdcfab6d3a9ee972eed8b7ee92375044cf645d Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 5 Jun 2026 08:11:37 +0200 Subject: [PATCH 25/39] Fix four bugs raised in code review - Guard against nil _embedded in BudgetCollectionDto.Convert - Resolve --parent-id semantic identifier to numeric ID before filtering - Use strconv.ParseFloat/ParseUint instead of fmt.Sscanf to reject trailing garbage (e.g. "1.5abc") - Fix IPv6 URL mis-detection in legacy config migration (HasPrefix instead of Contains) - Add regression test for IPv6 host migration --- cmd/workpackage/list.go | 4 +++- components/configuration/profiles.go | 5 +++-- components/configuration/profiles_test.go | 16 ++++++++++++++++ components/resources/time_entries/create.go | 9 +++++---- dtos/budget.go | 3 +++ 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/cmd/workpackage/list.go b/cmd/workpackage/list.go index b571cf1..224c417 100644 --- a/cmd/workpackage/list.go +++ b/cmd/workpackage/list.go @@ -60,10 +60,12 @@ func listWorkPackages(_ *cobra.Command, _ []string) { printer.ErrorText(fmt.Sprintf("--parent-id: %s", err.Error())) return } - if _, err := work_packages.Lookup(listParentId); err != nil { + parentWp, err := work_packages.Lookup(listParentId) + if err != nil { printer.ErrorText(fmt.Sprintf("--parent-id: work package %s not found.", listParentId)) return } + listParentId = fmt.Sprintf("%d", parentWp.Id) } query, err := buildQuery() diff --git a/components/configuration/profiles.go b/components/configuration/profiles.go index bac3163..fda9aa6 100644 --- a/components/configuration/profiles.go +++ b/components/configuration/profiles.go @@ -187,8 +187,9 @@ func (f *iniFile) marshal() []byte { func readOrMigrate(data []byte) (*iniFile, bool) { content := strings.TrimSpace(string(data)) - // Old format: no section headers - if !strings.Contains(content, "[") && content != "" { + // Old format: no section headers. Check prefix rather than Contains so that + // IPv6 host URLs (e.g. http://[::1]) are not mis-detected as INI files. + if !strings.HasPrefix(content, "[") && content != "" { clean := common.SanitizeLineBreaks(content) parts := strings.SplitN(clean, " ", 2) if len(parts) == 2 && parts[0] != "" && parts[1] != "" { diff --git a/components/configuration/profiles_test.go b/components/configuration/profiles_test.go index bfe46cc..f8daaab 100644 --- a/components/configuration/profiles_test.go +++ b/components/configuration/profiles_test.go @@ -345,6 +345,22 @@ func TestMigration_oldFormatMigratedToDefault(t *testing.T) { } } +func TestMigration_IPv6HostMigratedCorrectly(t *testing.T) { + setupTempConfig(t) + writeRaw(t, "http://[::1]:8080 mytoken") + + host, token, err := configuration.ReadConfig("default") + if err != nil { + t.Fatal(err) + } + if host != "http://[::1]:8080" { + t.Errorf("host = %q, want %q", host, "http://[::1]:8080") + } + if token != "mytoken" { + t.Errorf("token = %q, want %q", token, "mytoken") + } +} + func TestMigration_oldFormatRewrittenAsIni(t *testing.T) { setupTempConfig(t) writeRaw(t, "https://legacy.example.com legacytoken") diff --git a/components/resources/time_entries/create.go b/components/resources/time_entries/create.go index e12690d..898f481 100644 --- a/components/resources/time_entries/create.go +++ b/components/resources/time_entries/create.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "math" + "strconv" "strings" "time" @@ -46,8 +47,8 @@ func workPackageCreate(entry *dtos.TimeEntryDto, input string) error { } func hoursCreate(entry *dtos.TimeEntryDto, input string) error { - var hours float64 - if _, err := fmt.Sscanf(input, "%f", &hours); err != nil { + hours, err := strconv.ParseFloat(input, 64) + if err != nil { return fmt.Errorf("invalid hours %q: must be a number", input) } if hours <= 0 { @@ -119,8 +120,8 @@ func spentOnCreate(entry *dtos.TimeEntryDto, input string) error { } func userCreate(entry *dtos.TimeEntryDto, input string) error { - var id uint64 - if _, err := fmt.Sscanf(input, "%d", &id); err != nil { + id, err := strconv.ParseUint(input, 10, 64) + if err != nil { return fmt.Errorf("invalid user id %q: must be a number", input) } if entry.Links == nil { diff --git a/dtos/budget.go b/dtos/budget.go index 1107cbc..b445c32 100644 --- a/dtos/budget.go +++ b/dtos/budget.go @@ -18,6 +18,9 @@ type BudgetCollectionDto struct { /////////////// MODEL CONVERSION /////////////// func (dto *BudgetCollectionDto) Convert() []*models.Budget { + if dto.Embedded == nil { + return []*models.Budget{} + } budgets := make([]*models.Budget, len(dto.Embedded.Elements)) for i, b := range dto.Embedded.Elements { budgets[i] = b.Convert() From 422a3c763f1e1492873d1cc580396d4e2197464a Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 5 Jun 2026 11:48:08 +0200 Subject: [PATCH 26/39] Add op work-package search command Searches across subject, type, status, project name, and identifier using the OpenProject API typeahead filter. Multiple words are ANDed. Returns up to 100 results. --- README.md | 10 ++++++ cmd/workpackage/search.go | 38 ++++++++++++++++++++ cmd/workpackage/workpackage.go | 2 +- components/resources/work_packages/search.go | 23 ++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 cmd/workpackage/search.go create mode 100644 components/resources/work_packages/search.go diff --git a/README.md b/README.md index 962af48..5d51adb 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ op work-package create -- Create work package in project inspect -- Inspect a work package list -- Lists work packages +search -- Searches for work packages update -- Update a work package # Discover flags: hitting completion key after @@ -262,6 +263,15 @@ op work-package update 42 --subject 'The new subject' --status 'In Progress' --t op work-package update 42 --attach ./Downloads/Report.pdf ``` +#### Searching + +```shell +# Search work packages by subject, type, status, project name, or identifier. +# Multiple words are ANDed: all terms must match. Returns up to 100 results. +op work-package search cascade +op work-package search fix login +``` + #### Inspecting ```shell diff --git a/cmd/workpackage/search.go b/cmd/workpackage/search.go new file mode 100644 index 0000000..72ad267 --- /dev/null +++ b/cmd/workpackage/search.go @@ -0,0 +1,38 @@ +package workpackage + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/resources/work_packages" +) + +var searchCmd = &cobra.Command{ + Use: "search ...", + Short: "Searches for work packages", + Long: `Searches for work packages by subject, type, status, project name, or identifier. +Multiple words are ANDed: all terms must match. Returns up to 100 results.`, + Run: searchWorkPackages, +} + +func searchWorkPackages(_ *cobra.Command, args []string) { + if len(args) == 0 { + printer.ErrorText("Expected at least 1 argument [searchInput], but got 0") + return + } + + collection, err := work_packages.Search(strings.Join(args, " ")) + if err != nil { + printer.Error(err) + return + } + + if len(collection) == 0 { + printer.Info(fmt.Sprintf("No work package found for search input %s.", printer.Cyan(args[0]))) + } else { + printer.WorkPackages(collection) + } +} diff --git a/cmd/workpackage/workpackage.go b/cmd/workpackage/workpackage.go index 4d32896..2d6ee67 100644 --- a/cmd/workpackage/workpackage.go +++ b/cmd/workpackage/workpackage.go @@ -99,5 +99,5 @@ func init() { "List the available types on the work package.", ) - RootCmd.AddCommand(listCmd, createCmd, updateCmd, inspectCmd) + RootCmd.AddCommand(listCmd, createCmd, updateCmd, inspectCmd, searchCmd) } diff --git a/components/resources/work_packages/search.go b/components/resources/work_packages/search.go new file mode 100644 index 0000000..c915c25 --- /dev/null +++ b/components/resources/work_packages/search.go @@ -0,0 +1,23 @@ +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/components/resources" + "github.com/opf/openproject-cli/dtos" + "github.com/opf/openproject-cli/models" +) + +func Search(input string) ([]*models.WorkPackage, error) { + filters := []requests.Filter{resources.TypeAheadFilter(input)} + query := requests.NewFilterQuery(filters) + + response, err := requests.Get(paths.WorkPackages(), &query) + if err != nil { + return nil, err + } + + collection := parser.Parse[dtos.WorkPackageCollectionDto](response) + return collection.Convert().Items, nil +} From c78815f09ce76669c957ce63dea81688309aa51d Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 5 Jun 2026 12:25:55 +0200 Subject: [PATCH 27/39] Add --project flag to op work-package search Scopes the search to a specific project when provided, using the project-scoped API endpoint. Accepts a numeric ID or identifier. --- README.md | 4 +++ cmd/workpackage/helpers.go | 10 ++++++++ cmd/workpackage/list.go | 7 ------ cmd/workpackage/search.go | 26 ++++++++++++++++---- cmd/workpackage/workpackage.go | 8 ++++++ components/resources/work_packages/search.go | 9 +++++-- 6 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 cmd/workpackage/helpers.go diff --git a/README.md b/README.md index 5d51adb..cb5a22d 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,10 @@ op work-package update 42 --attach ./Downloads/Report.pdf # Multiple words are ANDed: all terms must match. Returns up to 100 results. op work-package search cascade op work-package search fix login + +# Limit search to a specific project (numeric ID or identifier) +op work-package search cascade --project my-project +op work-package search cascade -p 11 ``` #### Inspecting diff --git a/cmd/workpackage/helpers.go b/cmd/workpackage/helpers.go new file mode 100644 index 0000000..6869e80 --- /dev/null +++ b/cmd/workpackage/helpers.go @@ -0,0 +1,10 @@ +package workpackage + +import opErrors "github.com/opf/openproject-cli/components/errors" + +func isNotFound(err error) bool { + if respErr, ok := err.(*opErrors.ResponseError); ok { + return respErr.Status() == 404 + } + return false +} diff --git a/cmd/workpackage/list.go b/cmd/workpackage/list.go index 224c417..919cbae 100644 --- a/cmd/workpackage/list.go +++ b/cmd/workpackage/list.go @@ -8,7 +8,6 @@ import ( "github.com/spf13/cobra" - opErrors "github.com/opf/openproject-cli/components/errors" "github.com/opf/openproject-cli/components/common" "github.com/opf/openproject-cli/components/printer" "github.com/opf/openproject-cli/components/requests" @@ -181,12 +180,6 @@ func validatedVersionId(version string) string { return strconv.FormatUint(filteredVersions[0].Id, 10) } -func isNotFound(err error) bool { - if respErr, ok := err.(*opErrors.ResponseError); ok { - return respErr.Status() == 404 - } - return false -} func validateFilterValue(filter work_packages.FilterOption, value string) string { matched, err := regexp.Match(work_packages.InputValidationExpression[filter], []byte(value)) diff --git a/cmd/workpackage/search.go b/cmd/workpackage/search.go index 72ad267..5a93165 100644 --- a/cmd/workpackage/search.go +++ b/cmd/workpackage/search.go @@ -7,9 +7,12 @@ import ( "github.com/spf13/cobra" "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/resources/projects" "github.com/opf/openproject-cli/components/resources/work_packages" ) +var searchProjectId string + var searchCmd = &cobra.Command{ Use: "search ...", Short: "Searches for work packages", @@ -19,19 +22,32 @@ Multiple words are ANDed: all terms must match. Returns up to 100 results.`, } func searchWorkPackages(_ *cobra.Command, args []string) { - if len(args) == 0 { - printer.ErrorText("Expected at least 1 argument [searchInput], but got 0") + query := strings.Join(args, " ") + if strings.TrimSpace(query) == "" { + printer.ErrorText("Search query cannot be blank") return } - collection, err := work_packages.Search(strings.Join(args, " ")) + isProjectScoped := len(searchProjectId) > 0 + if isProjectScoped { + if err := projects.ValidateIdentifier(searchProjectId); err != nil { + printer.ErrorText(fmt.Sprintf("--project: %s", err.Error())) + return + } + } + + collection, err := work_packages.Search(query, searchProjectId) if err != nil { - printer.Error(err) + if isNotFound(err) && isProjectScoped { + printer.ErrorText(fmt.Sprintf("--project: no project found with identifier or ID '%s'", searchProjectId)) + } else { + printer.Error(err) + } return } if len(collection) == 0 { - printer.Info(fmt.Sprintf("No work package found for search input %s.", printer.Cyan(args[0]))) + printer.Info(fmt.Sprintf("No work package found for search input %s.", printer.Cyan(query))) } else { printer.WorkPackages(collection) } diff --git a/cmd/workpackage/workpackage.go b/cmd/workpackage/workpackage.go index 2d6ee67..2f39122 100644 --- a/cmd/workpackage/workpackage.go +++ b/cmd/workpackage/workpackage.go @@ -99,5 +99,13 @@ func init() { "List the available types on the work package.", ) + searchCmd.Flags().StringVarP( + &searchProjectId, + "project", + "p", + "", + "Limit search to a project (numeric ID or identifier)", + ) + RootCmd.AddCommand(listCmd, createCmd, updateCmd, inspectCmd, searchCmd) } diff --git a/components/resources/work_packages/search.go b/components/resources/work_packages/search.go index c915c25..21a57e3 100644 --- a/components/resources/work_packages/search.go +++ b/components/resources/work_packages/search.go @@ -9,11 +9,16 @@ import ( "github.com/opf/openproject-cli/models" ) -func Search(input string) ([]*models.WorkPackage, error) { +func Search(input string, projectId string) ([]*models.WorkPackage, error) { filters := []requests.Filter{resources.TypeAheadFilter(input)} query := requests.NewFilterQuery(filters) - response, err := requests.Get(paths.WorkPackages(), &query) + requestUrl := paths.WorkPackages() + if projectId != "" { + requestUrl = paths.ProjectWorkPackages(projectId) + } + + response, err := requests.Get(requestUrl, &query) if err != nil { return nil, err } From 747da316ebe4ced6b43d63be6f8c134375b45e68 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Mon, 8 Jun 2026 11:38:13 +0200 Subject: [PATCH 28/39] Fix: + is not allowed in project identifers The plus sign is not allowed, neither in old project identifiers nor in new semantic project identifiers. --- components/resources/projects/validate.go | 5 +++-- components/resources/projects/validate_test.go | 3 +-- components/resources/work_packages/validate_test.go | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/components/resources/projects/validate.go b/components/resources/projects/validate.go index c54841d..85fc343 100644 --- a/components/resources/projects/validate.go +++ b/components/resources/projects/validate.go @@ -7,7 +7,8 @@ import ( "github.com/opf/openproject-cli/components/errors" ) -var invalidIdentifierChars = regexp.MustCompile(`[^a-zA-Z0-9\-_+]`) +// Dashes are allowed for old project identifiers; semantic identifiers only allow letters, numbers and underscores. +var invalidIdentifierChars = regexp.MustCompile(`[^a-zA-Z0-9\-_]`) func ValidateIdentifier(identifier string) error { if identifier == "" { @@ -15,7 +16,7 @@ func ValidateIdentifier(identifier string) error { } if invalidIdentifierChars.MatchString(identifier) { return errors.Custom(fmt.Sprintf( - "invalid project %q: only letters, numbers, -, _, and + are allowed", + "invalid project %q: only letters, numbers, - and _ are allowed", identifier, )) } diff --git a/components/resources/projects/validate_test.go b/components/resources/projects/validate_test.go index 29e5a59..6e97703 100644 --- a/components/resources/projects/validate_test.go +++ b/components/resources/projects/validate_test.go @@ -11,10 +11,8 @@ func TestValidateIdentifier(t *testing.T) { "devops", "my-project", "project_name", - "project+extra", "42", "ABC123", - "a-b_c+d", } for _, id := range valid { @@ -33,6 +31,7 @@ func TestValidateIdentifier(t *testing.T) { {"proj@name", "at sign"}, {"proj.name", "dot"}, {"proj!name", "exclamation mark"}, + {"project+extra", "plus sign"}, } for _, tc := range invalid { diff --git a/components/resources/work_packages/validate_test.go b/components/resources/work_packages/validate_test.go index 7680408..93b4fb0 100644 --- a/components/resources/work_packages/validate_test.go +++ b/components/resources/work_packages/validate_test.go @@ -37,6 +37,7 @@ func TestValidateIdentifier(t *testing.T) { {"SJF_13", "underscore instead of hyphen separator"}, {"ABCDEFGHIJK-1", "project identifier exceeds 10 characters"}, {"SJF 13", "space"}, + {"SJ+F-13", "plus sign in project identifier"}, } for _, tc := range invalid { From a069d467f73bf1fe8c8d6bce7818b0c2451fca4f Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Mon, 8 Jun 2026 17:25:54 +0200 Subject: [PATCH 29/39] Add .claude/op.md reference file for AI agent integration Ships a command reference that teaches Claude Code how to use `op`. Documents setup instructions in README for global and project-local use. --- .claude/op.md | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 32 ++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 .claude/op.md diff --git a/.claude/op.md b/.claude/op.md new file mode 100644 index 0000000..867a0fc --- /dev/null +++ b/.claude/op.md @@ -0,0 +1,141 @@ +# OpenProject CLI (`op`) + +`op` is a CLI for interacting with OpenProject project management instances. Use it proactively whenever the user discusses work packages, projects, time entries, notifications, or anything related to OpenProject — just as naturally as you'd use `git` for version control or `ls` to explore the filesystem. + +## Global flags + +These flags apply to every command: + +``` +--format string Output format: text (default) or json +--profile string Profile name (overrides OP_CLI_PROFILE env var, default "default") +--verbose Print verbose output +``` + +## Orientation + +```bash +op whoami # Show current user and server (verify connection) +op whoami --profile # Same for a specific profile +op project list # Discover available projects (get IDs for other commands) +``` + +## Work packages + +IDs accept either a numeric ID (`42`) or a project-based identifier (`PROJ-123`) everywhere. + +```bash +op work-package list # All visible work packages +op work-package list -p # Filter by project (numeric ID or identifier/slug) +op work-package list -s open # Filter: open / closed / / comma-separated IDs +op work-package list -s '!' # Exclude a status (prefix with !) +op work-package list -a me # Filter by assignee (me or user ID) +op work-package list -t # Filter by type (comma-separated IDs, ! prefix to exclude) +op work-package list -v # Filter by version +op work-package list --not-version # Exclude a version +op work-package list --parent-id # Direct children of a work package +op work-package list --include-sub-projects # Include sub-project work packages +op work-package list --sub-project # Limit sub-projects to include (with --include-sub-projects) +op work-package list --not-sub-project # Exclude sub-projects (with --include-sub-projects) +op work-package list --timestamp 2025-01-01 # Work packages as of a date (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ) +op work-package list --total # Show only the total count + +op work-package search ... # Search by subject, type, status, project name, or identifier +op work-package search ... -p # Limit search to a project; multiple words are ANDed; up to 100 results + +op work-package inspect # Full details of one work package +op work-package inspect --types # Also list available types on the work package +op work-package inspect --open # Open in default browser + +op work-package create "Subject" -p # Create (project accepts numeric ID or slug) +op work-package create "Subject" -p \ + --type --assignee \ + --description "markdown text" --open # Full create with all options + +op work-package update --subject "…" # Update subject +op work-package update --type # Change type +op work-package update --assignee # Change assignee +op work-package update --description "…" # Update description (markdown) +op work-package update --action # Execute a custom action (e.g. status transition) +op work-package update --attach # Attach a file +``` + +## Time entries + +```bash +op time-entry list # Time entries for current user +op time-entry list -u # Time entries for a specific user + +op time-entry create \ + -w \ + --hours 1.5 \ + --activity "Development" \ + --spent-on 2025-01-15 \ + --comment "Fixed the bug" \ + -u # All flags; --spent-on defaults to today +``` + +## Budgets + +```bash +op budget list -p # Budgets for a project +op budget inspect # Full details of a budget +``` + +## Projects + +```bash +op project list # All visible projects +op project inspect # Project details (numeric ID or identifier/slug) +op project inspect --open # Open in default browser +``` + +## Other resources + +```bash +op notification list # Unread notifications +op notification list -r # Filter by reason + +op user search # Find a user + +op status list # List work package statuses +op type list # List work package types + +op activities list # All activities +op activities list --work-package # Activities for a specific work package +``` + +## Git integration + +```bash +op git start workpackage # Create a branch from WP id/subject/type and switch to it +``` + +## Profiles (multiple OpenProject instances) + +The `--profile` flag selects which instance to talk to (stored in `~/.config/openproject/config`). The default profile is used when omitted. The env var `OP_CLI_PROFILE` also selects a profile. + +```bash +op login # Authenticate (prompts for host, token, profile name) +op login --profile work # Authenticate a named profile +op logout # Remove the default profile +op logout --profile work # Remove a named profile +op whoami # Show all profiles with their server and user +``` + +## JSON output + +Use `--format json` to get machine-readable output or raw markdown descriptions: + +```bash +op work-package inspect --format json +op work-package list --format json -p +``` + +## Workflow for use-case analysis + +1. `op whoami` — confirm which instance you're on +2. `op project list` — find relevant project IDs +3. `op work-package list -p -s open` — get the work items +4. `op work-package inspect --format json ` — drill into specifics (raw markdown) +5. `op work-package search -p ` — find by subject or identifier fragment diff --git a/README.md b/README.md index cb5a22d..4d74eef 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,38 @@ op work-package inspect 42 op work-package inspect PROJ-123 ``` +## 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. + +`~/.claude/CLAUDE.md` is Claude Code's global instruction file — create it if it doesn't exist yet. + +### Option A — available across all your projects (recommended) + +Symlink the file into your Claude configuration directory from the repo root: + +```shell +ln -s $(pwd)/.claude/op.md ~/.claude/op.md +``` + +Then add a line to `~/.claude/CLAUDE.md`: + +```markdown +Use `op.md` as a reference for all `op` CLI commands (work packages, projects, time entries, search, profiles…). Read it whenever the user asks about OpenProject or wants to use `op`. +``` + +The symlink keeps the file in sync automatically as you pull new versions. + +### Option B — available in this project only + +Add a line to the project's `CLAUDE.md` at the repo root: + +```markdown +Use `.claude/op.md` as a reference for all `op` CLI commands (work packages, projects, time entries, search, profiles…). Read it whenever the user asks about OpenProject or wants to use `op`. +``` + +No copy or symlink needed — the path is relative to the project root. + ## Creating a release Releases are triggered by pushing a git tag. The tag name becomes the version string embedded in the binaries. From 7492763156fd99fcf426d47b94dd28c58d976d29 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 14 Jun 2026 17:59:29 +0100 Subject: [PATCH 30/39] Report invalid config instead of empty credentials A corrupt config file previously read as empty credentials (or bogus ones if it held a space) with no error; it is now reported instead. --- components/configuration/findings_test.go | 63 +++++++++++++++++++++++ components/configuration/profiles.go | 51 +++++++++++++++--- 2 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 components/configuration/findings_test.go diff --git a/components/configuration/findings_test.go b/components/configuration/findings_test.go new file mode 100644 index 0000000..71a4062 --- /dev/null +++ b/components/configuration/findings_test.go @@ -0,0 +1,63 @@ +package configuration_test + +// Regression tests for review findings #1 and #2 on PR #15: a corrupt or +// malformed config file must be reported as an error rather than silently +// yielding empty (or bogus migrated) credentials. They reuse setupTempConfig / +// writeRaw from profiles_test.go (same package). + +import ( + "testing" + + "github.com/opf/openproject-cli/components/configuration" +) + +// Finding #1: a corrupt config file must now be reported as an error rather than +// silently yielding empty (or, for spaced garbage, bogus migrated) credentials. +func TestFinding_CorruptConfigReportsError(t *testing.T) { + corrupt := []struct { + name string + content string + }{ + {"garbage with spaces", "%%% not ini and not host token %%%"}, + {"garbage no spaces", "@@@garbage@@@"}, + {"malformed ini section", "[default\nhost broken"}, + {"host only, no token", "https://example.com"}, + } + + for _, c := range corrupt { + t.Run(c.name, func(t *testing.T) { + setupTempConfig(t) + writeRaw(t, c.content) + + host, token, err := configuration.ReadConfig("default") + if err == nil { + t.Errorf("expected an error for corrupt config %q, got host=%q token=%q", c.content, host, token) + } + }) + } +} + +// Finding #2: a malformed old-format file (single field, no usable host) is now +// surfaced as an error instead of a silent logout. +func TestFinding_MalformedOldFormatReportsError(t *testing.T) { + setupTempConfig(t) + writeRaw(t, "justatokenwithnospace") + + if _, _, err := configuration.ReadConfig("default"); err == nil { + t.Error("expected an error for malformed old-format config, got nil") + } +} + +// A well-formed old file ("host token") must still migrate cleanly. +func TestFinding_WellFormedOldFormatStillMigrates(t *testing.T) { + setupTempConfig(t) + writeRaw(t, "https://legacy.example.com legacytoken") + + host, token, err := configuration.ReadConfig("default") + if err != nil { + t.Fatal(err) + } + if host != "https://legacy.example.com" || token != "legacytoken" { + t.Errorf("well-formed old file should migrate, got host=%q token=%q", host, token) + } +} diff --git a/components/configuration/profiles.go b/components/configuration/profiles.go index fda9aa6..e027102 100644 --- a/components/configuration/profiles.go +++ b/components/configuration/profiles.go @@ -2,6 +2,7 @@ package configuration import ( "fmt" + "net/url" "os" "regexp" "strings" @@ -182,25 +183,53 @@ func (f *iniFile) marshal() []byte { return []byte(sb.String()) } +// looksLikeHost reports whether s parses as an absolute URL with a scheme and +// host. Used to distinguish a genuine old-format "host token" line from a +// corrupt file, so garbage is not silently migrated into bogus credentials. +func looksLikeHost(s string) bool { + u, err := url.Parse(s) + return err == nil && u.Scheme != "" && u.Host != "" +} + // readOrMigrate reads the config file and migrates it from the old // single-line "host token" format when needed. Returns (ini, migrated, error). -func readOrMigrate(data []byte) (*iniFile, bool) { +// A non-empty file that is neither valid INI nor a well-formed old-format line +// is reported as corrupt rather than silently yielding empty/bogus credentials. +func readOrMigrate(data []byte) (*iniFile, bool, error) { content := strings.TrimSpace(string(data)) + if content == "" { + return newIniFile(), false, nil + } // Old format: no section headers. Check prefix rather than Contains so that // IPv6 host URLs (e.g. http://[::1]) are not mis-detected as INI files. - if !strings.HasPrefix(content, "[") && content != "" { + if !strings.HasPrefix(content, "[") { clean := common.SanitizeLineBreaks(content) parts := strings.SplitN(clean, " ", 2) - if len(parts) == 2 && parts[0] != "" && parts[1] != "" { + // Require the first field to look like a real host URL, so a corrupt + // file is not migrated into bogus credentials just because it happens + // to contain a space. + if len(parts) == 2 && looksLikeHost(parts[0]) && parts[1] != "" { f := newIniFile() f.set(DefaultProfile, "host", parts[0]) f.set(DefaultProfile, "token", parts[1]) - return f, true + return f, true, nil } + return nil, false, invalidConfigError() } - return parseIni(data), false + f := parseIni(data) + if len(f.sections) == 0 { + return nil, false, invalidConfigError() + } + return f, false, nil +} + +func invalidConfigError() error { + return errors.Custom(fmt.Sprintf( + "invalid config file at %s. Please remove the file and run `op login` again.", + configFile(), + )) } func readOrMigrateFile() (*iniFile, error) { @@ -212,7 +241,10 @@ func readOrMigrateFile() (*iniFile, error) { return nil, err } - f, migrated := readOrMigrate(data) + f, migrated, err := readOrMigrate(data) + if err != nil { + return nil, err + } if migrated { if err := os.WriteFile(configFile(), f.marshal(), 0644); err != nil { return nil, err @@ -265,7 +297,12 @@ func deleteProfile(profile string) error { if err != nil { return err } - f, _ := readOrMigrate(data) + f, _, err := readOrMigrate(data) + if err != nil { + // Corrupt file: nothing meaningful to delete, but removing a profile + // must stay idempotent, so report the corruption rather than panicking. + return err + } f.delete(profile) return os.WriteFile(configFile(), f.marshal(), 0644) } From 552f7319fb54cd90f85b4ef7415ccb774c6fafec Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 14 Jun 2026 18:00:17 +0100 Subject: [PATCH 31/39] Preserve unknown keys when rewriting config marshal() wrote only host and token, so any other key in a profile section was silently lost on rewrite; such keys now round-trip. --- .../configuration/findings_marshal_test.go | 41 +++++++++++++++++++ components/configuration/profiles.go | 16 ++++++++ 2 files changed, 57 insertions(+) create mode 100644 components/configuration/findings_marshal_test.go diff --git a/components/configuration/findings_marshal_test.go b/components/configuration/findings_marshal_test.go new file mode 100644 index 0000000..b5e4a88 --- /dev/null +++ b/components/configuration/findings_marshal_test.go @@ -0,0 +1,41 @@ +package configuration_test + +// Regression test for review finding #3 on PR #15: rewriting the config must +// preserve keys other than host/token. Reuses setupTempConfig / writeRaw from +// profiles_test.go (same package). + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/opf/openproject-cli/components/configuration" +) + +// readRawConfig returns the raw bytes of the config file the package writes to. +func readRawConfig(t *testing.T) string { + t.Helper() + path := filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "openproject", "config") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading raw config: %v", err) + } + return string(data) +} + +func TestFinding_MarshalPreservesUnknownKeys(t *testing.T) { + setupTempConfig(t) + writeRaw(t, "[default]\nhost = https://example.com\ntoken = tok\nextra = keepme\n") + + // Trigger a read-modify-write cycle on a different profile so the default + // section is preserved-and-rewritten rather than edited. + if err := configuration.WriteConfigForProfile("work", "https://work.example.com", "tok-work"); err != nil { + t.Fatal(err) + } + + raw := readRawConfig(t) + if !strings.Contains(raw, "extra = keepme") { + t.Errorf("expected unknown key 'extra = keepme' to survive rewrite, file was:\n%s", raw) + } +} diff --git a/components/configuration/profiles.go b/components/configuration/profiles.go index e027102..6dfcacd 100644 --- a/components/configuration/profiles.go +++ b/components/configuration/profiles.go @@ -5,6 +5,7 @@ import ( "net/url" "os" "regexp" + "sort" "strings" "github.com/opf/openproject-cli/components/common" @@ -174,11 +175,26 @@ func (f *iniFile) marshal() []byte { sb.WriteString("\n") } sb.WriteString(fmt.Sprintf("[%s]\n", s.name)) + // host and token first for stable, readable ordering. + written := make(map[string]bool) for _, key := range []string{"host", "token"} { if v, ok := s.kv[key]; ok { sb.WriteString(fmt.Sprintf("%s = %s\n", key, v)) + written[key] = true } } + // Preserve any other keys (sorted for deterministic output) so data the + // CLI does not recognise is not silently dropped on rewrite. + rest := make([]string, 0, len(s.kv)) + for k := range s.kv { + if !written[k] { + rest = append(rest, k) + } + } + sort.Strings(rest) + for _, k := range rest { + sb.WriteString(fmt.Sprintf("%s = %s\n", k, s.kv[k])) + } } return []byte(sb.String()) } From 0f607939f18a4b94bd9e23ebb313cd2a14b93915 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 14 Jun 2026 18:00:37 +0100 Subject: [PATCH 32/39] Keep sub-minute precision in time entry durations Rounding to whole minutes turned a positive sub-minute entry into a zero-length PT0H; it now formats with second precision. Hours stay a flat count (not days), as time entries are measured in hours. --- components/resources/time_entries/create.go | 29 ++++++++-- .../resources/time_entries/create_test.go | 57 +++++++++++++++++++ 2 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 components/resources/time_entries/create_test.go diff --git a/components/resources/time_entries/create.go b/components/resources/time_entries/create.go index 898f481..55f9601 100644 --- a/components/resources/time_entries/create.go +++ b/components/resources/time_entries/create.go @@ -58,14 +58,31 @@ func hoursCreate(entry *dtos.TimeEntryDto, input string) error { return nil } +// hoursToISO8601 formats a number of hours as an ISO 8601 time-only duration +// (e.g. PT1H30M, PT45M, PT8H). Seconds are included so sub-minute entries are +// not silently rounded away to a zero-length duration. Hours are kept as a flat +// hour count (no day/week roll-up) because OpenProject time entries are +// expressed in hours. func hoursToISO8601(hours float64) string { - totalMinutes := int(math.Round(hours * 60)) - h := totalMinutes / 60 - m := totalMinutes % 60 - if m == 0 { - return fmt.Sprintf("PT%dH", h) + totalSeconds := int64(math.Round(hours * 3600)) + h := totalSeconds / 3600 + m := (totalSeconds % 3600) / 60 + s := totalSeconds % 60 + + out := "PT" + if h > 0 { + out += fmt.Sprintf("%dH", h) + } + if m > 0 { + out += fmt.Sprintf("%dM", m) + } + if s > 0 { + out += fmt.Sprintf("%dS", s) + } + if out == "PT" { + return "PT0S" } - return fmt.Sprintf("PT%dH%dM", h, m) + return out } func activityCreate(entry *dtos.TimeEntryDto, input string) error { diff --git a/components/resources/time_entries/create_test.go b/components/resources/time_entries/create_test.go new file mode 100644 index 0000000..aeb242f --- /dev/null +++ b/components/resources/time_entries/create_test.go @@ -0,0 +1,57 @@ +package time_entries + +// Regression tests for review finding #4 on PR #15, asserting the FIXED +// behaviour of hoursToISO8601: correct ISO 8601 output that round-trips through +// github.com/sosodev/duration, including sub-minute precision (no silent loss). + +import ( + "math" + "testing" + + "github.com/sosodev/duration" +) + +func TestHoursToISO8601_FormatAndRoundTrip(t *testing.T) { + cases := []struct { + hours float64 + want string + }{ + {1.0, "PT1H"}, + {1.5, "PT1H30M"}, + {0.25, "PT15M"}, + {0.5, "PT30M"}, + {8.0, "PT8H"}, + {2.75, "PT2H45M"}, + {25.0, "PT25H"}, // flat hours, no day roll-up + } + + for _, c := range cases { + got := hoursToISO8601(c.hours) + if got != c.want { + t.Errorf("hoursToISO8601(%g) = %q, want %q", c.hours, got, c.want) + } + + parsed, err := duration.Parse(got) + if err != nil { + t.Errorf("library cannot parse output %q: %v", got, err) + continue + } + if rt := parsed.ToTimeDuration().Hours(); math.Abs(rt-c.hours) > 1e-9 { + t.Errorf("round-trip of %q = %g hours, want %g", got, rt, c.hours) + } + } +} + +// A positive sub-minute entry must no longer be rounded away to zero. +func TestHoursToISO8601_SubMinutePreserved(t *testing.T) { + const tiny = 0.001 // 3.6 seconds + got := hoursToISO8601(tiny) + + parsed, err := duration.Parse(got) + if err != nil { + t.Fatalf("library cannot parse %q: %v", got, err) + } + if rt := parsed.ToTimeDuration().Hours(); rt <= 0 { + t.Errorf("sub-minute input %g formatted as %q -> %g hours; expected > 0", tiny, got, rt) + } +} From 3caf3a96938a014494fb3139d6bd437fde7c137a Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 14 Jun 2026 20:42:41 +0200 Subject: [PATCH 33/39] Rename activity command from plural activities The noun-first rename left this command plural while every other noun is singular (work-package, time-entry); align it to op activity list. --- .claude/op.md | 4 ++-- .../activities.go => activity/activity.go} | 4 ++-- cmd/activity/activity_test.go | 15 +++++++++++++++ cmd/{activities => activity}/list.go | 2 +- cmd/root.go | 4 ++-- 5 files changed, 22 insertions(+), 7 deletions(-) rename cmd/{activities/activities.go => activity/activity.go} (89%) create mode 100644 cmd/activity/activity_test.go rename cmd/{activities => activity}/list.go (98%) diff --git a/.claude/op.md b/.claude/op.md index 867a0fc..fdc9766 100644 --- a/.claude/op.md +++ b/.claude/op.md @@ -101,8 +101,8 @@ op user search # Find a user op status list # List work package statuses op type list # List work package types -op activities list # All activities -op activities list --work-package # Activities for a specific work package +op activity list # All activities +op activity list --work-package # Activities for a specific work package ``` ## Git integration diff --git a/cmd/activities/activities.go b/cmd/activity/activity.go similarity index 89% rename from cmd/activities/activities.go rename to cmd/activity/activity.go index 6b0429e..63ff6e6 100644 --- a/cmd/activities/activities.go +++ b/cmd/activity/activity.go @@ -1,9 +1,9 @@ -package activities +package activity import "github.com/spf13/cobra" var RootCmd = &cobra.Command{ - Use: "activities [verb]", + Use: "activity [verb]", Short: "Manage activities", Long: "List activities scoped by work package, project, or globally.", } diff --git a/cmd/activity/activity_test.go b/cmd/activity/activity_test.go new file mode 100644 index 0000000..af98c2f --- /dev/null +++ b/cmd/activity/activity_test.go @@ -0,0 +1,15 @@ +package activity_test + +import ( + "strings" + "testing" + + "github.com/opf/openproject-cli/cmd/activity" +) + +func TestRootCmd_UsesSingularNoun(t *testing.T) { + use := activity.RootCmd.Use + if !strings.HasPrefix(use, "activity") { + t.Errorf("expected command name 'activity', got %q", use) + } +} diff --git a/cmd/activities/list.go b/cmd/activity/list.go similarity index 98% rename from cmd/activities/list.go rename to cmd/activity/list.go index ccc0934..cc63b69 100644 --- a/cmd/activities/list.go +++ b/cmd/activity/list.go @@ -1,4 +1,4 @@ -package activities +package activity import ( "github.com/spf13/cobra" diff --git a/cmd/root.go b/cmd/root.go index f5707e7..e61c8a6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" - "github.com/opf/openproject-cli/cmd/activities" + "github.com/opf/openproject-cli/cmd/activity" "github.com/opf/openproject-cli/cmd/budget" "github.com/opf/openproject-cli/cmd/git" "github.com/opf/openproject-cli/cmd/notification" @@ -157,7 +157,7 @@ func init() { logoutCmd, whoamiCmd, // noun-first (new) - activities.RootCmd, + activity.RootCmd, budget.RootCmd, workpackage.RootCmd, project.RootCmd, From 223e1b4c9f72954a3ebec10e855cceb7fd84a5a0 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 14 Jun 2026 20:47:18 +0200 Subject: [PATCH 34/39] Write config with 0600 to protect API tokens The config file holds API tokens, but writes used 0644, leaving it world-readable on Unix; new writes use 0600 (dir is already 0700). --- components/configuration/filemode_test.go | 29 +++++++++++++++++++++++ components/configuration/profiles.go | 6 ++--- 2 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 components/configuration/filemode_test.go diff --git a/components/configuration/filemode_test.go b/components/configuration/filemode_test.go new file mode 100644 index 0000000..89c02f6 --- /dev/null +++ b/components/configuration/filemode_test.go @@ -0,0 +1,29 @@ +package configuration_test + +// Copilot review (PR #15): the config file stores API tokens, so it must not be +// world-readable. WriteConfigForProfile must create it with mode 0600. + +import ( + "os" + "path/filepath" + "testing" + + "github.com/opf/openproject-cli/components/configuration" +) + +func TestWriteConfigForProfile_FileModeIs0600(t *testing.T) { + setupTempConfig(t) + + if err := configuration.WriteConfigForProfile("default", "https://example.com", "secret-token"); err != nil { + t.Fatal(err) + } + + path := filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "openproject", "config") + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if mode := info.Mode().Perm(); mode != 0600 { + t.Errorf("config file mode = %04o, want 0600 (token must not be world-readable)", mode) + } +} diff --git a/components/configuration/profiles.go b/components/configuration/profiles.go index 6dfcacd..0a9fc2b 100644 --- a/components/configuration/profiles.go +++ b/components/configuration/profiles.go @@ -262,7 +262,7 @@ func readOrMigrateFile() (*iniFile, error) { return nil, err } if migrated { - if err := os.WriteFile(configFile(), f.marshal(), 0644); err != nil { + if err := os.WriteFile(configFile(), f.marshal(), 0600); err != nil { return nil, err } } @@ -302,7 +302,7 @@ func writeProfile(profile, host, token string) error { } f.set(profile, "host", host) f.set(profile, "token", token) - return os.WriteFile(configFile(), f.marshal(), 0644) + return os.WriteFile(configFile(), f.marshal(), 0600) } func deleteProfile(profile string) error { @@ -320,5 +320,5 @@ func deleteProfile(profile string) error { return err } f.delete(profile) - return os.WriteFile(configFile(), f.marshal(), 0644) + return os.WriteFile(configFile(), f.marshal(), 0600) } From 19468f2530c37f0cd40f555f2eaf32fe855cb406 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 14 Jun 2026 20:47:34 +0200 Subject: [PATCH 35/39] Emit valid JSON when serialization fails The marshal-failure branch interpolated the raw error into a JSON literal, which breaks if it contains quotes or newlines; marshal it. --- components/printer/json_renderer.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/components/printer/json_renderer.go b/components/printer/json_renderer.go index 7fdfde6..915223d 100644 --- a/components/printer/json_renderer.go +++ b/components/printer/json_renderer.go @@ -2,6 +2,7 @@ package printer import ( "encoding/json" + "fmt" "sort" "github.com/opf/openproject-cli/models" @@ -252,7 +253,12 @@ func (r *JsonRenderer) Number(n int64) { func printJson(v any) { b, err := json.MarshalIndent(v, "", " ") if err != nil { - activePrinter.Printf("{\"error\": \"failed to serialize output: %s\"}\n", err) + // Marshal the error payload too, so the failure output is still valid + // JSON even when the error text contains quotes or newlines. + errJson, _ := json.Marshal(struct { + Error string `json:"error"` + }{fmt.Sprintf("failed to serialize output: %s", err)}) + activePrinter.Println(string(errJson)) return } activePrinter.Println(string(b)) From 03902c059ab310652f18c1b0deb85dfd6aa2aabd Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 14 Jun 2026 20:47:49 +0200 Subject: [PATCH 36/39] Drop completed TODO about fmt output root.go and login.go now route all output through printer.*, so the item claiming they use fmt directly is stale and misleading. --- TODO.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/TODO.md b/TODO.md index a177a60..6727359 100644 --- a/TODO.md +++ b/TODO.md @@ -2,10 +2,6 @@ Issues identified during code review — non-blocking, to address in future iterations. -## Code quality - -- [ ] **Replace `fmt.Println/Printf` with `printer.*` in `root.go` and `login.go`** — Both files use `fmt` directly for terminal output, violating the convention that all output goes through `printer/`. `root.go` uses it for the version string, `login.go` for the token prompt and error messages. - ## Known limitations - [ ] **`op time-entry create --activity` cannot list available activities** — `GET /api/v3/time_entries/activities` returns 404 in some OpenProject instances (version or permission issue). Investigate using the form endpoint `GET /api/v3/time_entries/form` which may include allowed activities in its schema. From bb4d9186cad34a4af6682f2b6eff2015cd5d9717 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 14 Jun 2026 21:17:22 +0200 Subject: [PATCH 37/39] Reject profile section missing host or token A section that parses but lacks host or token (e.g. a malformed line was dropped) was returned as empty credentials with no error; it is now reported. Absent sections still read as "not logged in". --- components/configuration/findings_test.go | 41 +++++++++++++++++++++++ components/configuration/profiles.go | 12 +++++++ 2 files changed, 53 insertions(+) diff --git a/components/configuration/findings_test.go b/components/configuration/findings_test.go index 71a4062..f8ae0cf 100644 --- a/components/configuration/findings_test.go +++ b/components/configuration/findings_test.go @@ -48,6 +48,47 @@ func TestFinding_MalformedOldFormatReportsError(t *testing.T) { } } +// Finding #1 (follow-up): an INI section that parses but is missing host or +// token — e.g. because a malformed line was silently dropped — must be reported +// as invalid rather than handed back as empty credentials. +func TestFinding_SectionMissingCredentialReportsError(t *testing.T) { + cases := []struct { + name string + content string + }{ + {"malformed host line dropped", "[default]\nhost broken\ntoken = tok"}, + {"missing token", "[default]\nhost = https://example.com"}, + {"missing host", "[default]\ntoken = tok"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + setupTempConfig(t) + writeRaw(t, c.content) + + if _, _, err := configuration.ReadConfig("default"); err == nil { + t.Errorf("expected an error for incomplete profile section %q, got nil", c.content) + } + }) + } +} + +// A section with usable host and token must still succeed even if it carries an +// extra unrecognised line — completeness of credentials is what matters, not +// line-by-line syntax pedantry. +func TestFinding_CompleteSectionWithStrayLineStillWorks(t *testing.T) { + setupTempConfig(t) + writeRaw(t, "[default]\nhost = https://example.com\ntoken = tok\nstrayline\n") + + host, token, err := configuration.ReadConfig("default") + if err != nil { + t.Fatalf("complete section should read cleanly, got error: %v", err) + } + if host != "https://example.com" || token != "tok" { + t.Errorf("got host=%q token=%q", host, token) + } +} + // A well-formed old file ("host token") must still migrate cleanly. func TestFinding_WellFormedOldFormatStillMigrates(t *testing.T) { setupTempConfig(t) diff --git a/components/configuration/profiles.go b/components/configuration/profiles.go index 0a9fc2b..9bff0f1 100644 --- a/components/configuration/profiles.go +++ b/components/configuration/profiles.go @@ -145,6 +145,11 @@ func (f *iniFile) get(section, key string) (string, bool) { return v, ok } +func (f *iniFile) hasSection(section string) bool { + _, ok := f.index[section] + return ok +} + func (f *iniFile) set(section, key, val string) { idx, ok := f.index[section] if !ok { @@ -276,6 +281,13 @@ func readConfigForProfile(profile string) (host, token string, err error) { } host, _ = f.get(profile, "host") token, _ = f.get(profile, "token") + // An absent profile is a normal "not logged in" state. But a section that + // exists yet is missing host or token is corrupt (e.g. a malformed line was + // dropped during parsing) and must be reported rather than handed back as + // empty credentials. + if f.hasSection(profile) && (host == "" || token == "") { + return "", "", invalidConfigError() + } return host, token, nil } From 5418b34678cb497ebb3e70b1aafdc2a256eeb84d Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 15 Jun 2026 11:51:05 +0200 Subject: [PATCH 38/39] Warn when config file is world-readable The config file stores API tokens and is written with mode 0600, but nothing detected a file that had been loosened (e.g. an old 0644 file predating the 0600 write, or one chmod-ed by hand). Add a permission check that runs on every command: when the file is accessible by group or other users, print a warning telling the user to chmod 600 it. The warning goes to stderr via a new printer.Warning so it never corrupts machine-readable output on stdout. Permission bits are not meaningful on Windows, so the check is skipped there. --- cmd/root.go | 8 ++++ components/configuration/filemode_test.go | 47 +++++++++++++++++++++++ components/configuration/profiles.go | 26 +++++++++++++ components/printer/common.go | 7 ++++ components/printer/console_printer.go | 9 ++++- components/printer/printer.go | 3 ++ components/printer/testing_printer.go | 6 +++ components/printer/warning_test.go | 21 ++++++++++ 8 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 components/printer/warning_test.go diff --git a/cmd/root.go b/cmd/root.go index e61c8a6..58ad97f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -60,6 +60,14 @@ projects of your OpenProject instance.`, os.Exit(1) } + if insecure, mode := configuration.InsecureConfigPermissions(); insecure { + path := configuration.ConfigFilePath() + printer.Warning(fmt.Sprintf( + "config file %s is accessible by other users (mode %#o); it stores your API token. Run 'chmod 600 \"%s\"' to restrict access.", + path, mode, path, + )) + } + if host == "" && explicit { printer.Error(openerrors.Custom(fmt.Sprintf( "Profile %q not found. Run 'op login --profile %s' to create it.", diff --git a/components/configuration/filemode_test.go b/components/configuration/filemode_test.go index 89c02f6..7ebfa47 100644 --- a/components/configuration/filemode_test.go +++ b/components/configuration/filemode_test.go @@ -6,6 +6,7 @@ package configuration_test import ( "os" "path/filepath" + "runtime" "testing" "github.com/opf/openproject-cli/components/configuration" @@ -27,3 +28,49 @@ func TestWriteConfigForProfile_FileModeIs0600(t *testing.T) { t.Errorf("config file mode = %04o, want 0600 (token must not be world-readable)", mode) } } + +// A config written by the CLI (mode 0600) must not be reported as insecure. +func TestInsecureConfigPermissions_SecureFile(t *testing.T) { + setupTempConfig(t) + + if err := configuration.WriteConfigForProfile("default", "https://example.com", "secret-token"); err != nil { + t.Fatal(err) + } + + if insecure, mode := configuration.InsecureConfigPermissions(); insecure { + t.Errorf("0600 config reported insecure (mode %#o)", mode) + } +} + +// A config readable by group or other users must be reported as insecure so the +// CLI can warn that the API token may leak. +func TestInsecureConfigPermissions_WorldReadableFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix permission bits are not meaningful on Windows") + } + setupTempConfig(t) + + if err := configuration.WriteConfigForProfile("default", "https://example.com", "secret-token"); err != nil { + t.Fatal(err) + } + if err := os.Chmod(configuration.ConfigFilePath(), 0644); err != nil { + t.Fatal(err) + } + + insecure, mode := configuration.InsecureConfigPermissions() + if !insecure { + t.Errorf("0644 config not reported insecure (mode %#o)", mode) + } + if mode.Perm() != 0644 { + t.Errorf("reported mode = %#o, want 0644", mode.Perm()) + } +} + +// A missing config file is not insecure. +func TestInsecureConfigPermissions_MissingFile(t *testing.T) { + setupTempConfig(t) + + if insecure, _ := configuration.InsecureConfigPermissions(); insecure { + t.Error("missing config file reported insecure") + } +} diff --git a/components/configuration/profiles.go b/components/configuration/profiles.go index 9bff0f1..fac9ef4 100644 --- a/components/configuration/profiles.go +++ b/components/configuration/profiles.go @@ -5,6 +5,7 @@ import ( "net/url" "os" "regexp" + "runtime" "sort" "strings" @@ -84,6 +85,31 @@ func DeleteProfile(profile string) error { return deleteProfile(profile) } +// ConfigFilePath returns the path of the config file. It honours +// $XDG_CONFIG_HOME/$HOME, so it is absolute only when those are. Exposed so +// callers can name the file in user-facing messages. +func ConfigFilePath() string { + return configFile() +} + +// InsecureConfigPermissions reports whether the config file exists with +// permissions that let group or other users access it. The file stores API +// tokens (and is written with mode 0600), so callers should warn the user when +// this returns true. The second value is the file's permission bits. Unix +// permission bits are not meaningful on Windows, so it always reports false +// there; a missing or unreadable file is likewise not reported as insecure. +func InsecureConfigPermissions() (insecure bool, mode os.FileMode) { + if runtime.GOOS == "windows" { + return false, 0 + } + info, err := os.Stat(configFile()) + if err != nil { + return false, 0 + } + mode = info.Mode().Perm() + return mode&0077 != 0, mode +} + // AllProfiles returns every profile stored in the config file. func AllProfiles() ([]*Profile, error) { if err := ensureConfigDir(); err != nil { diff --git a/components/printer/common.go b/components/printer/common.go index 7b3b33f..e3ef4d3 100644 --- a/components/printer/common.go +++ b/components/printer/common.go @@ -2,6 +2,7 @@ package printer import ( "encoding/json" + "fmt" "github.com/opf/openproject-cli/components/errors" ) @@ -20,6 +21,12 @@ func Input(prompt string) { activePrinter.Printf(prompt) } +// Warning writes a non-fatal diagnostic to standard error so it never corrupts +// machine-readable output (e.g. JSON) written to standard out. +func Warning(msg string) { + activePrinter.Eprintln(fmt.Sprintf("%s %s", Yellow("[WARNING]"), msg)) +} + func Done() { activePrinter.Println(Green("DONE")) } diff --git a/components/printer/console_printer.go b/components/printer/console_printer.go index a60002d..bf7c8e7 100644 --- a/components/printer/console_printer.go +++ b/components/printer/console_printer.go @@ -1,6 +1,9 @@ package printer -import "fmt" +import ( + "fmt" + "os" +) type ConsolePrinter struct{} @@ -11,3 +14,7 @@ func (printer *ConsolePrinter) Printf(format string, a ...any) (n int, err error func (printer *ConsolePrinter) Println(a ...any) (n int, err error) { return fmt.Println(a...) } + +func (printer *ConsolePrinter) Eprintln(a ...any) (n int, err error) { + return fmt.Fprintln(os.Stderr, a...) +} diff --git a/components/printer/printer.go b/components/printer/printer.go index 00f0c24..776acae 100644 --- a/components/printer/printer.go +++ b/components/printer/printer.go @@ -3,6 +3,9 @@ package printer type Printer interface { Printf(format string, a ...any) (n int, err error) Println(a ...any) (n int, err error) + // Eprintln writes a line to standard error, keeping diagnostics out of the + // machine-readable output written to standard out. + Eprintln(a ...any) (n int, err error) } var activePrinter Printer diff --git a/components/printer/testing_printer.go b/components/printer/testing_printer.go index faea265..82f2c31 100644 --- a/components/printer/testing_printer.go +++ b/components/printer/testing_printer.go @@ -18,6 +18,12 @@ func (printer *TestingPrinter) Println(a ...any) (n int, err error) { return len(printer.Result), nil } +func (printer *TestingPrinter) Eprintln(a ...any) (n int, err error) { + printer.Result += fmt.Sprintln(a...) + + return len(printer.Result), nil +} + func (printer *TestingPrinter) Reset() { printer.Result = "" } diff --git a/components/printer/warning_test.go b/components/printer/warning_test.go new file mode 100644 index 0000000..327b51d --- /dev/null +++ b/components/printer/warning_test.go @@ -0,0 +1,21 @@ +package printer_test + +import ( + "strings" + "testing" + + "github.com/opf/openproject-cli/components/printer" +) + +func TestWarning(t *testing.T) { + testingPrinter.Reset() + + printer.Warning("watch out") + + if !strings.Contains(testingPrinter.Result, "[WARNING]") { + t.Errorf("warning missing [WARNING] tag: %q", testingPrinter.Result) + } + if !strings.Contains(testingPrinter.Result, "watch out") { + t.Errorf("warning missing message: %q", testingPrinter.Result) + } +} From ed739d67892da2e2cbb1e733e34772ee100066d9 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 16 Jun 2026 11:18:07 +0200 Subject: [PATCH 39/39] Add section about identifiers AI agents seem to have trouble with managing both identifiers. --- .claude/op.md | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/.claude/op.md b/.claude/op.md index fdc9766..24523f8 100644 --- a/.claude/op.md +++ b/.claude/op.md @@ -20,9 +20,55 @@ op whoami --profile # Same for a specific profile op project list # Discover available projects (get IDs for other commands) ``` +## Identifiers + +OpenProject has different identifiers for project and work packages. Mixing them up causes silent wrong-WP bugs. + +Since 17.5, semantic identifiers can be enabled. Once enabled, work packages have project-based identifiers like Jira. + +### Project id + +The internal integer primary key of the database. Examples: `141` or `239`. In JSON output, the **`id`** field on a *project* object. + +### Project identifier + +The human-readable short string identifying the project uniquely. Unique across all projects of the instance. When semantic identifiers are enabled on the instance, they are alphanumeric characters (e.g., `OP`, `PROJ`, `JIM`). When not, they are slugs (e.g., `openproject`, `stream-jira-exit`, `jira-migrator`). In JSON output, the **`identifier`** field on a *project* object. + +### Work package id + +The internal integer primary key (e.g., `71305`). Unique across all projects on the instance. Always present regardless of instance configuration. This is the number in `/wp/71305` URLs. In JSON output: the **`id`** field of a *work-package* object. + +```bash +op work-package inspect 71305 +op work-package inspect --format json 71305 | jq .id # → 71305 +``` + +### Work package semantic identifier (aka display ID, aka project-based identifier) + +When enabled by an administrator, each work package gets a project-scoped human label of the form `{PREFIX}-{N}` (e.g., `SJF-6`, `AGILE-54`, `OP-18917`, `JIM-43`). The prefix is the project identifier. Designed to support Jira migrations (existing Jira issue keys can be preserved). Historical numerical IDs remain valid and continue resolving to the same work packages. + +When enabled, the CLI and list output show these instead of bare numbers. In JSON output: the **`display_id`** field of a *work-package* object (same as `id` on instances where the feature is off). + +```bash +op work-package inspect --format json 71305 | jq .display_id # → "AGILE-54" (if enabled) + # → "71305" (if not enabled) +``` + +> **Gotcha — never strip the display ID prefix:** when project-based identifiers are enabled, list output shows `OP-7756` or `AGILE-32`. Pass these as-is to `inspect` — stripping the prefix gives a bare integer that resolves to a completely different WP: +> ```bash +> op work-package inspect --format json OP-7756 # correct +> op work-package inspect --format json 7756 # WRONG — different unrelated WP +> ``` + +### Choosing between id and identifier + +Prefer the numeric id in scripts, prefer the semantic identifier in human-facing output like changelogs. + +--- + ## Work packages -IDs accept either a numeric ID (`42`) or a project-based identifier (`PROJ-123`) everywhere. +IDs accept either an ID (`42`) or an identifier (`PROJ-123`, when enabled) everywhere. ```bash op work-package list # All visible work packages