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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .claude/op.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ op work-package list -s open # Filter: open / closed / <id>
op work-package list -s '!<id>' # Exclude a status (prefix with !)
op work-package list -a me # Filter by assignee (me or user ID)
op work-package list -t <type-id> # Filter by type (comma-separated IDs, ! prefix to exclude)
op work-package list -v <version-id> # Filter by version
op work-package list -v <version-id> # Filter by version (see: op version list --project <project>)
op work-package list --not-version <id> # Exclude a version
op work-package list --parent-id <wp-id> # Direct children of a work package
op work-package list --include-sub-projects # Include sub-project work packages
Expand Down Expand Up @@ -128,6 +128,12 @@ op budget list -p <project-id-or-slug> # Budgets for a project
op budget inspect <id> # Full details of a budget
```

## Versions

```bash
op version list --project <project-id-or-slug> # List a project's versions (IDs for -v/--not-version)
```

## Projects

```bash
Expand Down
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import shadowing (claude finding): cmd/root.go:143's func Execute(version *configuration.Version) parameter strictly shadows the newly added github.com/opf/openproject-cli/cmd/version import.

"github.com/opf/openproject-cli/cmd/workpackage"
"github.com/opf/openproject-cli/cmd/wptype"
"github.com/opf/openproject-cli/components/configuration"
Expand Down Expand Up @@ -196,6 +197,7 @@ func init() {
status.RootCmd,
notification.RootCmd,
git.RootCmd,
version.RootCmd,
)

rootCmd.InitDefaultCompletionCmd()
Expand Down
34 changes: 34 additions & 0 deletions cmd/version/list.go
Original file line number Diff line number Diff line change
@@ -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())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we contextualise the error message? (as cmd/budget/list.go:25 does)

Suggested change
printer.ErrorText(err.Error())
printer.ErrorText(fmt.Sprintf("--project: %s", err.Error()))

return openerrors.ErrHandled
}

versions, err := projects.AvailableVersions(listProjectId)
if err != nil {
printer.Error(err)
return openerrors.ErrHandled
}

printer.Versions(versions)
return nil
}
64 changes: 64 additions & 0 deletions cmd/version/list_test.go
Original file line number Diff line number Diff line change
@@ -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"
)
Comment on lines +3 to +14

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although an unlikely scenario in real-world usage (at least aT OP), We're missing a test for an empty version collection. This contrasts with cmd/workpackage/search.go:51 which explicitly prints No work package found for search input.

server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write([]byte(`{
Comment on lines +32 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about this finding.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same. It was there initially, but I removed it thinking that it's just a stub server. Now I realize that there may be value in testing the verb and http endpoint.

"_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)
}
}
Comment on lines +52 to +64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the same as #21 (comment)

23 changes: 23 additions & 0 deletions cmd/version/version.go
Original file line number Diff line number Diff line change
@@ -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)
}
12 changes: 12 additions & 0 deletions components/printer/json_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
1 change: 1 addition & 0 deletions components/printer/renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions components/printer/text_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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))
}
15 changes: 2 additions & 13 deletions components/printer/versions.go
Original file line number Diff line number Diff line change
@@ -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)
}
27 changes: 6 additions & 21 deletions components/printer/versions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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"))
Comment on lines +20 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're only testing TextRenderer. Shouldn't we exercise JsonRenderer as well?


printer.Versions(versions)

Expand Down
Loading