diff --git a/runatlantis.io/docs/api-endpoints.md b/runatlantis.io/docs/api-endpoints.md index ce622979d..91b55a2dc 100644 --- a/runatlantis.io/docs/api-endpoints.md +++ b/runatlantis.io/docs/api-endpoints.md @@ -161,6 +161,38 @@ curl --request POST 'https:///api/apply' \ The endpoints listed in this section are non-destructive and therefore don't require authentication nor special secret token. +### GET /api/locks + +#### Description + +List the currently held project locks. + +#### Sample Request + +```shell +curl --request GET 'https:///api/locks' +``` + +#### Sample Response + +```json +{ + "Locks": [ + { + "Name": "lock-id", + "ProjectName": "terraform", + "ProjectRepo": "owner/repo", + "ProjectRepoPath": "/path", + "PullID": "123", + "PullURL": "url", + "User": "jdoe", + "Workspace": "default", + "Time": "2025-02-13T16:47:42.040856-08:00" + } + ] +} +``` + ### GET /status #### Description diff --git a/server/controllers/api_controller.go b/server/controllers/api_controller.go index 0694e72b2..1103802f1 100644 --- a/server/controllers/api_controller.go +++ b/server/controllers/api_controller.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "strings" + "time" "github.com/go-playground/validator/v10" "github.com/runatlantis/atlantis/server/core/locking" @@ -159,6 +160,55 @@ func (a *APIController) Apply(w http.ResponseWriter, r *http.Request) { a.respond(w, logging.Warn, code, "%s", string(response)) } +type LockDetail struct { + Name string + ProjectName string + ProjectRepo string + ProjectRepoPath string + PullID int `json:",string"` + PullURL string + User string + Workspace string + Time time.Time +} + +type ListLocksResult struct { + Locks []LockDetail +} + +func (a *APIController) ListLocks(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + locks, err := a.Locker.List() + if err != nil { + a.apiReportError(w, http.StatusInternalServerError, err) + return + } + + result := ListLocksResult{} + for name, lock := range locks { + lockDetail := LockDetail{ + name, + lock.Project.ProjectName, + lock.Project.RepoFullName, + lock.Project.Path, + lock.Pull.Num, + lock.Pull.URL, + lock.User.Username, + lock.Workspace, + lock.Time, + } + result.Locks = append(result.Locks, lockDetail) + } + + response, err := json.Marshal(result) + if err != nil { + a.apiReportError(w, http.StatusInternalServerError, err) + return + } + a.respond(w, logging.Warn, http.StatusOK, "%s", string(response)) +} + func (a *APIController) apiSetup(ctx *command.Context) error { pull := ctx.Pull baseRepo := ctx.Pull.BaseRepo diff --git a/server/controllers/api_controller_test.go b/server/controllers/api_controller_test.go index 02a35dbcb..96b85b72f 100644 --- a/server/controllers/api_controller_test.go +++ b/server/controllers/api_controller_test.go @@ -3,9 +3,11 @@ package controllers_test import ( "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" "testing" + "time" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/controllers" @@ -198,6 +200,62 @@ func TestAPIController_Apply(t *testing.T) { projectCommandRunner.VerifyWasCalled(Times(expectedCalls)).Apply(Any[command.ProjectContext]()) } +func TestAPIController_ListLocks(t *testing.T) { + ac, _, _ := setup(t) + time := time.Now() + expected := controllers.ListLocksResult{[]controllers.LockDetail{ + { + Name: "lock-id", + ProjectName: "terraform", + ProjectRepo: "owner/repo", + ProjectRepoPath: "/path", + PullID: 123, + PullURL: "url", + User: "jdoe", + Workspace: "default", + Time: time, + }, + }, + } + mockLock := models.ProjectLock{ + Project: models.Project{ProjectName: "terraform", RepoFullName: "owner/repo", Path: "/path"}, + Pull: models.PullRequest{Num: 123, URL: "url", Author: "lkysow"}, + User: models.User{Username: "jdoe"}, + Workspace: "default", + Time: time, + } + mockLocks := map[string]models.ProjectLock{ + "lock-id": mockLock, + } + When(ac.Locker.List()).ThenReturn(mockLocks, nil) + + req, _ := http.NewRequest("GET", "", nil) + w := httptest.NewRecorder() + ac.ListLocks(w, req) + response, _ := io.ReadAll(w.Result().Body) + var result controllers.ListLocksResult + err := json.Unmarshal(response, &result) + Ok(t, err) + Equals(t, expected, result) +} + +func TestAPIController_ListLocksEmpty(t *testing.T) { + ac, _, _ := setup(t) + + expected := controllers.ListLocksResult{} + mockLocks := map[string]models.ProjectLock{} + When(ac.Locker.List()).ThenReturn(mockLocks, nil) + + req, _ := http.NewRequest("GET", "", nil) + w := httptest.NewRecorder() + ac.ListLocks(w, req) + response, _ := io.ReadAll(w.Result().Body) + var result controllers.ListLocksResult + err := json.Unmarshal(response, &result) + Ok(t, err) + Equals(t, expected, result) +} + func setup(t *testing.T) (controllers.APIController, *MockProjectCommandBuilder, *MockProjectCommandRunner) { RegisterMockTestingT(t) locker := NewMockLocker() diff --git a/server/server.go b/server/server.go index 0b244aed8..ebc520f85 100644 --- a/server/server.go +++ b/server/server.go @@ -1038,6 +1038,7 @@ func (s *Server) Start() error { s.Router.HandleFunc("/events", s.VCSEventsController.Post).Methods("POST") s.Router.HandleFunc("/api/plan", s.APIController.Plan).Methods("POST") s.Router.HandleFunc("/api/apply", s.APIController.Apply).Methods("POST") + s.Router.HandleFunc("/api/locks", s.APIController.ListLocks).Methods("GET") s.Router.HandleFunc("/github-app/exchange-code", s.GithubAppController.ExchangeCode).Methods("GET") s.Router.HandleFunc("/github-app/setup", s.GithubAppController.New).Methods("GET") s.Router.HandleFunc("/locks", s.LocksController.DeleteLock).Methods("DELETE").Queries("id", "{id:.*}")