Skip to content

feat: Add "projects list" command to the CLI #333

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 21, 2022
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
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)
Copy link
Contributor

Choose a reason for hiding this comment

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

I like factoring out the caret, nice improvement 👍


_, 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"))
Copy link
Contributor

Choose a reason for hiding this comment

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

One thing the caret refactoring made me think about is if we'd also want to have a helper for the prompt string $ - mainly because we may want to show something different on Windows. But not a big deal!

Copy link
Member Author

Choose a reason for hiding this comment

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

Good point!

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")),
Comment on lines +56 to +57
Copy link
Contributor

Choose a reason for hiding this comment

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

We could log issues to track this missing data, if it's not yet available. Might be good starter issues to on-board other developers here!

Copy link
Member Author

Choose a reason for hiding this comment

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

So this is there, but the formatting string makes it look weird here hahaha. This actually outputs the proper dates!

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 {
Comment on lines 31 to +34
Copy link
Contributor

Choose a reason for hiding this comment

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

Neat, looks like some extra fields we can put in the UI table to make it look more interesting now, too!

Copy link
Member Author

Choose a reason for hiding this comment

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

Indeed!

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.

Loading