Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 67613da

Browse files
authored
feat: Add "projects list" command to the CLI (#333)
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 59ee22d commit 67613da

13 files changed

+311
-16
lines changed

cli/login.go

Lines changed: 3 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
}

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: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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.Fprintln(cmd.OutOrStdout(), color.HiMagentaString(" $ coder projects create <directory>\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.Since(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+
return writer.Flush()
61+
},
62+
}
63+
}

cli/projectlist_test.go

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

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
varNoOpen = "no-open"

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: 72 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,22 @@ 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
7495
}
7596
render.Status(r, http.StatusOK)
76-
render.JSON(rw, r, projects)
97+
render.JSON(rw, r, convertProjects(projects, workspaceCounts))
7798
}
7899

79100
// Lists all projects in an organization.
@@ -89,11 +110,22 @@ func (api *api) projectsByOrganization(rw http.ResponseWriter, r *http.Request)
89110
})
90111
return
91112
}
92-
if projects == nil {
93-
projects = []database.Project{}
113+
projectIDs := make([]uuid.UUID, 0, len(projects))
114+
for _, project := range projects {
115+
projectIDs = append(projectIDs, project.ID)
116+
}
117+
workspaceCounts, err := api.Database.GetWorkspaceOwnerCountsByProjectIDs(r.Context(), projectIDs)
118+
if errors.Is(err, sql.ErrNoRows) {
119+
err = nil
120+
}
121+
if err != nil {
122+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
123+
Message: fmt.Sprintf("get workspace counts: %s", err.Error()),
124+
})
125+
return
94126
}
95127
render.Status(r, http.StatusOK)
96-
render.JSON(rw, r, projects)
128+
render.JSON(rw, r, convertProjects(projects, workspaceCounts))
97129
}
98130

99131
// Create a new project in an organization.
@@ -162,7 +194,7 @@ func (api *api) postProjectsByOrganization(rw http.ResponseWriter, r *http.Reque
162194
if err != nil {
163195
return xerrors.Errorf("insert project version: %s", err)
164196
}
165-
project = Project(dbProject)
197+
project = convertProject(dbProject, 0)
166198
return nil
167199
})
168200
if err != nil {
@@ -241,6 +273,38 @@ func (api *api) parametersByProject(rw http.ResponseWriter, r *http.Request) {
241273
render.JSON(rw, r, apiParameterValues)
242274
}
243275

276+
func convertProjects(projects []database.Project, workspaceCounts []database.GetWorkspaceOwnerCountsByProjectIDsRow) []Project {
277+
apiProjects := make([]Project, 0, len(projects))
278+
for _, project := range projects {
279+
found := false
280+
for _, workspaceCount := range workspaceCounts {
281+
if workspaceCount.ProjectID.String() != project.ID.String() {
282+
continue
283+
}
284+
apiProjects = append(apiProjects, convertProject(project, uint32(workspaceCount.Count)))
285+
found = true
286+
break
287+
}
288+
if !found {
289+
apiProjects = append(apiProjects, convertProject(project, uint32(0)))
290+
}
291+
}
292+
return apiProjects
293+
}
294+
295+
func convertProject(project database.Project, workspaceOwnerCount uint32) Project {
296+
return Project{
297+
ID: project.ID,
298+
CreatedAt: project.CreatedAt,
299+
UpdatedAt: project.UpdatedAt,
300+
OrganizationID: project.OrganizationID,
301+
Name: project.Name,
302+
Provisioner: project.Provisioner,
303+
ActiveVersionID: project.ActiveVersionID,
304+
WorkspaceOwnerCount: workspaceOwnerCount,
305+
}
306+
}
307+
244308
func convertParameterValue(parameterValue database.ParameterValue) ParameterValue {
245309
parameterValue.SourceValue = ""
246310
return ParameterValue(parameterValue)

coderd/projects_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,22 @@ 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+
_ = coderdtest.CreateWorkspace(t, client, "", project.ID)
50+
projects, err := client.Projects(context.Background(), "")
51+
require.NoError(t, err)
52+
require.Len(t, projects, 1)
53+
require.Equal(t, projects[0].WorkspaceOwnerCount, uint32(1))
54+
})
3955
}
4056

4157
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(_ context.Context, projectIDs []uuid.UUID) ([]database.GetWorkspaceOwnerCountsByProjectIDsRow, error) {
199+
counts := map[string]map[string]struct{}{}
200+
for _, projectID := range projectIDs {
201+
found := false
202+
for _, workspace := range q.workspace {
203+
if workspace.ProjectID.String() != projectID.String() {
204+
continue
205+
}
206+
countByOwnerID, ok := counts[projectID.String()]
207+
if !ok {
208+
countByOwnerID = map[string]struct{}{}
209+
}
210+
countByOwnerID[workspace.OwnerID] = struct{}{}
211+
counts[projectID.String()] = countByOwnerID
212+
found = true
213+
break
214+
}
215+
if !found {
216+
counts[projectID.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()

database/querier.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)