Skip to content

Commit

Permalink
feat: Add "projects list" command to the CLI (#333)
Browse files Browse the repository at this point in the history
This adds a WorkspaceOwnerCount parameter returned from the
projects API. It's helpful to display the amount of usage
a specific project has.
  • Loading branch information
kylecarbs committed Feb 21, 2022
1 parent 59ee22d commit 67613da
Show file tree
Hide file tree
Showing 13 changed files with 311 additions and 16 deletions.
6 changes: 3 additions & 3 deletions cli/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func login() *cobra.Command {
if !isTTY(cmd) {
return xerrors.New("the initial user cannot be created in non-interactive mode. use the API")
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Your Coder deployment hasn't been set up!\n", color.HiBlackString(">"))
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Your Coder deployment hasn't been set up!\n", caret)

_, err := prompt(cmd, &promptui.Prompt{
Label: "Would you like to create the first user?",
Expand Down Expand Up @@ -147,7 +147,7 @@ func login() *cobra.Command {
return xerrors.Errorf("write server url: %w", err)
}

_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", color.HiBlackString(">"), color.HiCyanString(username))
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", caret, color.HiCyanString(username))
return nil
}

Expand Down Expand Up @@ -192,7 +192,7 @@ func login() *cobra.Command {
return xerrors.Errorf("write server url: %w", err)
}

_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", color.HiBlackString(">"), color.HiCyanString(resp.Username))
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", caret, color.HiCyanString(resp.Username))
return nil
},
}
Expand Down
2 changes: 1 addition & 1 deletion cli/projectcreate.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func projectCreate() *cobra.Command {
return err
}

_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s The %s project has been created!\n", color.HiBlackString(">"), color.HiCyanString(project.Name))
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s The %s project has been created!\n", caret, color.HiCyanString(project.Name))
_, err = prompt(cmd, &promptui.Prompt{
Label: "Create a new workspace?",
IsConfirm: true,
Expand Down
63 changes: 63 additions & 0 deletions cli/projectlist.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package cli

import (
"fmt"
"text/tabwriter"
"time"

"github.com/fatih/color"
"github.com/spf13/cobra"
)

func projectList() *cobra.Command {
return &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
start := time.Now()
organization, err := currentOrganization(cmd, client)
if err != nil {
return err
}
projects, err := client.Projects(cmd.Context(), organization.Name)
if err != nil {
return err
}

if len(projects) == 0 {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s No projects found in %s! Create one:\n\n", caret, color.HiWhiteString(organization.Name))
_, _ = fmt.Fprintln(cmd.OutOrStdout(), color.HiMagentaString(" $ coder projects create <directory>\n"))
return nil
}

_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Projects found in %s %s\n\n",
caret,
color.HiWhiteString(organization.Name),
color.HiBlackString("[%dms]",
time.Since(start).Milliseconds()))

writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 4, ' ', 0)
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n",
color.HiBlackString("Project"),
color.HiBlackString("Source"),
color.HiBlackString("Last Updated"),
color.HiBlackString("Used By"))
for _, project := range projects {
suffix := ""
if project.WorkspaceOwnerCount != 1 {
suffix = "s"
}
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n",
color.New(color.FgHiCyan).Sprint(project.Name),
color.WhiteString("Archive"),
color.WhiteString(project.UpdatedAt.Format("January 2, 2006")),
color.New(color.FgHiWhite).Sprintf("%d developer%s", project.WorkspaceOwnerCount, suffix))
}
return writer.Flush()
},
}
}
56 changes: 56 additions & 0 deletions cli/projectlist_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package cli_test

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/pty/ptytest"
)

