diff --git a/api/build/list_sender.go b/api/build/list_sender.go new file mode 100644 index 000000000..383bd3e5d --- /dev/null +++ b/api/build/list_sender.go @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: Apache-2.0 + +package build + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/go-vela/server/api" + "github.com/go-vela/server/database" + "github.com/go-vela/server/router/middleware/user" + "github.com/go-vela/server/util" + "github.com/go-vela/types/constants" + "github.com/go-vela/types/library" + "github.com/sirupsen/logrus" +) + +// swagger:operation GET /api/v1/user/builds builds ListBuildsForSender +// +// Get builds from the configured backend +// +// --- +// produces: +// - application/json +// parameters: +// - in: query +// name: event +// description: Filter by build event +// type: string +// enum: +// - comment +// - deployment +// - pull_request +// - push +// - schedule +// - tag +// - in: query +// name: commit +// description: Filter builds based on the commit hash +// type: string +// - in: query +// name: branch +// description: Filter builds by branch +// type: string +// - in: query +// name: status +// description: Filter by build status +// type: string +// enum: +// - canceled +// - error +// - failure +// - killed +// - pending +// - running +// - success +// - in: query +// name: page +// description: The page of results to retrieve +// type: integer +// default: 1 +// - in: query +// name: per_page +// description: How many results per page to return +// type: integer +// maximum: 100 +// default: 10 +// - in: query +// name: before +// description: filter builds created before a certain time +// type: integer +// default: 1 +// - in: query +// name: after +// description: filter builds created after a certain time +// type: integer +// default: 0 +// security: +// - ApiKeyAuth: [] +// responses: +// '200': +// description: Successfully retrieved the builds +// schema: +// type: array +// items: +// "$ref": "#/definitions/Build" +// headers: +// X-Total-Count: +// description: Total number of results +// type: integer +// Link: +// description: see https://tools.ietf.org/html/rfc5988 +// type: string +// '400': +// description: Unable to retrieve the list of builds +// schema: +// "$ref": "#/definitions/Error" +// '500': +// description: Unable to retrieve the list of builds +// schema: +// "$ref": "#/definitions/Error" + +// ListBuildsForSender represents the API handler to capture a +// list of builds for a sender from the configured backend. +func ListBuildsForSender(c *gin.Context) { + // variables that will hold the build list, build list filters and total count + var ( + filters = map[string]interface{}{} + b []*library.Build + t int64 + ) + + // capture middleware values + u := user.Retrieve(c) + ctx := c.Request.Context() + + // update engine logger with API metadata + // + // https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry.WithFields + logrus.WithFields(logrus.Fields{ + "user": u.GetName(), + }).Infof("listing builds for sender %s", u.GetName()) + + // capture the branch name parameter + branch := c.Query("branch") + // capture the event type parameter + event := c.Query("event") + // capture the status type parameter + status := c.Query("status") + // capture the commit hash parameter + commit := c.Query("commit") + + // check if branch filter was provided + if len(branch) > 0 { + // add branch to filters map + filters["branch"] = branch + } + // check if event filter was provided + if len(event) > 0 { + // verify the event provided is a valid event type + if event != constants.EventComment && event != constants.EventDeploy && + event != constants.EventPush && event != constants.EventPull && + event != constants.EventTag && event != constants.EventSchedule { + retErr := fmt.Errorf("unable to process event %s: invalid event type provided", event) + + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + + // add event to filters map + filters["event"] = event + } + // check if status filter was provided + if len(status) > 0 { + // verify the status provided is a valid status type + if status != constants.StatusCanceled && status != constants.StatusError && + status != constants.StatusFailure && status != constants.StatusKilled && + status != constants.StatusPending && status != constants.StatusRunning && + status != constants.StatusSuccess { + retErr := fmt.Errorf("unable to process status %s: invalid status type provided", status) + + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + + // add status to filters map + filters["status"] = status + } + + // check if commit hash filter was provided + if len(commit) > 0 { + // add commit to filters map + filters["commit"] = commit + } + + // capture page query parameter if present + page, err := strconv.Atoi(c.DefaultQuery("page", "1")) + if err != nil { + retErr := fmt.Errorf("unable to convert page query parameter for sender %s: %w", u.GetName(), err) + + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + + // capture per_page query parameter if present + perPage, err := strconv.Atoi(c.DefaultQuery("per_page", "10")) + if err != nil { + retErr := fmt.Errorf("unable to convert per_page query parameter for sender %s: %w", u.GetName(), err) + + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + + // ensure per_page isn't above or below allowed values + perPage = util.MaxInt(1, util.MinInt(100, perPage)) + + // capture before query parameter if present, default to now + before, err := strconv.ParseInt(c.DefaultQuery("before", strconv.FormatInt(time.Now().UTC().Unix(), 10)), 10, 64) + if err != nil { + retErr := fmt.Errorf("unable to convert before query parameter for sender %s: %w", u.GetName(), err) + + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + + // capture after query parameter if present, default to 0 + after, err := strconv.ParseInt(c.DefaultQuery("after", "0"), 10, 64) + if err != nil { + retErr := fmt.Errorf("unable to convert after query parameter for sender %s: %w", u.GetName(), err) + + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + + b, t, err = database.FromContext(c).ListBuildsForSender(ctx, u.GetName(), filters, before, after, page, perPage) + if err != nil { + retErr := fmt.Errorf("unable to list builds for sender %s: %w", u.GetName(), err) + + util.HandleError(c, http.StatusInternalServerError, retErr) + + return + } + + // create pagination object + pagination := api.Pagination{ + Page: page, + PerPage: perPage, + Total: t, + } + // set pagination headers + pagination.SetHeaderLink(c) + + c.JSON(http.StatusOK, b) +} diff --git a/database/build/build_test.go b/database/build/build_test.go index d232aed78..4b889f4f7 100644 --- a/database/build/build_test.go +++ b/database/build/build_test.go @@ -33,6 +33,7 @@ func TestBuild_New(t *testing.T) { _mock.ExpectExec(CreateRepoIDIndex).WillReturnResult(sqlmock.NewResult(1, 1)) _mock.ExpectExec(CreateSourceIndex).WillReturnResult(sqlmock.NewResult(1, 1)) _mock.ExpectExec(CreateStatusIndex).WillReturnResult(sqlmock.NewResult(1, 1)) + _mock.ExpectExec(CreateSenderIndex).WillReturnResult(sqlmock.NewResult(1, 1)) _config := &gorm.Config{SkipDefaultTransaction: true} @@ -129,6 +130,7 @@ func testPostgres(t *testing.T) (*engine, sqlmock.Sqlmock) { _mock.ExpectExec(CreateRepoIDIndex).WillReturnResult(sqlmock.NewResult(1, 1)) _mock.ExpectExec(CreateSourceIndex).WillReturnResult(sqlmock.NewResult(1, 1)) _mock.ExpectExec(CreateStatusIndex).WillReturnResult(sqlmock.NewResult(1, 1)) + _mock.ExpectExec(CreateSenderIndex).WillReturnResult(sqlmock.NewResult(1, 1)) // create the new mock Postgres database client // diff --git a/database/build/count_sender.go b/database/build/count_sender.go new file mode 100644 index 000000000..bbd27703e --- /dev/null +++ b/database/build/count_sender.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +package build + +import ( + "context" + + "github.com/go-vela/types/constants" + "github.com/sirupsen/logrus" +) + +// CountBuildsForSender gets the count of builds by sender from the database. +func (e *engine) CountBuildsForSender(ctx context.Context, sender string, filters map[string]interface{}) (int64, error) { + e.logger.WithFields(logrus.Fields{ + "sender": sender, + }).Tracef("getting count of builds for sender %s from the database", sender) + + // variable to store query results + var b int64 + + // send query to the database and store result in variable + err := e.client. + Table(constants.TableBuild). + Where("sender = ?", sender). + Where(filters). + Count(&b). + Error + + return b, err +} diff --git a/database/build/count_sender_test.go b/database/build/count_sender_test.go new file mode 100644 index 000000000..54e16d563 --- /dev/null +++ b/database/build/count_sender_test.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 + +package build + +import ( + "context" + "reflect" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestBuild_Engine_CountBuildsForSender(t *testing.T) { + // setup types + _buildOne := testBuild() + _buildOne.SetID(1) + _buildOne.SetRepoID(1) + _buildOne.SetNumber(1) + _buildOne.SetDeployPayload(nil) + _buildOne.SetSender("octocat") + + _buildTwo := testBuild() + _buildTwo.SetID(2) + _buildTwo.SetRepoID(1) + _buildTwo.SetNumber(2) + _buildTwo.SetDeployPayload(nil) + _buildTwo.SetSender("octokitty") + + _postgres, _mock := testPostgres(t) + defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() + + // create expected result in mock + _rows := sqlmock.NewRows([]string{"count"}).AddRow(1) + + // ensure the mock expects the query + _mock.ExpectQuery(`SELECT count(*) FROM "builds" WHERE sender = $1`).WithArgs("octocat").WillReturnRows(_rows) + + _sqlite := testSqlite(t) + defer func() { _sql, _ := _sqlite.client.DB(); _sql.Close() }() + + _, err := _sqlite.CreateBuild(context.TODO(), _buildOne) + if err != nil { + t.Errorf("unable to create test build for sqlite: %v", err) + } + + _, err = _sqlite.CreateBuild(context.TODO(), _buildTwo) + if err != nil { + t.Errorf("unable to create test build for sqlite: %v", err) + } + + // setup tests + tests := []struct { + failure bool + name string + database *engine + want int64 + }{ + { + failure: false, + name: "postgres", + database: _postgres, + want: 1, + }, + { + failure: false, + name: "sqlite3", + database: _sqlite, + want: 1, + }, + } + + filters := map[string]interface{}{} + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := test.database.CountBuildsForSender(context.TODO(), "octocat", filters) + + if test.failure { + if err == nil { + t.Errorf("CountBuildsForSender for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("CountBuildsForSender for %s returned err: %v", test.name, err) + } + + if !reflect.DeepEqual(got, test.want) { + t.Errorf("CountBuildsForSender for %s is %v, want %v", test.name, got, test.want) + } + }) + } +} diff --git a/database/build/index.go b/database/build/index.go index f5bea2898..620b941db 100644 --- a/database/build/index.go +++ b/database/build/index.go @@ -39,6 +39,15 @@ CREATE INDEX IF NOT EXISTS builds_status ON builds (status); +` + + // CreateSenderIndex represents a query to create an + // index on the builds table for the sender column. + CreateSenderIndex = ` +CREATE INDEX +IF NOT EXISTS +builds_sender +ON builds (sender); ` ) @@ -64,6 +73,11 @@ func (e *engine) CreateBuildIndexes(ctx context.Context) error { return err } + err = e.client.Exec(CreateStatusIndex).Error + if err != nil { + return err + } + // create the status column index for the builds table - return e.client.Exec(CreateStatusIndex).Error + return e.client.Exec(CreateSenderIndex).Error } diff --git a/database/build/index_test.go b/database/build/index_test.go index 03d02fc8d..6502549eb 100644 --- a/database/build/index_test.go +++ b/database/build/index_test.go @@ -18,6 +18,7 @@ func TestBuild_Engine_CreateBuildIndexes(t *testing.T) { _mock.ExpectExec(CreateRepoIDIndex).WillReturnResult(sqlmock.NewResult(1, 1)) _mock.ExpectExec(CreateSourceIndex).WillReturnResult(sqlmock.NewResult(1, 1)) _mock.ExpectExec(CreateStatusIndex).WillReturnResult(sqlmock.NewResult(1, 1)) + _mock.ExpectExec(CreateSenderIndex).WillReturnResult(sqlmock.NewResult(1, 1)) _sqlite := testSqlite(t) defer func() { _sql, _ := _sqlite.client.DB(); _sql.Close() }() diff --git a/database/build/interface.go b/database/build/interface.go index 3f790ac10..1910b744d 100644 --- a/database/build/interface.go +++ b/database/build/interface.go @@ -36,6 +36,8 @@ type BuildInterface interface { CountBuildsForOrg(context.Context, string, map[string]interface{}) (int64, error) // CountBuildsForRepo defines a function that gets the count of builds by repo ID. CountBuildsForRepo(context.Context, *library.Repo, map[string]interface{}) (int64, error) + // CountBuildsForSender defines a function that gets the count of builds by sender. + CountBuildsForSender(context.Context, string, map[string]interface{}) (int64, error) // CountBuildsForStatus defines a function that gets the count of builds by status. CountBuildsForStatus(context.Context, string, map[string]interface{}) (int64, error) // CreateBuild defines a function that creates a new build. @@ -56,6 +58,8 @@ type BuildInterface interface { ListBuildsForOrg(context.Context, string, map[string]interface{}, int, int) ([]*library.Build, int64, error) // ListBuildsForRepo defines a function that gets a list of builds by repo ID. ListBuildsForRepo(context.Context, *library.Repo, map[string]interface{}, int64, int64, int, int) ([]*library.Build, int64, error) + // ListBuildsForSender defines a function that gets a list of builds by sender. + ListBuildsForSender(context.Context, string, map[string]interface{}, int64, int64, int, int) ([]*library.Build, int64, error) // ListPendingAndRunningBuilds defines a function that gets a list of pending and running builds. ListPendingAndRunningBuilds(context.Context, string) ([]*library.BuildQueue, error) // UpdateBuild defines a function that updates an existing build. diff --git a/database/build/list_sender.go b/database/build/list_sender.go new file mode 100644 index 000000000..ef5e422e5 --- /dev/null +++ b/database/build/list_sender.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 + +package build + +import ( + "context" + + "github.com/go-vela/types/constants" + "github.com/go-vela/types/database" + "github.com/go-vela/types/library" + "github.com/sirupsen/logrus" +) + +// ListBuildsForSender gets a list of builds by sender name from the database. +// +//nolint:lll // ignore long line length due to variable names +func (e *engine) ListBuildsForSender(ctx context.Context, sender string, filters map[string]interface{}, before, after int64, page, perPage int) ([]*library.Build, int64, error) { + e.logger.WithFields(logrus.Fields{ + "sender": sender, + }).Tracef("listing builds for sender %s from the database", sender) + + // variables to store query results and return values + count := int64(0) + b := new([]database.Build) + builds := []*library.Build{} + + // count the results + count, err := e.CountBuildsForSender(ctx, sender, filters) + if err != nil { + return builds, 0, err + } + + // short-circuit if there are no results + if count == 0 { + return builds, 0, nil + } + + // calculate offset for pagination through results + offset := perPage * (page - 1) + + err = e.client. + Table(constants.TableBuild). + Where("sender = ?", sender). + Where("created < ?", before). + Where("created > ?", after). + Where(filters). + Order("number DESC"). + Limit(perPage). + Offset(offset). + Find(&b). + Error + if err != nil { + return nil, count, err + } + + // iterate through all query results + for _, build := range *b { + // https://golang.org/doc/faq#closures_and_goroutines + tmp := build + + // convert query result to library type + // + // https://pkg.go.dev/github.com/go-vela/types/database#Build.ToLibrary + builds = append(builds, tmp.ToLibrary()) + } + + return builds, count, nil +} diff --git a/database/build/list_sender_test.go b/database/build/list_sender_test.go new file mode 100644 index 000000000..3318e1e59 --- /dev/null +++ b/database/build/list_sender_test.go @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 + +package build + +import ( + "context" + "reflect" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-vela/types/library" +) + +func TestBuild_Engine_ListBuildsForSender(t *testing.T) { + // setup types + _buildOne := testBuild() + _buildOne.SetID(1) + _buildOne.SetRepoID(1) + _buildOne.SetNumber(1) + _buildOne.SetDeployPayload(nil) + _buildOne.SetCreated(1) + _buildOne.SetSender("octocat") + + _buildTwo := testBuild() + _buildTwo.SetID(2) + _buildTwo.SetRepoID(1) + _buildTwo.SetNumber(2) + _buildTwo.SetDeployPayload(nil) + _buildTwo.SetCreated(2) + _buildTwo.SetSender("octokitty") + + _buildThree := testBuild() + _buildThree.SetID(3) + _buildThree.SetRepoID(1) + _buildThree.SetNumber(3) + _buildThree.SetDeployPayload(nil) + _buildThree.SetCreated(3) + _buildThree.SetSender("octocat") + + _postgres, _mock := testPostgres(t) + defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() + + // create expected count query result in mock + _rows := sqlmock.NewRows([]string{"count"}).AddRow(2) + + // ensure the mock expects the count query + _mock.ExpectQuery(`SELECT count(*) FROM "builds" WHERE sender = $1`).WithArgs("octocat").WillReturnRows(_rows) + + // create expected query result in mock + _rows = sqlmock.NewRows( + []string{"id", "repo_id", "pipeline_id", "number", "parent", "event", "event_action", "status", "error", "enqueued", "created", "started", "finished", "deploy", "deploy_payload", "clone", "source", "title", "message", "commit", "sender", "author", "email", "link", "branch", "ref", "base_ref", "head_ref", "host", "runtime", "distribution", "timestamp"}). + AddRow(3, 1, nil, 3, 0, "", "", "", "", 0, 3, 0, 0, "", nil, "", "", "", "", "", "octocat", "", "", "", "", "", "", "", "", "", "", 0). + AddRow(1, 1, nil, 1, 0, "", "", "", "", 0, 1, 0, 0, "", nil, "", "", "", "", "", "octocat", "", "", "", "", "", "", "", "", "", "", 0) + + // ensure the mock expects the query + _mock.ExpectQuery(`SELECT * FROM "builds" WHERE sender = $1 AND created < $2 AND created > $3 ORDER BY number DESC LIMIT 10`).WithArgs("octocat", AnyArgument{}, 0).WillReturnRows(_rows) + + _sqlite := testSqlite(t) + defer func() { _sql, _ := _sqlite.client.DB(); _sql.Close() }() + + _, err := _sqlite.CreateBuild(context.TODO(), _buildOne) + if err != nil { + t.Errorf("unable to create test build for sqlite: %v", err) + } + + _, err = _sqlite.CreateBuild(context.TODO(), _buildTwo) + if err != nil { + t.Errorf("unable to create test build for sqlite: %v", err) + } + + _, err = _sqlite.CreateBuild(context.TODO(), _buildThree) + if err != nil { + t.Errorf("unable to create test build for sqlite: %v", err) + } + + // setup tests + tests := []struct { + failure bool + name string + database *engine + want []*library.Build + }{ + { + failure: false, + name: "postgres", + database: _postgres, + want: []*library.Build{_buildThree, _buildOne}, + }, + { + failure: false, + name: "sqlite3", + database: _sqlite, + want: []*library.Build{_buildThree, _buildOne}, + }, + } + + filters := map[string]interface{}{} + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, _, err := test.database.ListBuildsForSender(context.TODO(), "octocat", filters, time.Now().UTC().Unix(), 0, 1, 10) + + if test.failure { + if err == nil { + t.Errorf("ListBuildsForSender for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("ListBuildsForSender for %s returned err: %v", test.name, err) + } + + if !reflect.DeepEqual(got, test.want) { + t.Errorf("ListBuildsForSender for %s is %v, want %v", test.name, got, test.want) + } + }) + } +} diff --git a/database/integration_test.go b/database/integration_test.go index bcf2fcd7f..2e4be8753 100644 --- a/database/integration_test.go +++ b/database/integration_test.go @@ -237,6 +237,16 @@ func testBuilds(t *testing.T, db Interface, resources *Resources) { } methods["CountBuildsForRepo"] = true + // count the builds for sender + count, err = db.CountBuildsForSender(context.TODO(), resources.Builds[0].GetSender(), nil) + if err != nil { + t.Errorf("unable to count builds for sender %s: %v", resources.Builds[0].GetSender(), err) + } + if int(count) != len(resources.Builds) { + t.Errorf("CountBuildsForSender() is %v, want %v", count, len(resources.Builds)) + } + methods["CountBuildsForSender"] = true + // count the builds for a status count, err = db.CountBuildsForStatus(context.TODO(), "running", nil) if err != nil { @@ -296,6 +306,19 @@ func testBuilds(t *testing.T, db Interface, resources *Resources) { } methods["ListBuildsForRepo"] = true + // list the builds for sender + list, count, err = db.ListBuildsForSender(context.TODO(), resources.Builds[0].GetSender(), nil, time.Now().UTC().Unix(), 0, 1, 10) + if err != nil { + t.Errorf("unable to list builds for sender %s: %v", resources.Builds[0].GetSender(), err) + } + if int(count) != len(resources.Builds) { + t.Errorf("ListBuildsForSender() is %v, want %v", count, len(resources.Builds)) + } + if !cmp.Equal(list, []*library.Build{resources.Builds[1], resources.Builds[0]}) { + t.Errorf("ListBuildsForSender() is %v, want %v", list, []*library.Build{resources.Builds[1], resources.Builds[0]}) + } + methods["ListBuildsForSender"] = true + // list the pending and running builds queueList, err := db.ListPendingAndRunningBuilds(context.TODO(), "0") if err != nil { diff --git a/database/resource_test.go b/database/resource_test.go index aaa78548d..1c408309b 100644 --- a/database/resource_test.go +++ b/database/resource_test.go @@ -31,6 +31,7 @@ func TestDatabase_Engine_NewResources(t *testing.T) { _mock.ExpectExec(build.CreateRepoIDIndex).WillReturnResult(sqlmock.NewResult(1, 1)) _mock.ExpectExec(build.CreateSourceIndex).WillReturnResult(sqlmock.NewResult(1, 1)) _mock.ExpectExec(build.CreateStatusIndex).WillReturnResult(sqlmock.NewResult(1, 1)) + _mock.ExpectExec(build.CreateSenderIndex).WillReturnResult(sqlmock.NewResult(1, 1)) // ensure the mock expects the build executable queries _mock.ExpectExec(executable.CreatePostgresTable).WillReturnResult(sqlmock.NewResult(1, 1)) // ensure the mock expects the hook queries diff --git a/router/user.go b/router/user.go index 8f8e68fcc..bb9764626 100644 --- a/router/user.go +++ b/router/user.go @@ -4,6 +4,7 @@ package router import ( "github.com/gin-gonic/gin" + "github.com/go-vela/server/api/build" "github.com/go-vela/server/api/user" "github.com/go-vela/server/router/middleware/perm" ) @@ -19,6 +20,7 @@ import ( // GET /api/v1/user // PUT /api/v1/user // GET /api/v1/user/source/repos +// GET /api/v1/user/builds // POST /api/v1/user/token // DELETE /api/v1/user/token . func UserHandlers(base *gin.RouterGroup) { @@ -38,6 +40,7 @@ func UserHandlers(base *gin.RouterGroup) { _user.GET("", user.GetCurrentUser) _user.PUT("", user.UpdateCurrentUser) _user.GET("/source/repos", user.GetSourceRepos) + _user.GET("/builds", build.ListBuildsForSender) _user.POST("/token", user.CreateToken) _user.DELETE("/token", user.DeleteToken) } // end of user endpoints