diff --git a/.claude/op.md b/.claude/op.md index 45d0977..13c4be6 100644 --- a/.claude/op.md +++ b/.claude/op.md @@ -77,7 +77,7 @@ op work-package list -s open # Filter: open / closed / 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 -v # Filter by version (see: op version list --project ) 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 @@ -128,6 +128,12 @@ op budget list -p # Budgets for a project op budget inspect # Full details of a budget ``` +## Versions + +```bash +op version list --project # List a project's versions (IDs for -v/--not-version) +``` + ## Projects ```bash diff --git a/cmd/root.go b/cmd/root.go index e567023..a9652af 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,7 @@ import ( "github.com/opf/openproject-cli/cmd/status" "github.com/opf/openproject-cli/cmd/timeentry" "github.com/opf/openproject-cli/cmd/user" + "github.com/opf/openproject-cli/cmd/version" "github.com/opf/openproject-cli/cmd/workpackage" "github.com/opf/openproject-cli/cmd/wptype" "github.com/opf/openproject-cli/components/configuration" @@ -196,6 +197,7 @@ func init() { status.RootCmd, notification.RootCmd, git.RootCmd, + version.RootCmd, ) rootCmd.InitDefaultCompletionCmd() diff --git a/cmd/version/list.go b/cmd/version/list.go new file mode 100644 index 0000000..1c22794 --- /dev/null +++ b/cmd/version/list.go @@ -0,0 +1,34 @@ +package version + +import ( + "github.com/spf13/cobra" + + openerrors "github.com/opf/openproject-cli/components/errors" + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/resources/projects" +) + +var listProjectId string + +var listCmd = &cobra.Command{ + Use: "list", + Short: "Lists versions", + Long: "Get a list of a project's versions, scoped by the provided flag (--project).", + RunE: listVersions, +} + +func listVersions(_ *cobra.Command, _ []string) error { + if err := projects.ValidateIdentifier(listProjectId); err != nil { + printer.ErrorText(err.Error()) + return openerrors.ErrHandled + } + + versions, err := projects.AvailableVersions(listProjectId) + if err != nil { + printer.Error(err) + return openerrors.ErrHandled + } + + printer.Versions(versions) + return nil +} diff --git a/cmd/version/list_test.go b/cmd/version/list_test.go new file mode 100644 index 0000000..457f220 --- /dev/null +++ b/cmd/version/list_test.go @@ -0,0 +1,64 @@ +package version + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + openerrors "github.com/opf/openproject-cli/components/errors" + "github.com/opf/openproject-cli/components/printer" + "github.com/opf/openproject-cli/components/requests" +) + +func TestListVersionsInvalidProjectReturnsError(t *testing.T) { + testingPrinter := &printer.TestingPrinter{} + printer.Init(testingPrinter) + listProjectId = "" + t.Cleanup(func() { listProjectId = "" }) + + err := listVersions(nil, nil) + if !errors.Is(err, openerrors.ErrHandled) { + t.Fatalf("listVersions error = %v, want ErrHandled", err) + } + if count := strings.Count(testingPrinter.ErrResult, "[ERROR]"); count != 1 { + t.Errorf("error diagnostic count = %d, want 1; stderr: %q", count, testingPrinter.ErrResult) + } +} + +func TestListVersionsPrintsAvailableVersions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + _, _ = response.Write([]byte(`{ + "_type": "Collection", + "_embedded": { + "elements": [ + {"id": 3, "name": "v17"}, + {"id": 7, "name": "v42"} + ] + } + }`)) + })) + t.Cleanup(server.Close) + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + requests.Init(host, "", false) + + testingPrinter := &printer.TestingPrinter{} + printer.Init(testingPrinter) + listProjectId = "example" + t.Cleanup(func() { listProjectId = "" }) + + if err := listVersions(nil, nil); err != nil { + t.Fatalf("listVersions error = %v, want nil", err) + } + + if !strings.Contains(testingPrinter.Result, "v17") || !strings.Contains(testingPrinter.Result, "v42") { + t.Errorf("expected output to contain versions 'v17' and 'v42', got: %q", testingPrinter.Result) + } +} diff --git a/cmd/version/version.go b/cmd/version/version.go new file mode 100644 index 0000000..8721789 --- /dev/null +++ b/cmd/version/version.go @@ -0,0 +1,23 @@ +package version + +import "github.com/spf13/cobra" + +var RootCmd = &cobra.Command{ + Use: "version [verb]", + Short: "Manage versions", + Long: "List a project's versions in OpenProject.", +} + +func init() { + listCmd.Flags().StringVarP( + &listProjectId, + "project", + "p", + "", + "Project numeric ID or identifier", + ) + + _ = listCmd.MarkFlagRequired("project") + + RootCmd.AddCommand(listCmd) +} diff --git a/components/printer/json_renderer.go b/components/printer/json_renderer.go index a933a68..fd79ed9 100644 --- a/components/printer/json_renderer.go +++ b/components/printer/json_renderer.go @@ -136,6 +136,18 @@ func (r *JsonRenderer) StatusList(statuses []*models.Status) { printJson(out) } +func (r *JsonRenderer) Versions(versions []*models.Version) { + type item struct { + Id uint64 `json:"id"` + Name string `json:"name"` + } + out := make([]item, len(versions)) + for i, v := range versions { + out[i] = item{v.Id, v.Name} + } + printJson(out) +} + func (r *JsonRenderer) TimeEntry(t *models.TimeEntry) { printJson(struct { Id uint64 `json:"id"` diff --git a/components/printer/renderer.go b/components/printer/renderer.go index 522870a..07ea554 100644 --- a/components/printer/renderer.go +++ b/components/printer/renderer.go @@ -18,6 +18,7 @@ type Renderer interface { Types([]*models.Type) Status(*models.Status) StatusList([]*models.Status) + Versions([]*models.Version) TimeEntryList([]*models.TimeEntry) TimeEntry(*models.TimeEntry) Notification(*models.Notification) diff --git a/components/printer/text_renderer.go b/components/printer/text_renderer.go index e3ba21d..aec2435 100644 --- a/components/printer/text_renderer.go +++ b/components/printer/text_renderer.go @@ -98,6 +98,16 @@ func (r *TextRenderer) StatusList(statuses []*models.Status) { } } +func (r *TextRenderer) Versions(versions []*models.Version) { + var maxIdLength = 0 + for _, v := range versions { + maxIdLength = common.Max(maxIdLength, idLength(v.Id)) + } + for _, v := range versions { + printVersion(v, maxIdLength) + } +} + func (r *TextRenderer) TimeEntry(t *models.TimeEntry) { printTimeEntry(t, idLength(t.Id), len(t.Activity), len(t.Project)) } @@ -206,6 +216,12 @@ func printStatus(s *models.Status, maxIdLength int) { activePrinter.Println(strings.Join(parts, " ")) } +func printVersion(v *models.Version, maxIdLength int) { + diff := maxIdLength - idLength(v.Id) + idStr := fmt.Sprintf("%s#%d", indent(diff), v.Id) + activePrinter.Println(strings.Join([]string{Red(idStr), Cyan(v.Name)}, " ")) +} + func printCustomAction(a *models.CustomAction) { activePrinter.Printf("%s %s\n", Red(fmt.Sprintf("#%d", a.Id)), Cyan(a.Name)) } diff --git a/components/printer/versions.go b/components/printer/versions.go index 25a16c7..3ad29a4 100644 --- a/components/printer/versions.go +++ b/components/printer/versions.go @@ -1,18 +1,7 @@ package printer -import ( - "fmt" - - "github.com/opf/openproject-cli/models" -) +import "github.com/opf/openproject-cli/models" func Versions(versions []*models.Version) { - for _, a := range versions { - printVersion(a) - } -} - -func printVersion(version *models.Version) { - id := fmt.Sprintf("#%d", version.Id) - activePrinter.Printf("[%s] %s\n", Red(id), Cyan(version.Name)) + activeRenderer.Versions(versions) } diff --git a/components/printer/versions_test.go b/components/printer/versions_test.go index 3371857..27c632e 100644 --- a/components/printer/versions_test.go +++ b/components/printer/versions_test.go @@ -2,10 +2,8 @@ package printer_test import ( "fmt" - "strconv" "testing" - "github.com/opf/openproject-cli/components/common" "github.com/opf/openproject-cli/components/printer" "github.com/opf/openproject-cli/models" ) @@ -14,27 +12,14 @@ func TestVersions(t *testing.T) { testingPrinter.Reset() versions := []*models.Version{ - { - Id: 2, - Name: "13.0", - }, - { - Id: 4, - Name: "13.1", - }, - { - Id: 43, - Name: "45.5", - }, + {Id: 2, Name: "13.0"}, + {Id: 4, Name: "13.1"}, + {Id: 43, Name: "45.5"}, } - expected := common.Reduce( - versions, - func(state string, version *models.Version) string { - idString := "#" + strconv.FormatUint(version.Id, 10) - return state + fmt.Sprintf("[%s] %s\n", printer.Red(idString), printer.Cyan(version.Name)) - }, - "") + expected := fmt.Sprintf("%s %s\n", printer.Red(" #2"), printer.Cyan("13.0")) + expected += fmt.Sprintf("%s %s\n", printer.Red(" #4"), printer.Cyan("13.1")) + expected += fmt.Sprintf("%s %s\n", printer.Red("#43"), printer.Cyan("45.5")) printer.Versions(versions)