func TestProjectList(t *testing.T) {
t.Parallel()
t.Run("None", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
coderdtest.CreateInitialUser(t, client)
cmd, root := clitest.New(t, "projects", "list")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
closeChan := make(chan struct{})
go func() {
err := cmd.Execute()
require.NoError(t, err)
close(closeChan)
}()
pty.ExpectMatch("No projects found")
<-closeChan
})
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
user := coderdtest.CreateInitialUser(t, client)
daemon := coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
coderdtest.AwaitProjectImportJob(t, client, user.Organization, job.ID)
_ = daemon.Close()
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
cmd, root := clitest.New(t, "projects", "list")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
closeChan := make(chan struct{})
go func() {
err := cmd.Execute()
require.NoError(t, err)
close(closeChan)
}()
pty.ExpectMatch(project.Name)
<-closeChan
})
}
9 changes: 6 additions & 3 deletions cli/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@ func projects() *cobra.Command {
` + color.New(color.FgHiMagenta).Sprint("$ coder projects update <name>"),
}
cmd.AddCommand(projectCreate())
cmd.AddCommand(projectPlan())
cmd.AddCommand(projectUpdate())
cmd.AddCommand(
projectCreate(),
projectList(),
projectPlan(),
projectUpdate(),
)

return cmd
}
Expand Down
4 changes: 4 additions & 0 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import (
"github.com/coder/coder/codersdk"
)

var (
caret = color.HiBlackString(">")
)

const (
varGlobalConfig = "global-config"
varNoOpen = "no-open"
Expand Down
2 changes: 1 addition & 1 deletion cli/workspacecreate.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func workspaceCreate() *cobra.Command {
}
}

_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Previewing project create...\n", color.HiBlackString(">"))
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Previewing project create...\n", caret)

project, err := client.Project(cmd.Context(), organization.Name, args[0])
if err != nil {
Expand Down
80 changes: 72 additions & 8 deletions coderd/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"time"

"github.com/go-chi/render"
"github.com/google/uuid"
Expand All @@ -30,7 +31,16 @@ type CreateParameterValueRequest struct {
// Project is the JSON representation of a Coder project.
// This type matches the database object for now, but is
// abstracted for ease of change later on.
type Project database.Project
type Project struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
OrganizationID string `json:"organization_id"`
Name string `json:"name"`
Provisioner database.ProvisionerType `json:"provisioner"`
ActiveVersionID uuid.UUID `json:"active_version_id"`
WorkspaceOwnerCount uint32 `json:"workspace_owner_count"`
}

// CreateProjectRequest enables callers to create a new Project.
type CreateProjectRequest struct {
Expand Down Expand Up @@ -69,11 +79,22 @@ func (api *api) projects(rw http.ResponseWriter, r *http.Request) {
})
return
}
if projects == nil {
projects = []database.Project{}
projectIDs := make([]uuid.UUID, 0, len(projects))
for _, project := range projects {
projectIDs = append(projectIDs, project.ID)
}
workspaceCounts, err := api.Database.GetWorkspaceOwnerCountsByProjectIDs(r.Context(), projectIDs)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get workspace counts: %s", err.Error()),
})
return
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, projects)
render.JSON(rw, r, convertProjects(projects, workspaceCounts))
}

// Lists all projects in an organization.
Expand All @@ -89,11 +110,22 @@ func (api *api) projectsByOrganization(rw http.ResponseWriter, r *http.Request)
})
return
}
if projects == nil {
projects = []database.Project{}
projectIDs := make([]uuid.UUID, 0, len(projects))
for _, project := range projects {
projectIDs = append(projectIDs, project.ID)
}
workspaceCounts, err := api.Database.GetWorkspaceOwnerCountsByProjectIDs(r.Context(), projectIDs)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get workspace counts: %s", err.Error()),
})
return
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, projects)
render.JSON(rw, r, convertProjects(projects, workspaceCounts))
}

// Create a new project in an organization.
Expand Down Expand Up @@ -162,7 +194,7 @@ func (api *api) postProjectsByOrganization(rw http.ResponseWriter, r *http.Reque
if err != nil {
return xerrors.Errorf("insert project version: %s", err)
}
project = Project(dbProject)
project = convertProject(dbProject, 0)
return nil
})
if err != nil {
Expand Down Expand Up @@ -241,6 +273,38 @@ func (api *api) parametersByProject(rw http.ResponseWriter, r *http.Request) {
render.JSON(rw, r, apiParameterValues)
}

func convertProjects(projects []database.Project, workspaceCounts []database.GetWorkspaceOwnerCountsByProjectIDsRow) []Project {
apiProjects := make([]Project, 0, len(projects))
for _, project := range projects {
found := false
for _, workspaceCount := range workspaceCounts {
if workspaceCount.ProjectID.String() != project.ID.String() {
continue
}
apiProjects = append(apiProjects, convertProject(project, uint32(workspaceCount.Count)))
found = true
break
}
if !found {
apiProjects = append(apiProjects, convertProject(project, uint32(0)))
}
}
return apiProjects
}

func convertProject(project database.Project, workspaceOwnerCount uint32) Project {
return Project{
ID: project.ID,
CreatedAt: project.CreatedAt,
UpdatedAt: project.UpdatedAt,
OrganizationID: project.OrganizationID,
Name: project.Name,
Provisioner: project.Provisioner,
ActiveVersionID: project.ActiveVersionID,
WorkspaceOwnerCount: workspaceOwnerCount,
}
}

func convertParameterValue(parameterValue database.ParameterValue) ParameterValue {
parameterValue.SourceValue = ""
return ParameterValue(parameterValue)
Expand Down
16 changes: 16 additions & 0 deletions coderd/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ func TestProjects(t *testing.T) {
require.NoError(t, err)
require.Len(t, projects, 1)
})

t.Run("ListWorkspaceOwnerCount", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t)
user := coderdtest.CreateInitialUser(t, client)
coderdtest.NewProvisionerDaemon(t, client)
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
coderdtest.AwaitProjectImportJob(t, client, user.Organization, job.ID)
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
_ = coderdtest.CreateWorkspace(t, client, "", project.ID)
_ = coderdtest.CreateWorkspace(t, client, "", project.ID)
projects, err := client.Projects(context.Background(), "")
require.NoError(t, err)
require.Len(t, projects, 1)
require.Equal(t, projects[0].WorkspaceOwnerCount, uint32(1))
})
}

func TestProjectsByOrganization(t *testing.T) {
Expand Down
35 changes: 35 additions & 0 deletions database/databasefake/databasefake.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,41 @@ func (q *fakeQuerier) GetWorkspaceByUserIDAndName(_ context.Context, arg databas
return database.Workspace{}, sql.ErrNoRows
}

func (q *fakeQuerier) GetWorkspaceOwnerCountsByProjectIDs(_ context.Context, projectIDs []uuid.UUID) ([]database.GetWorkspaceOwnerCountsByProjectIDsRow, error) {
counts := map[string]map[string]struct{}{}
for _, projectID := range projectIDs {
found := false
for _, workspace := range q.workspace {
if workspace.ProjectID.String() != projectID.String() {
continue
}
countByOwnerID, ok := counts[projectID.String()]
if !ok {
countByOwnerID = map[string]struct{}{}
}
countByOwnerID[workspace.OwnerID] = struct{}{}
counts[projectID.String()] = countByOwnerID
found = true
break
}
if !found {
counts[projectID.String()] = map[string]struct{}{}
}
}
res := make([]database.GetWorkspaceOwnerCountsByProjectIDsRow, 0)
for key, value := range counts {
uid := uuid.MustParse(key)
res = append(res, database.GetWorkspaceOwnerCountsByProjectIDsRow{
ProjectID: uid,
Count: int64(len(value)),
})
}
if len(res) == 0 {
return nil, sql.ErrNoRows
}
return res, nil
}

func (q *fakeQuerier) GetWorkspaceResourcesByHistoryID(_ context.Context, workspaceHistoryID uuid.UUID) ([]database.WorkspaceResource, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
Expand Down
1 change: 1 addition & 0 deletions database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 67613da

Please sign in to comment.