Skip to content
Merged
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
33 changes: 33 additions & 0 deletions app/cli/cmd/group.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// Copyright 2025 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"github.com/spf13/cobra"
)

func newGroupCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "group",
Aliases: []string{"groups", "grp", "g"},
Short: "Group management",
Hidden: true,
}

cmd.AddCommand(newGroupCreateCmd(), newGroupDescribeCmd(), newGroupUpdateCmd(), newGroupListCmd(), newGroupDeleteCmd(), newGroupMembersCmd())

return cmd
}
80 changes: 80 additions & 0 deletions app/cli/cmd/group_create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//
// Copyright 2025 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"fmt"

"github.com/chainloop-dev/chainloop/app/cli/internal/action"

"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
)

func newGroupCreateCmd() *cobra.Command {
var name, description string

cmd := &cobra.Command{
Use: "create",
Short: "Create a new group",
Example: ` chainloop group create --name [groupName]

# With a description
chainloop group create --name developers --description "Group for developers"
`,
RunE: func(cmd *cobra.Command, _ []string) error {
resp, err := action.NewGroupCreate(actionOpts).Run(cmd.Context(), name, description)
if err != nil {
return err
}

// Print the group details
if err := encodeOutput(resp, groupItemTableOutput); err != nil {
return fmt.Errorf("failed to print group: %w", err)
}

logger.Info().Msg("Group created successfully")

return nil
},
}

cmd.Flags().StringVar(&name, "name", "", "group name")
err := cmd.MarkFlagRequired("name")
cobra.CheckErr(err)
cmd.Flags().StringVar(&description, "description", "", "group description")
cmd.Flags().SortFlags = false

return cmd
}

// Format function for group output
func groupItemTableOutput(data *action.GroupCreateItem) error {
t := newTableWriter()

t.AppendHeader(table.Row{"ID", "Name", "Description", "Created At", "Updated At"})
t.AppendRow(table.Row{
data.ID,
data.Name,
data.Description,
data.CreatedAt,
data.UpdatedAt,
})

t.Render()

return nil
}
59 changes: 59 additions & 0 deletions app/cli/cmd/group_delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// Copyright 2025 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"fmt"

"github.com/chainloop-dev/chainloop/app/cli/internal/action"
"github.com/spf13/cobra"
)

func newGroupDeleteCmd() *cobra.Command {
var groupName string
var force bool
Copy link
Member

Choose a reason for hiding this comment

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

is force used?

Copy link
Member Author

Choose a reason for hiding this comment

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

I think it is from a previous implementation, removing it.


cmd := &cobra.Command{
Use: "delete",
Short: "Delete a group",
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()

// Ask for confirmation, unless the --yes flag is set
if !flagYes {
logger.Warn().Msgf("Are you sure you want to delete the group '%s'?", groupName)

if err := confirmDeletion(); err != nil {
return err
}
}

if err := action.NewGroupDelete(actionOpts).Run(ctx, groupName); err != nil {
return fmt.Errorf("removing group: %w", err)
}

logger.Info().Msgf("Group '%s' has been removed", groupName)
return nil
},
}

cmd.Flags().StringVar(&groupName, "name", "", "Name of the group to remove")
cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt")
err := cmd.MarkFlagRequired("name")
cobra.CheckErr(err)

return cmd
}
51 changes: 51 additions & 0 deletions app/cli/cmd/group_describe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// Copyright 2025 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"fmt"

"github.com/chainloop-dev/chainloop/app/cli/internal/action"
"github.com/spf13/cobra"
)

func newGroupDescribeCmd() *cobra.Command {
var groupName string

cmd := &cobra.Command{
Use: "describe",
Short: "Get detailed information about a specific group",
RunE: func(cmd *cobra.Command, _ []string) error {
group, err := action.NewGroupDescribe(actionOpts).Run(cmd.Context(), groupName)
if err != nil {
return fmt.Errorf("describing group: %w", err)
}

// Print the group details
if err := encodeOutput(group, groupItemTableOutput); err != nil {
return fmt.Errorf("failed to print group: %w", err)
}

return nil
},
}

cmd.Flags().StringVar(&groupName, "name", "", "Name of the group to describe")
err := cmd.MarkFlagRequired("name")
cobra.CheckErr(err)

return cmd
}
158 changes: 158 additions & 0 deletions app/cli/cmd/group_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
//
// Copyright 2025 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"fmt"
"time"

