-
Notifications
You must be signed in to change notification settings - Fork 890
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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")) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One thing the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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! There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
}, | ||
} | ||
} |
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 | ||
}) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,6 +5,7 @@ import ( | |
"errors" | ||
"fmt" | ||
"net/http" | ||
"time" | ||
|
||
"github.com/go-chi/render" | ||
"github.com/google/uuid" | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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! There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
@@ -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. | ||
|
@@ -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. | ||
|
@@ -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 { | ||
|
@@ -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) | ||
|
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
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 👍