Skip to content

Commit e91a6fa

Browse files
committed
feat: Add "projects list" command to the CLI
This adds a WorkspaceOwnerCount parameter returned from the projects API. It's helpful to display the amount of usage a specific project has.
1 parent 91bf863 commit e91a6fa

13 files changed

+307
-16
lines changed

cli/login.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ func login() *cobra.Command {
6767
if !isTTY(cmd) {
6868
return xerrors.New("the initial user cannot be created in non-interactive mode. use the API")
6969
}
70-
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Your Coder deployment hasn't been set up!\n", color.HiBlackString(">"))
70+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Your Coder deployment hasn't been set up!\n", caret)
7171

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

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

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

195-
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", color.HiBlackString(">"), color.HiCyanString(resp.Username))
195+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", caret, color.HiCyanString(resp.Username))
196196
return nil
197197
},
198198
}
@@ -212,6 +212,7 @@ func isWSL() (bool, error) {
212212

213213
// openURL opens the provided URL via user's default browser
214214
func openURL(urlToOpen string) error {
215+
return exec.ErrNotFound
215216
var cmd string
216217
var args []string
217218

cli/projectcreate.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ func projectCreate() *cobra.Command {
9292
return err
9393
}
9494

95-
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s The %s project has been created!\n", color.HiBlackString(">"), color.HiCyanString(project.Name))
95+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s The %s project has been created!\n", caret, color.HiCyanString(project.Name))
9696
_, err = prompt(cmd, &promptui.Prompt{
9797
Label: "Create a new workspace?",
9898
IsConfirm: true,

cli/projectlist.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"text/tabwriter"
6+
"time"
7+
8+
"github.com/fatih/color"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
func projectList() *cobra.Command {
13+
return &cobra.Command{
14+
Use: "list",
15+
Aliases: []string{"ls"},
16+
RunE: func(cmd *cobra.Command, args []string) error {
17+
client, err := createClient(cmd)
18+
if err != nil {
19+
return err
20+
}
21+
start := time.Now()
22+
organization, err := currentOrganization(cmd, client)
23+
if err != nil {
24+
return err
25+
}
26+
projects, err := client.Projects(cmd.Context(), organization.Name)
27+
if err != nil {
28+
return err
29+
}
30+
31+
if len(projects) == 0 {
32+
fmt.Fprintf(cmd.OutOrStdout(), "%s No projects found in %s! Create one:\n\n", caret, color.HiWhiteString(organization.Name))
33+
fmt.Fprintf(cmd.OutOrStdout(), color.HiMagentaString(" $ coder projects create <directory>\n\n"))
34+
return nil
35+
}
36+
37+
fmt.Fprintf(cmd.OutOrStdout(), "%s Projects found in %s %s\n\n",
38+
caret,
39+
color.HiWhiteString(organization.Name),
40+
color.HiBlackString("[%dms]",
41+
time.Now().Sub(start).Milliseconds()))
42+
43+
writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 4, ' ', 0)
44+
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n",
45+
color.HiBlackString("Project"),
46+
color.HiBlackString("Source"),
47+
color.HiBlackString("Last Updated"),
48+
color.HiBlackString("Used By"))
49+
for _, project := range projects {
50+
suffix := ""
51+
if project.WorkspaceOwnerCount != 1 {
52+
suffix = "s"
53+
}
54+
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n",
55+
color.New(color.FgHiCyan).Sprint(project.Name),
56+
color.WhiteString("Archive"),
57+
color.WhiteString(project.UpdatedAt.Format("January 2, 2006")),
58+
color.New(color.FgHiWhite).Sprintf("%d developer%s", project.WorkspaceOwnerCount, suffix))
59+
}
60+
writer.Flush()
61+
62+
return nil
63+
},
64+
}
65+
}

cli/projectlist_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package cli_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/coder/coder/cli/clitest"
7+
"github.com/coder/coder/coderd/coderdtest"
8+
"github.com/coder/coder/pty/ptytest"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestProjectList(t *testing.T) {
13+
t.Parallel()
14+
t.Run("None", func(t *testing.T) {
15+
t.Parallel()
16+
client := coderdtest.New(t)
17+
coderdtest.CreateInitialUser(t, client)
18+
cmd, root := clitest.New(t, "projects", "list")
19+
clitest.SetupConfig(t, client, root)
20+
pty := ptytest.New(t)
21+
cmd.SetIn(pty.Input())
22+
cmd.SetOut(pty.Output())
23+
closeChan := make(chan struct{})
24+
go func() {
25+
err := cmd.Execute()
26+
require.NoError(t, err)
27+
close(closeChan)
28+
}()
29+
pty.ExpectMatch("No projects found")
30+
<-closeChan
31+
})
32+
t.Run("List", func(t *testing.T) {
33+
t.Parallel()
34+
client := coderdtest.New(t)
35+
user := coderdtest.CreateInitialUser(t, client)
36+
daemon := coderdtest.NewProvisionerDaemon(t, client)
37+
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
38+
coderdtest.AwaitProjectImportJob(t, client, user.Organization, job.ID)
39+
_ = daemon.Close()
40+
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
41+
cmd, root := clitest.New(t, "projects", "list")
42+
clitest.SetupConfig(t, client, root)
43+
pty := ptytest.New(t)
44+
cmd.SetIn(pty.Input())
45+
cmd.SetOut(pty.Output())
46+
closeChan := make(chan struct{})
47+
go func() {
48+
err := cmd.Execute()
49+
require.NoError(t, err)
50+
close(closeChan)
51+
}()
52+
pty.ExpectMatch(project.Name)
53+
<-closeChan
54+
})
55+
}

cli/projects.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,12 @@ func projects() *cobra.Command {
3030
3131
` + color.New(color.FgHiMagenta).Sprint("$ coder projects update <name>"),
3232
}
33-
cmd.AddCommand(projectCreate())
34-
cmd.AddCommand(projectPlan())
35-
cmd.AddCommand(projectUpdate())
33+
cmd.AddCommand(
34+
projectCreate(),
35+
projectList(),
36+
projectPlan(),
37+
projectUpdate(),
38+
)
3639

3740
return cmd
3841
}

cli/root.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import (
1818
"github.com/coder/coder/codersdk"
1919
)
2020

21+
var (
22+
caret = color.HiBlackString(">")
23+
)
24+
2125
const (
2226
varGlobalConfig = "global-config"
2327
varForceTty = "force-tty"

cli/workspacecreate.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func workspaceCreate() *cobra.Command {
5454
}
5555
}
5656

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

5959
project, err := client.Project(cmd.Context(), organization.Name, args[0])
6060
if err != nil {

coderd/projects.go

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"net/http"
8+
"time"
89

910
"github.com/go-chi/render"
1011
"github.com/google/uuid"
@@ -30,7 +31,16 @@ type CreateParameterValueRequest struct {
3031
// Project is the JSON representation of a Coder project.
3132
// This type matches the database object for now, but is
3233
// abstracted for ease of change later on.
33-
type Project database.Project
34+
type Project struct {
35+
ID uuid.UUID `json:"id"`
36+
CreatedAt time.Time `json:"created_at"`
37+
UpdatedAt time.Time `json:"updated_at"`
38+
OrganizationID string `json:"organization_id"`
39+
Name string `json:"name"`
40+
Provisioner database.ProvisionerType `json:"provisioner"`
41+
ActiveVersionID uuid.UUID `json:"active_version_id"`
42+
WorkspaceOwnerCount uint32 `json:"workspace_owner_count"`
43+
}
3444

3545
// CreateProjectRequest enables callers to create a new Project.
3646
type CreateProjectRequest struct {
@@ -69,11 +79,32 @@ func (api *api) projects(rw http.ResponseWriter, r *http.Request) {
6979
})
7080
return
7181
}
72-
if projects == nil {
73-
projects = []database.Project{}
82+
projectIDs := make([]uuid.UUID, 0, len(projects))
83+
for _, project := range projects {
84+
projectIDs = append(projectIDs, project.ID)
85+
}
86+
workspaceCounts, err := api.Database.GetWorkspaceOwnerCountsByProjectIDs(r.Context(), projectIDs)
87+
if errors.Is(err, sql.ErrNoRows) {
88+
err = nil
89+
}
90+
if err != nil {
91+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
92+
Message: fmt.Sprintf("get workspace counts: %s", err.Error()),
93+
})
94+
return
95+
}
96+
apiProjects := make([]Project, 0, len(projects))
97+
for _, project := range projects {
98+
for _, workspaceCount := range workspaceCounts {
99+
if workspaceCount.ProjectID.String() != project.ID.String() {
100+
continue
101+
}
102+
apiProjects = append(apiProjects, convertProject(project, uint32(workspaceCount.Count)))
103+
break
104+
}
74105
}
75106
render.Status(r, http.StatusOK)
76-
render.JSON(rw, r, projects)
107+
render.JSON(rw, r, apiProjects)
77108
}
78109

79110
// Lists all projects in an organization.
@@ -89,11 +120,32 @@ func (api *api) projectsByOrganization(rw http.ResponseWriter, r *http.Request)
89120
})
90121
return
91122
}
92-
if projects == nil {
93-
projects = []database.Project{}
123+
projectIDs := make([]uuid.UUID, 0, len(projects))
124+
for _, project := range projects {
125+
projectIDs = append(projectIDs, project.ID)
126+
}
127+
workspaceCounts, err := api.Database.GetWorkspaceOwnerCountsByProjectIDs(r.Context(), projectIDs)
128+
if errors.Is(err, sql.ErrNoRows) {
129+
err = nil
130+
}
131+
if err != nil {
132+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
133+
Message: fmt.Sprintf("get workspace counts: %s", err.Error()),
134+
})
135+
return
136+
}
137+
apiProjects := make([]Project, 0, len(projects))
138+
for _, project := range projects {
139+
for _, workspaceCount := range workspaceCounts {
140+
if workspaceCount.ProjectID.String() != project.ID.String() {
141+
continue
142+
}
143+
apiProjects = append(apiProjects, convertProject(project, uint32(workspaceCount.Count)))
144+
break
145+
}
94146
}
95147
render.Status(r, http.StatusOK)
96-
render.JSON(rw, r, projects)
148+
render.JSON(rw, r, apiProjects)
97149
}
98150

99151
// Create a new project in an organization.
@@ -162,7 +214,7 @@ func (api *api) postProjectsByOrganization(rw http.ResponseWriter, r *http.Reque
162214
if err != nil {
163215
return xerrors.Errorf("insert project version: %s", err)
164216
}
165-
project = Project(dbProject)
217+
project = convertProject(dbProject, 0)
166218
return nil
167219
})
168220
if err != nil {
@@ -241,6 +293,19 @@ func (api *api) parametersByProject(rw http.ResponseWriter, r *http.Request) {
241293
render.JSON(rw, r, apiParameterValues)
242294
}
243295

296+
func convertProject(project database.Project, workspaceOwnerCount uint32) Project {
297+
return Project{
298+
ID: project.ID,
299+
CreatedAt: project.CreatedAt,
300+
UpdatedAt: project.UpdatedAt,
301+
OrganizationID: project.OrganizationID,
302+
Name: project.Name,
303+
Provisioner: project.Provisioner,
304+
ActiveVersionID: project.ActiveVersionID,
305+
WorkspaceOwnerCount: workspaceOwnerCount,
306+
}
307+
}
308+
244309
func convertParameterValue(parameterValue database.ParameterValue) ParameterValue {
245310
parameterValue.SourceValue = ""
246311
return ParameterValue(parameterValue)

coderd/projects_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@ func TestProjects(t *testing.T) {
3636
require.NoError(t, err)
3737
require.Len(t, projects, 1)
3838
})
39+
40+
t.Run("ListWorkspaceOwnerCount", func(t *testing.T) {
41+
t.Parallel()
42+
client := coderdtest.New(t)
43+
user := coderdtest.CreateInitialUser(t, client)
44+
coderdtest.NewProvisionerDaemon(t, client)
45+
job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil)
46+
coderdtest.AwaitProjectImportJob(t, client, user.Organization, job.ID)
47+
project := coderdtest.CreateProject(t, client, user.Organization, job.ID)
48+
_ = coderdtest.CreateWorkspace(t, client, "", project.ID)
49+
projects, err := client.Projects(context.Background(), "")
50+
require.NoError(t, err)
51+
require.Len(t, projects, 1)
52+
require.Equal(t, projects[0].WorkspaceOwnerCount, uint32(1))
53+
})
3954
}
4055

4156
func TestProjectsByOrganization(t *testing.T) {

database/databasefake/databasefake.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,41 @@ func (q *fakeQuerier) GetWorkspaceByUserIDAndName(_ context.Context, arg databas
195195
return database.Workspace{}, sql.ErrNoRows
196196
}
197197

198+
func (q *fakeQuerier) GetWorkspaceOwnerCountsByProjectIDs(ctx context.Context, ids []uuid.UUID) ([]database.GetWorkspaceOwnerCountsByProjectIDsRow, error) {
199+
counts := map[string]map[string]struct{}{}
200+
for _, id := range ids {
201+
found := false
202+
for _, workspace := range q.workspace {
203+
if workspace.ProjectID.String() != id.String() {
204+
continue
205+
}
206+
countByOwnerID, ok := counts[id.String()]
207+
if !ok {
208+
countByOwnerID = map[string]struct{}{}
209+
}
210+
countByOwnerID[workspace.OwnerID] = struct{}{}
211+
counts[id.String()] = countByOwnerID
212+
found = true
213+
break
214+
}
215+
if !found {
216+
counts[id.String()] = map[string]struct{}{}
217+
}
218+
}
219+
res := make([]database.GetWorkspaceOwnerCountsByProjectIDsRow, 0)
220+
for key, value := range counts {
221+
uid := uuid.MustParse(key)
222+
res = append(res, database.GetWorkspaceOwnerCountsByProjectIDsRow{
223+
ProjectID: uid,
224+
Count: int64(len(value)),
225+
})
226+
}
227+
if len(res) == 0 {
228+
return nil, sql.ErrNoRows
229+
}
230+
return res, nil
231+
}
232+
198233
func (q *fakeQuerier) GetWorkspaceResourcesByHistoryID(_ context.Context, workspaceHistoryID uuid.UUID) ([]database.WorkspaceResource, error) {
199234
q.mutex.Lock()
200235
defer q.mutex.Unlock()

0 commit comments

Comments
 (0)