"github.com/chainloop-dev/chainloop/app/cli/cmd/options"
"github.com/chainloop-dev/chainloop/app/cli/internal/action"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
)

func newGroupListCmd() *cobra.Command {
var paginationOpts = &options.OffsetPaginationOpts{}
var groupName, description, memberEmail string

cmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List existing Groups",
Example: ` # Let the default pagination apply
chainloop group list

# Specify the page and page size
chainloop group list --page 2 --limit 10

# Output in json format to paginate using scripts
chainloop group list --page 2 --limit 10 --output json

# Filter by group name
chainloop group list --group-name developers

# Filter by description
chainloop group list --description "team members"

# Filter by member email
chainloop group list --member-email user@example.com

# Show the full report
chainloop group list --full
`,
PreRunE: func(_ *cobra.Command, _ []string) error {
if paginationOpts.Page < 1 {
return fmt.Errorf("--page must be greater or equal than 1")
}
if paginationOpts.Limit < 1 {
return fmt.Errorf("--limit must be greater or equal than 1")
}

return nil
},
RunE: func(cmd *cobra.Command, _ []string) error {
filterOpts := &action.GroupListFilterOpts{
GroupName: groupName,
Description: description,
MemberEmail: memberEmail,
}

res, err := action.NewGroupList(actionOpts).Run(cmd.Context(), paginationOpts.Page, paginationOpts.Limit, filterOpts)
if err != nil {
return err
}

if err := encodeOutput(res, GroupListTableOutput); err != nil {
return err
}

pgResponse := res.Pagination

if pgResponse.TotalPages >= paginationOpts.Page {
inPage := min(paginationOpts.Limit, len(res.Groups))
lowerBound := (paginationOpts.Page - 1) * paginationOpts.Limit
logger.Info().Msg(fmt.Sprintf("Showing [%d-%d] out of %d", lowerBound+1, lowerBound+inPage, pgResponse.TotalCount))
}

if pgResponse.TotalCount > pgResponse.Page*pgResponse.PageSize {
logger.Info().Msg(fmt.Sprintf("Next page available: %d", pgResponse.Page+1))
}

return nil
},
}

cmd.Flags().BoolVar(&full, "full", false, "show the full report")
cmd.Flags().StringVar(&groupName, "group-name", "", "filter by group name")
cmd.Flags().StringVar(&description, "description", "", "filter by description")
cmd.Flags().StringVar(&memberEmail, "member-email", "", "filter by member email")
paginationOpts.AddFlags(cmd)

return cmd
}

func GroupListTableOutput(groupListResult *action.GroupListResult) error {
Copy link
Member

Choose a reason for hiding this comment

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

it might not need to be exposed

if len(groupListResult.Groups) == 0 {
fmt.Println("there are no groups yet")
return nil
}

headerRow := table.Row{"ID", "Name", "Description", "Created At"}
headerRowFull := table.Row{"ID", "Name", "Description", "Created At", "Updated At"}

t := newTableWriter()
if full {
t.AppendHeader(headerRowFull)
} else {
t.AppendHeader(headerRow)
}

for _, g := range groupListResult.Groups {
var row table.Row

if !full {
row = table.Row{
g.ID, g.Name, g.Description,
formatTime(g.CreatedAt),
}
} else {
row = table.Row{
g.ID, g.Name, g.Description,
formatTime(g.CreatedAt), formatTime(g.UpdatedAt),
}
}

t.AppendRow(row)
}
t.Render()

return nil
}

// Helper function to format time string
func formatTime(timeStr string) string {
Copy link
Member

Choose a reason for hiding this comment

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

this doesn't exist already?

Copy link
Member Author

Choose a reason for hiding this comment

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

I haven't seen it, we parse the time individually, see the example of workflow list

p.CreatedAt.Format(time.RFC822),

if timeStr == "" {
return ""
}

t, err := time.Parse(time.RFC3339, timeStr)
if err != nil {
return timeStr
}

return t.Format(time.RFC822)
}
Loading
Loading