diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..aa3ff10 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Contributing to gitlab.nvim + +Thank you for taking time to contribute to this plugin! Please follow these steps when creating a feature. + +1. If the functionality you want is not a bug fix, please create a "feature request" issue first + +It's possible that the feature you want is already implemented, or does not belong in `gitlab.nvim` at all. By creating an issue first you can have a conversation with the maintainers about the functionality first. While this is not strictly necessary, it greatly increases the likelihood that your merge request will be accepted. + +2. Fork the repository, and create a new feature branch for your desired functionality. Make your changes. + +If you are using Lazy as a plugin manager, the easiest way to work on changes is by setting a specific path for the plugin that points to your repository locally. This is what I do: + +```lua +{ + "harrisoncramer/gitlab.nvim", + dependencies = { + "MunifTanjim/nui.nvim", + "nvim-lua/plenary.nvim", + }, + build = function() + require("gitlab.server").build() + end, + dir = "~/.path/to/your-closed-version", -- Pass in the path to your cloned repository + config = function() + require("gitlab").setup({}) + end, +} +``` + +If you are making changes to the Go codebase, don't forget to run `make compile` in the root of the project to rebuild the binary! + +3. Apply formatters and linters to your changes + +For changes to the Go codbase: We use gofmt to check formatting and golangci-lint to check linting. Run these commands in the root of the repository: + +```bash +$ stylua . +$ luacheck --globals vim busted --no-max-line-length -- . +``` + +For changes to the Lua codebase: We use stylua for formatting and luacheck for linting. Run these commands in the root of the repository: + +```bash +$ go fmt ./... +$ golangci-lint run +``` + +4. Make the merge request to the `main` branch of `.gitlab.nvim` + +Please provide a description of the feature, and links to any relevant issues. + +That's it! I'll try to respond to any incoming merge request in a few days. Once we've reviewed it and it's been merged into main, the pipeline will detect whether we're merging in a patch, minor, or major change, and create a new tag (e.g. 1.0.12) and release. diff --git a/README.md b/README.md index f68f509..c47d97f 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ https://github.com/harrisoncramer/gitlab.nvim/assets/32515581/dc5c07de-4ae6-4335 - [MR Approvals](#mr-approvals) - [Pipelines](#pipelines) - [Reviewers and Assignees](#reviewers-and-assignees) + - [Restarting or Shutting down](#restarting-or-shutting-down) - [Keybindings](#keybindings) - [Troubleshooting](#troubleshooting) @@ -324,6 +325,27 @@ require("dressing").setup({ }) ``` +### Restarting or Shutting Down + +The `gitlab.nvim` server will shut down automatically when you exit Neovim. However, if you would like to manage this yourself (for instance, restart the server when you check out a new branch) you may do so via the `restart` command, or `shutdown` commands, which +both accept callbacks. + +```lua +require("gitlab.server").restart() +``` + +For instance you could set up the following keybinding to close and reopen the reviewer when checking out a new branch: + +```lua +local gitlab = require("gitlab") +vim.keymap.set("n", "glB", function () + require("gitlab.server").restart(function () + vim.cmd.tabclose() + gitlab.review() -- Reopen the reviewer after the server restarts + end) +end) +``` + ## Keybindings The plugin does not set up any keybindings outside of the special buffers it creates, @@ -333,6 +355,7 @@ as `gl` does not have a special meaning in normal mode): ```lua local gitlab = require("gitlab") +local gitlab_server = require("gitlab.server") vim.keymap.set("n", "glr", gitlab.review) vim.keymap.set("n", "gls", gitlab.summary) vim.keymap.set("n", "glA", gitlab.approve) diff --git a/cmd/approve.go b/cmd/approve.go index a589ea4..54a9d08 100644 --- a/cmd/approve.go +++ b/cmd/approve.go @@ -2,36 +2,38 @@ package main import ( "encoding/json" - "errors" "net/http" ) -func ApproveHandler(w http.ResponseWriter, r *http.Request) { +/* approveHandler approves a merge request. */ +func (a *api) approveHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - c := r.Context().Value("client").(Client) - if r.Method != http.MethodPost { - w.Header().Set("Allow", http.MethodPost) - c.handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) + w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) + handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) return } - _, res, err := c.git.MergeRequestApprovals.ApproveMergeRequest(c.projectId, c.mergeId, nil, nil) + _, res, err := a.client.ApproveMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, nil, nil) if err != nil { - c.handleError(w, err, "Could not approve MR", http.StatusBadRequest) + handleError(w, err, "Could not approve merge request", http.StatusInternalServerError) return } - /* TODO: Check for non-200 status codes */ - w.WriteHeader(res.StatusCode) + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/approve"}, "Could not approve merge request", res.StatusCode) + return + } + + w.WriteHeader(http.StatusOK) response := SuccessResponse{ - Message: "Success! Approved MR.", + Message: "Approved MR", Status: http.StatusOK, } err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } diff --git a/cmd/approve_test.go b/cmd/approve_test.go new file mode 100644 index 0000000..ec13bc3 --- /dev/null +++ b/cmd/approve_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "errors" + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +func approveMergeRequest(pid interface{}, mr int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) { + return &gitlab.MergeRequestApprovals{}, makeResponse(http.StatusOK), nil +} + +func approveMergeRequestNon200(pid interface{}, mr int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) { + return &gitlab.MergeRequestApprovals{}, makeResponse(http.StatusSeeOther), nil +} + +func approveMergeRequestErr(pid interface{}, mr int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) { + return &gitlab.MergeRequestApprovals{}, nil, errors.New("Some error from Gitlab") +} + +func TestApproveHandler(t *testing.T) { + t.Run("Approves merge request", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/approve", nil) + server, _ := createRouterAndApi(fakeClient{approveMergeRequest: approveMergeRequest}) + data := serveRequest(t, server, request, SuccessResponse{}) + assert(t, data.Message, "Approved MR") + assert(t, data.Status, http.StatusOK) + }) + + t.Run("Disallows non-POST method", func(t *testing.T) { + request := makeRequest(t, http.MethodPut, "/approve", nil) + server, _ := createRouterAndApi(fakeClient{approveMergeRequest: approveMergeRequest}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkBadMethod(t, *data, http.MethodPost) + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/approve", nil) + server, _ := createRouterAndApi(fakeClient{approveMergeRequest: approveMergeRequestErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not approve merge request") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/approve", nil) + server, _ := createRouterAndApi(fakeClient{approveMergeRequest: approveMergeRequestNon200}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not approve merge request", "/approve") + }) +} diff --git a/cmd/assignee.go b/cmd/assignee.go index 5f9d6da..d74bb9b 100644 --- a/cmd/assignee.go +++ b/cmd/assignee.go @@ -22,13 +22,18 @@ type AssigneesRequestResponse struct { Assignees []int `json:"assignees"` } -func AssigneesHandler(w http.ResponseWriter, r *http.Request) { - c := r.Context().Value("client").(Client) +/* assigneesHandler adds or removes assignees from a merge request. */ +func (a *api) assigneesHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPut { + w.Header().Set("Access-Control-Allow-Methods", http.MethodPut) + handleError(w, InvalidRequestError{}, "Expected PUT", http.StatusMethodNotAllowed) + return + } body, err := io.ReadAll(r.Body) if err != nil { - c.handleError(w, err, "Could not read request body", http.StatusBadRequest) + handleError(w, err, "Could not read request body", http.StatusBadRequest) return } @@ -37,26 +42,25 @@ func AssigneesHandler(w http.ResponseWriter, r *http.Request) { err = json.Unmarshal(body, &assigneeUpdateRequest) if err != nil { - c.handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) + handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) return } - mr, res, err := c.git.MergeRequests.UpdateMergeRequest(c.projectId, c.mergeId, &gitlab.UpdateMergeRequestOptions{ + mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{ AssigneeIDs: &assigneeUpdateRequest.Ids, }) if err != nil { - c.handleError(w, err, "Could not modify merge request assignees", http.StatusBadRequest) + handleError(w, err, "Could not modify merge request assignees", http.StatusInternalServerError) return } - if res.StatusCode != http.StatusOK { - c.handleError(w, err, "Could not modify merge request assignees", http.StatusBadRequest) + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/mr/assignee"}, "Could not modify merge request assignees", res.StatusCode) return } w.WriteHeader(http.StatusOK) - response := AssigneeUpdateResponse{ SuccessResponse: SuccessResponse{ Message: "Assignees updated", @@ -67,6 +71,6 @@ func AssigneesHandler(w http.ResponseWriter, r *http.Request) { err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } diff --git a/cmd/assignee_test.go b/cmd/assignee_test.go new file mode 100644 index 0000000..08bd5d1 --- /dev/null +++ b/cmd/assignee_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "errors" + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +func updateAssignees(pid interface{}, mergeRequest int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + return &gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil +} + +func updateAssigneesNon200(pid interface{}, mergeRequest int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + return nil, makeResponse(http.StatusSeeOther), nil +} + +func updateAssigneesErr(pid interface{}, mergeRequest int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + return nil, nil, errors.New("Some error from Gitlab") +} + +func TestAssigneeHandler(t *testing.T) { + t.Run("Updates assignees", func(t *testing.T) { + request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}}) + server, _ := createRouterAndApi(fakeClient{updateMergeRequestFn: updateAssignees}) + data := serveRequest(t, server, request, AssigneeUpdateResponse{}) + assert(t, data.SuccessResponse.Message, "Assignees updated") + assert(t, data.SuccessResponse.Status, http.StatusOK) + }) + + t.Run("Disallows non-PUT method", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/assignee", nil) + server, _ := createRouterAndApi(fakeClient{updateMergeRequestFn: updateAssignees}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Status, http.StatusMethodNotAllowed) + assert(t, data.Details, "Invalid request type") + assert(t, data.Message, "Expected PUT") + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}}) + server, _ := createRouterAndApi(fakeClient{updateMergeRequestFn: updateAssigneesErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Status, http.StatusInternalServerError) + assert(t, data.Message, "Could not modify merge request assignees") + assert(t, data.Details, "Some error from Gitlab") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}}) + server, _ := createRouterAndApi(fakeClient{updateMergeRequestFn: updateAssigneesNon200}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Status, http.StatusSeeOther) + assert(t, data.Message, "Could not modify merge request assignees") + assert(t, data.Details, "An error occurred on the /mr/assignee endpoint") + }) +} diff --git a/cmd/attachment.go b/cmd/attachment.go index e049a2b..556f533 100644 --- a/cmd/attachment.go +++ b/cmd/attachment.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "encoding/json" "fmt" "io" @@ -8,6 +9,10 @@ import ( "os" ) +type FileReader interface { + ReadFile(path string) (io.Reader, error) +} + type AttachmentRequest struct { FilePath string `json:"file_path"` FileName string `json:"file_name"` @@ -20,18 +25,40 @@ type AttachmentResponse struct { Url string `json:"url"` } -func AttachmentHandler(w http.ResponseWriter, r *http.Request) { +type attachmentReader struct{} + +func (ar attachmentReader) ReadFile(path string) (io.Reader, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + + data, err := io.ReadAll(file) + if err != nil { + return nil, err + } + + defer file.Close() + + reader := bytes.NewReader(data) + + return reader, nil +} + +/* attachmentHandler uploads an attachment (file, image, etc) to Gitlab and returns metadata about the upload. */ +func (a *api) attachmentHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { - w.WriteHeader(http.StatusMethodNotAllowed) + w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) + handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) return } - c := r.Context().Value("client").(Client) - w.Header().Set("Content-Type", "application/json") var attachmentRequest AttachmentRequest + body, err := io.ReadAll(r.Body) if err != nil { - c.handleError(w, err, "Could not read request body", http.StatusBadRequest) + handleError(w, err, "Could not read request body", http.StatusBadRequest) return } @@ -39,21 +66,23 @@ func AttachmentHandler(w http.ResponseWriter, r *http.Request) { err = json.Unmarshal(body, &attachmentRequest) if err != nil { - c.handleError(w, err, "Could not unmarshal JSON", http.StatusBadRequest) + handleError(w, err, "Could not unmarshal JSON", http.StatusBadRequest) return } - file, err := os.Open(attachmentRequest.FilePath) + file, err := a.fileReader.ReadFile(attachmentRequest.FileName) if err != nil { - c.handleError(w, err, fmt.Sprintf("Could not read %s", attachmentRequest.FilePath), http.StatusBadRequest) + handleError(w, err, fmt.Sprintf("Could not read %s file", attachmentRequest.FileName), http.StatusInternalServerError) + } + + projectFile, res, err := a.client.UploadFile(a.projectInfo.ProjectId, file, attachmentRequest.FileName) + if err != nil { + handleError(w, err, fmt.Sprintf("Could not upload %s to Gitlab", attachmentRequest.FileName), http.StatusInternalServerError) return } - defer file.Close() - - projectFile, res, err := c.git.Projects.UploadFile(c.projectId, file, attachmentRequest.FileName) - if err != nil { - c.handleError(w, err, fmt.Sprintf("Could not upload %s to Gitlab", attachmentRequest.FilePath), res.StatusCode) + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/mr/attachment"}, fmt.Sprintf("Could not upload %s to Gitlab", attachmentRequest.FileName), res.StatusCode) return } @@ -69,6 +98,6 @@ func AttachmentHandler(w http.ResponseWriter, r *http.Request) { err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } diff --git a/cmd/attachment_test.go b/cmd/attachment_test.go new file mode 100644 index 0000000..efce0b5 --- /dev/null +++ b/cmd/attachment_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "bytes" + "errors" + "io" + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +type MockAttachmentReader struct{} + +func (mf MockAttachmentReader) ReadFile(path string) (io.Reader, error) { + return bytes.NewReader([]byte{}), nil +} + +func uploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) { + return &gitlab.ProjectFile{}, makeResponse(http.StatusOK), nil +} + +func uploadFileNon200(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) { + return &gitlab.ProjectFile{}, makeResponse(http.StatusSeeOther), nil +} + +func uploadFileErr(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) { + return nil, nil, errors.New("Some error from Gitlab") +} + +func withMockFileReader(a *api) error { + reader := MockAttachmentReader{} + a.fileReader = reader + return nil +} + +func TestAttachmentHandler(t *testing.T) { + t.Run("Returns 200-status response after upload", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/attachment", AttachmentRequest{FilePath: "some_file_path", FileName: "some_file_name"}) + router, _ := createRouterAndApi(fakeClient{uploadFile: uploadFile}, withMockFileReader) + data := serveRequest(t, router, request, AttachmentResponse{}) + assert(t, data.SuccessResponse.Status, http.StatusOK) + assert(t, data.SuccessResponse.Message, "File uploaded successfully") + }) + + t.Run("Disallows non-POST method", func(t *testing.T) { + request := makeRequest(t, http.MethodPut, "/mr/attachment", AttachmentRequest{FilePath: "some_file_path", FileName: "some_file_name"}) + router, _ := createRouterAndApi(fakeClient{uploadFile: uploadFile}, withMockFileReader) + data := serveRequest(t, router, request, ErrorResponse{}) + checkBadMethod(t, *data, http.MethodPost) + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/attachment", AttachmentRequest{FilePath: "some_file_path", FileName: "some_file_name"}) + router, _ := createRouterAndApi(fakeClient{uploadFile: uploadFileErr}, withMockFileReader) + data := serveRequest(t, router, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not upload some_file_name to Gitlab") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/attachment", AttachmentRequest{FilePath: "some_file_path", FileName: "some_file_name"}) + router, _ := createRouterAndApi(fakeClient{uploadFile: uploadFileNon200}, withMockFileReader) + data := serveRequest(t, router, request, ErrorResponse{}) + checkNon200(t, *data, "Could not upload some_file_name to Gitlab", "/mr/attachment") + }) +} diff --git a/cmd/client.go b/cmd/client.go index 09de10a..60eff6d 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -14,34 +14,42 @@ import ( "github.com/xanzy/go-gitlab" ) -type Client struct { - projectId string - mergeId int - gitlabInstance string - authToken string - git *gitlab.Client -} - type DebugSettings struct { GoRequest bool `json:"go_request"` GoResponse bool `json:"go_response"` } -/* This will parse and validate the project settings and then initialize the Gitlab client */ -func (c *Client) initGitlabClient() error { +type ProjectInfo struct { + ProjectId string + MergeId int +} + +/* The Client struct embeds all the methods from Gitlab for the different services */ +type Client struct { + *gitlab.MergeRequestsService + *gitlab.MergeRequestApprovalsService + *gitlab.DiscussionsService + *gitlab.ProjectsService + *gitlab.ProjectMembersService + *gitlab.JobsService + *gitlab.PipelinesService +} + +/* initGitlabClient parses and validates the project settings and initializes the Gitlab client. */ +func initGitlabClient() (error, *Client) { if len(os.Args) < 6 { - return errors.New("Must provide gitlab url, port, auth token, debug settings, and log path") + return errors.New("Must provide gitlab url, port, auth token, debug settings, and log path"), nil } gitlabInstance := os.Args[1] if gitlabInstance == "" { - return errors.New("GitLab instance URL cannot be empty") + return errors.New("GitLab instance URL cannot be empty"), nil } authToken := os.Args[3] if authToken == "" { - return errors.New("Auth token cannot be empty") + return errors.New("Auth token cannot be empty"), nil } /* Parse debug settings and initialize logger handlers */ @@ -49,7 +57,7 @@ func (c *Client) initGitlabClient() error { var debugObject DebugSettings err := json.Unmarshal([]byte(debugSettings), &debugObject) if err != nil { - return fmt.Errorf("Could not parse debug settings: %w, %s", err, debugSettings) + return fmt.Errorf("Could not parse debug settings: %w, %s", err, debugSettings), nil } var apiCustUrl = fmt.Sprintf(gitlabInstance + "/api/v4") @@ -66,65 +74,70 @@ func (c *Client) initGitlabClient() error { gitlabOptions = append(gitlabOptions, gitlab.WithResponseLogHook(responseLogger)) } - git, err := gitlab.NewClient(authToken, gitlabOptions...) + client, err := gitlab.NewClient(authToken, gitlabOptions...) if err != nil { - return fmt.Errorf("Failed to create client: %v", err) + return fmt.Errorf("Failed to create client: %v", err), nil } - c.gitlabInstance = gitlabInstance - c.authToken = authToken - c.git = git - - return nil + return nil, &Client{ + MergeRequestsService: client.MergeRequests, + MergeRequestApprovalsService: client.MergeRequestApprovals, + DiscussionsService: client.Discussions, + ProjectsService: client.Projects, + ProjectMembersService: client.ProjectMembers, + JobsService: client.Jobs, + } } -/* This will fetch the project ID and merge request ID using the client */ -func (c *Client) initProjectSettings(g GitProjectInfo) error { +/* initProjectSettings fetch the project ID and merge request ID using the client. */ +func initProjectSettings(c *Client, gitInfo GitProjectInfo) (error, *ProjectInfo) { opt := gitlab.GetProjectOptions{} - project, _, err := c.git.Projects.GetProject(g.projectPath(), &opt) + project, _, err := c.GetProject(gitInfo.projectPath(), &opt) if err != nil { - return fmt.Errorf(fmt.Sprintf("Error getting project at %s", g.RemoteUrl), err) + return fmt.Errorf(fmt.Sprintf("Error getting project at %s", gitInfo.RemoteUrl), err), nil } if project == nil { - return fmt.Errorf(fmt.Sprintf("Could not find project at %s", g.RemoteUrl), err) + return fmt.Errorf(fmt.Sprintf("Could not find project at %s", gitInfo.RemoteUrl), err), nil } if project == nil { - return fmt.Errorf("No projects you are a member of contained remote URL %s", g.RemoteUrl) + return fmt.Errorf("No projects you are a member of contained remote URL %s", gitInfo.RemoteUrl), nil } - c.projectId = fmt.Sprint(project.ID) + projectId := fmt.Sprint(project.ID) options := gitlab.ListProjectMergeRequestsOptions{ Scope: gitlab.String("all"), State: gitlab.String("opened"), - SourceBranch: &g.BranchName, + SourceBranch: &gitInfo.BranchName, } - mergeRequests, _, err := c.git.MergeRequests.ListProjectMergeRequests(c.projectId, &options) + mergeRequests, _, err := c.ListProjectMergeRequests(projectId, &options) if err != nil { - return fmt.Errorf("Failed to list merge requests: %w", err) + return fmt.Errorf("Failed to list merge requests: %w", err), nil } if len(mergeRequests) == 0 { - return errors.New("No merge requests found") + return errors.New("No merge requests found"), nil } mergeId := strconv.Itoa(mergeRequests[0].IID) mergeIdInt, err := strconv.Atoi(mergeId) if err != nil { - return err + return err, nil } - c.mergeId = mergeIdInt - - return nil + return nil, &ProjectInfo{ + MergeId: mergeIdInt, + ProjectId: projectId, + } } -func (c *Client) handleError(w http.ResponseWriter, err error, message string, status int) { +/* handleError is a utililty handler that returns errors to the client along with their statuses and messages */ +func handleError(w http.ResponseWriter, err error, message string, status int) { w.WriteHeader(status) response := ErrorResponse{ Message: message, @@ -134,7 +147,7 @@ func (c *Client) handleError(w http.ResponseWriter, err error, message string, s err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode error response", http.StatusInternalServerError) } } diff --git a/cmd/comment.go b/cmd/comment.go index 46a58dd..00446fe 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -3,7 +3,6 @@ package main import ( "crypto/sha1" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -23,14 +22,13 @@ type PostCommentRequest struct { LineRange *LineRange `json:"line_range,omitempty"` } -// LineRange represents the range of a note. +/* LineRange represents the range of a note. */ type LineRange struct { StartRange *LinePosition `json:"start"` EndRange *LinePosition `json:"end"` } -// LinePosition represents a position in a line range. -// unlike gitlab struct this does not contain LineCode with sha1 of filename +/* LinePosition represents a position in a line range. Unlike the Gitlab struct, this does not contain LineCode with a sha1 of the filename */ type LinePosition struct { Type string `json:"type"` OldLine int `json:"old_line"` @@ -55,26 +53,28 @@ type CommentResponse struct { Discussion *gitlab.Discussion `json:"discussion"` } -func CommentHandler(w http.ResponseWriter, r *http.Request) { +/* commentHandler creates, edits, and deletes discussions (comments, multi-line comments) */ +func (a *api) commentHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { - case http.MethodDelete: - DeleteComment(w, r) case http.MethodPost: - PostComment(w, r) + a.postComment(w, r) case http.MethodPatch: - EditComment(w, r) + a.editComment(w, r) + case http.MethodDelete: + a.deleteComment(w, r) default: - w.WriteHeader(http.StatusMethodNotAllowed) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s, %s", http.MethodDelete, http.MethodPost, http.MethodPatch)) + handleError(w, InvalidRequestError{}, "Expected DELETE, POST or PATCH", http.StatusMethodNotAllowed) } } -func DeleteComment(w http.ResponseWriter, r *http.Request) { +/* deleteComment deletes a note, multiline comment, or comment, which are all considered discussion notes. */ +func (a *api) deleteComment(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - c := r.Context().Value("client").(Client) - body, err := io.ReadAll(r.Body) if err != nil { - c.handleError(w, err, "Could not read request body", http.StatusBadRequest) + handleError(w, err, "Could not read request body", http.StatusBadRequest) return } @@ -83,37 +83,41 @@ func DeleteComment(w http.ResponseWriter, r *http.Request) { var deleteCommentRequest DeleteCommentRequest err = json.Unmarshal(body, &deleteCommentRequest) if err != nil { - c.handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) + handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) return } - res, err := c.git.Discussions.DeleteMergeRequestDiscussionNote(c.projectId, c.mergeId, deleteCommentRequest.DiscussionId, deleteCommentRequest.NoteId) + res, err := a.client.DeleteMergeRequestDiscussionNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, deleteCommentRequest.DiscussionId, deleteCommentRequest.NoteId) if err != nil { - c.handleError(w, err, "Could not delete comment", res.StatusCode) + handleError(w, err, "Could not delete comment", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/comment"}, "Could not delete comment", res.StatusCode) return } - /* TODO: Check status code */ w.WriteHeader(http.StatusOK) response := SuccessResponse{ - Message: "Comment deleted succesfully", + Message: "Comment deleted successfully", Status: http.StatusOK, } err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } -func PostComment(w http.ResponseWriter, r *http.Request) { +/* postComment creates a note, multiline comment, or comment. */ +func (a *api) postComment(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - c := r.Context().Value("client").(Client) body, err := io.ReadAll(r.Body) if err != nil { - c.handleError(w, err, "Could not read request body", http.StatusBadRequest) + handleError(w, err, "Could not read request body", http.StatusBadRequest) return } @@ -122,7 +126,7 @@ func PostComment(w http.ResponseWriter, r *http.Request) { var postCommentRequest PostCommentRequest err = json.Unmarshal(body, &postCommentRequest) if err != nil { - c.handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest) + handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest) return } @@ -132,7 +136,9 @@ func PostComment(w http.ResponseWriter, r *http.Request) { /* If we are leaving a comment on a line, leave position. Otherwise, we are leaving a note (unlinked comment) */ + var friendlyName = "Note" if postCommentRequest.FileName != "" { + friendlyName = "Comment" opt.Position = &gitlab.PositionOptions{ PositionType: &postCommentRequest.Type, StartSHA: &postCommentRequest.StartCommitSHA, @@ -145,15 +151,16 @@ func PostComment(w http.ResponseWriter, r *http.Request) { } if postCommentRequest.LineRange != nil { - var format = "%x_%d_%d" - var start_filename_sha1 = fmt.Sprintf( - format, + friendlyName = "Multiline Comment" + shaFormat := "%x_%d_%d" + startFilenameSha := fmt.Sprintf( + shaFormat, sha1.Sum([]byte(postCommentRequest.FileName)), postCommentRequest.LineRange.StartRange.OldLine, postCommentRequest.LineRange.StartRange.NewLine, ) - var end_filename_sha1 = fmt.Sprintf( - format, + endFilenameSha := fmt.Sprintf( + shaFormat, sha1.Sum([]byte(postCommentRequest.FileName)), postCommentRequest.LineRange.EndRange.OldLine, postCommentRequest.LineRange.EndRange.NewLine, @@ -161,26 +168,32 @@ func PostComment(w http.ResponseWriter, r *http.Request) { opt.Position.LineRange = &gitlab.LineRangeOptions{ Start: &gitlab.LinePositionOptions{ Type: &postCommentRequest.LineRange.StartRange.Type, - LineCode: &start_filename_sha1, + LineCode: &startFilenameSha, }, End: &gitlab.LinePositionOptions{ Type: &postCommentRequest.LineRange.EndRange.Type, - LineCode: &end_filename_sha1, + LineCode: &endFilenameSha, }, } } } - discussion, _, err := c.git.Discussions.CreateMergeRequestDiscussion(c.projectId, c.mergeId, &opt) + discussion, res, err := a.client.CreateMergeRequestDiscussion(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt) if err != nil { - c.handleError(w, err, "Could not create comment", http.StatusBadRequest) + handleError(w, err, "Could not create discussion", http.StatusInternalServerError) return } + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/comment"}, "Could not create discussion", res.StatusCode) + return + } + + w.WriteHeader(http.StatusOK) response := CommentResponse{ SuccessResponse: SuccessResponse{ - Message: "Comment updated succesfully", + Message: fmt.Sprintf("%s created successfully", friendlyName), Status: http.StatusOK, }, Comment: discussion.Notes[0], @@ -189,17 +202,17 @@ func PostComment(w http.ResponseWriter, r *http.Request) { err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } -func EditComment(w http.ResponseWriter, r *http.Request) { +/* editComment changes the text of a comment or changes it's resolved status. */ +func (a *api) editComment(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - c := r.Context().Value("client").(Client) - body, err := io.ReadAll(r.Body) + if err != nil { - c.handleError(w, err, "Could not read request body", http.StatusBadRequest) + handleError(w, err, "Could not read request body", http.StatusBadRequest) return } @@ -208,31 +221,29 @@ func EditComment(w http.ResponseWriter, r *http.Request) { var editCommentRequest EditCommentRequest err = json.Unmarshal(body, &editCommentRequest) if err != nil { - c.handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest) + handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest) return } options := gitlab.UpdateMergeRequestDiscussionNoteOptions{} - - msg := "edit comment" options.Body = gitlab.String(editCommentRequest.Comment) - note, res, err := c.git.Discussions.UpdateMergeRequestDiscussionNote(c.projectId, c.mergeId, editCommentRequest.DiscussionId, editCommentRequest.NoteId, &options) + note, res, err := a.client.UpdateMergeRequestDiscussionNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, editCommentRequest.DiscussionId, editCommentRequest.NoteId, &options) if err != nil { - c.handleError(w, err, "Could not "+msg, res.StatusCode) + handleError(w, err, "Could not update comment", http.StatusInternalServerError) return } - w.WriteHeader(res.StatusCode) - - if res.StatusCode != http.StatusOK { - c.handleError(w, errors.New("Non-200 status code recieved"), "Could not "+msg, res.StatusCode) + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/comment"}, "Could not update comment", res.StatusCode) + return } + w.WriteHeader(http.StatusOK) response := CommentResponse{ SuccessResponse: SuccessResponse{ - Message: "Comment updated succesfully", + Message: "Comment updated successfully", Status: http.StatusOK, }, Comment: note, @@ -240,6 +251,6 @@ func EditComment(w http.ResponseWriter, r *http.Request) { err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } diff --git a/cmd/comment_test.go b/cmd/comment_test.go new file mode 100644 index 0000000..6e920e5 --- /dev/null +++ b/cmd/comment_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "errors" + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +func createMergeRequestDiscussion(pid interface{}, mergeRequest int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) { + return &gitlab.Discussion{Notes: []*gitlab.Note{{}}}, makeResponse(http.StatusOK), nil +} + +func createMergeRequestDiscussionNon200(pid interface{}, mergeRequest int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) { + return nil, makeResponse(http.StatusSeeOther), nil +} + +func createMergeRequestDiscussionErr(pid interface{}, mergeRequest int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) { + return nil, nil, errors.New("Some error from Gitlab") +} + +func TestPostComment(t *testing.T) { + t.Run("Creates a new note (unlinked comment)", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/comment", PostCommentRequest{}) + server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion}) + data := serveRequest(t, server, request, CommentResponse{}) + assert(t, data.SuccessResponse.Message, "Note created successfully") + assert(t, data.SuccessResponse.Status, http.StatusOK) + }) + + t.Run("Creates a new comment", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/comment", PostCommentRequest{FileName: "some_file.txt"}) + server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion}) + data := serveRequest(t, server, request, CommentResponse{}) + assert(t, data.SuccessResponse.Message, "Comment created successfully") + assert(t, data.SuccessResponse.Status, http.StatusOK) + }) + + t.Run("Creates a new multiline comment", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/comment", PostCommentRequest{ + FileName: "some_file.txt", + LineRange: &LineRange{ + StartRange: &LinePosition{}, /* These would have real data */ + EndRange: &LinePosition{}, + }, + }) + server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion}) + data := serveRequest(t, server, request, CommentResponse{}) + assert(t, data.SuccessResponse.Message, "Multiline Comment created successfully") + assert(t, data.SuccessResponse.Status, http.StatusOK) + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/comment", PostCommentRequest{}) + server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussionErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not create discussion") + }) + + t.Run("Handles non-200s from Gitlab", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/comment", PostCommentRequest{}) + server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussionNon200}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not create discussion", "/comment") + }) +} + +func deleteMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return makeResponse(http.StatusOK), nil +} + +func deleteMergeRequestDiscussionNoteErr(pid interface{}, mergeRequest int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return nil, errors.New("Some error from Gitlab") +} + +func deleteMergeRequestDiscussionNoteNon200(pid interface{}, mergeRequest int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return makeResponse(http.StatusSeeOther), nil +} + +func TestDeleteComment(t *testing.T) { + t.Run("Deletes a comment", func(t *testing.T) { + request := makeRequest(t, http.MethodDelete, "/comment", DeleteCommentRequest{}) + server, _ := createRouterAndApi(fakeClient{deleteMergeRequestDiscussionNote: deleteMergeRequestDiscussionNote}) + data := serveRequest(t, server, request, CommentResponse{}) + assert(t, data.SuccessResponse.Message, "Comment deleted successfully") + assert(t, data.SuccessResponse.Status, http.StatusOK) + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodDelete, "/comment", DeleteCommentRequest{}) + server, _ := createRouterAndApi(fakeClient{deleteMergeRequestDiscussionNote: deleteMergeRequestDiscussionNoteErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not delete comment") + }) + + t.Run("Handles non-200s from Gitlab", func(t *testing.T) { + request := makeRequest(t, http.MethodDelete, "/comment", DeleteCommentRequest{}) + server, _ := createRouterAndApi(fakeClient{deleteMergeRequestDiscussionNote: deleteMergeRequestDiscussionNoteNon200}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not delete comment", "/comment") + }) +} + +func updateMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { + return &gitlab.Note{}, makeResponse(http.StatusOK), nil +} + +func updateMergeRequestDiscussionNoteErr(pid interface{}, mergeRequest int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { + return nil, nil, errors.New("Some error from Gitlab") +} + +func updateMergeRequestDiscussionNoteNon200(pid interface{}, mergeRequest int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { + return nil, makeResponse(http.StatusSeeOther), nil +} + +func TestEditComment(t *testing.T) { + t.Run("Edits a comment", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/comment", EditCommentRequest{}) + server, _ := createRouterAndApi(fakeClient{updateMergeRequestDiscussionNote: updateMergeRequestDiscussionNote}) + data := serveRequest(t, server, request, CommentResponse{}) + assert(t, data.SuccessResponse.Message, "Comment updated successfully") + assert(t, data.SuccessResponse.Status, http.StatusOK) + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/comment", EditCommentRequest{}) + server, _ := createRouterAndApi(fakeClient{updateMergeRequestDiscussionNote: updateMergeRequestDiscussionNoteErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not update comment") + }) + + t.Run("Handles non-200s from Gitlab", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/comment", EditCommentRequest{}) + server, _ := createRouterAndApi(fakeClient{updateMergeRequestDiscussionNote: updateMergeRequestDiscussionNoteNon200}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not update comment", "/comment") + }) +} diff --git a/cmd/git.go b/cmd/git.go index e5caf2a..c34e9ac 100644 --- a/cmd/git.go +++ b/cmd/git.go @@ -27,7 +27,7 @@ Extracts information about the current repository and returns it to the client for initialization. The current directory must be a valid Gitlab project and the branch must be a feature branch */ -func ExtractGitInfo(refreshGitInfo func() error, getProjectRemoteUrl func() (string, error), getCurrentBranchName func() (string, error)) (GitProjectInfo, error) { +func extractGitInfo(refreshGitInfo func() error, getProjectRemoteUrl func() (string, error), getCurrentBranchName func() (string, error)) (GitProjectInfo, error) { err := refreshGitInfo() if err != nil { diff --git a/cmd/git_test.go b/cmd/git_test.go deleted file mode 100644 index 475de96..0000000 --- a/cmd/git_test.go +++ /dev/null @@ -1,251 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "testing" -) - -func TestExtractGitInfo_Success(t *testing.T) { - getCurrentBranchName := func() (string, error) { - return "feature/abc", nil - } - refreshGitInfo := func() error { - return nil - } - testCases := []struct { - getProjectRemoteUrl func() (string, error) - expected GitProjectInfo - desc string - }{ - { - desc: "Project configured in SSH under a single folder", - getProjectRemoteUrl: func() (string, error) { - return "git@custom-gitlab.com:namespace-1/project-name.git", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "git@custom-gitlab.com:namespace-1/project-name.git", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1", - }, - }, - { - desc: "Project configured in SSH under a single folder without .git extension", - getProjectRemoteUrl: func() (string, error) { - return "git@custom-gitlab.com:namespace-1/project-name", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "git@custom-gitlab.com:namespace-1/project-name", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1", - }, - }, - { - desc: "Project configured in SSH under one nested folder", - getProjectRemoteUrl: func() (string, error) { - return "git@custom-gitlab.com:namespace-1/namespace-2/project-name.git", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "git@custom-gitlab.com:namespace-1/namespace-2/project-name.git", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1/namespace-2", - }, - }, - { - desc: "Project configured in SSH under two nested folders", - getProjectRemoteUrl: func() (string, error) { - return "git@custom-gitlab.com:namespace-1/namespace-2/namespace-3/project-name.git", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "git@custom-gitlab.com:namespace-1/namespace-2/namespace-3/project-name.git", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1/namespace-2/namespace-3", - }, - }, - { - desc: "Project configured in SSH:// under a single folder", - getProjectRemoteUrl: func() (string, error) { - return "ssh://custom-gitlab.com/namespace-1/project-name.git", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "ssh://custom-gitlab.com/namespace-1/project-name.git", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1", - }, - }, - { - desc: "Project configured in SSH:// under a single folder without .git extension", - getProjectRemoteUrl: func() (string, error) { - return "ssh://custom-gitlab.com/namespace-1/project-name", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "ssh://custom-gitlab.com/namespace-1/project-name", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1", - }, - }, - { - desc: "Project configured in SSH:// under two nested folders", - getProjectRemoteUrl: func() (string, error) { - return "ssh://custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "ssh://custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1/namespace-2/namespace-3", - }, - }, - { - desc: "Project configured in HTTP and under a single folder without .git extension", - getProjectRemoteUrl: func() (string, error) { - return "http://custom-gitlab.com/namespace-1/project-name", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "http://custom-gitlab.com/namespace-1/project-name", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1", - }, - }, - { - desc: "Project configured in HTTPS and under a single folder", - getProjectRemoteUrl: func() (string, error) { - return "https://custom-gitlab.com/namespace-1/project-name.git", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "https://custom-gitlab.com/namespace-1/project-name.git", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1", - }, - }, - { - desc: "Project configured in HTTPS and under a nested folder", - getProjectRemoteUrl: func() (string, error) { - return "https://custom-gitlab.com/namespace-1/namespace-2/project-name.git", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "https://custom-gitlab.com/namespace-1/namespace-2/project-name.git", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1/namespace-2", - }, - }, - { - desc: "Project configured in HTTPS and under two nested folders", - getProjectRemoteUrl: func() (string, error) { - return "https://custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "https://custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1/namespace-2/namespace-3", - }, - }, - } - for _, tC := range testCases { - t.Run(tC.desc, func(t *testing.T) { - actual, err := ExtractGitInfo(refreshGitInfo, tC.getProjectRemoteUrl, getCurrentBranchName) - if err != nil { - t.Errorf("No error was expected, got %s", err) - } - if actual != tC.expected { - t.Errorf("\nExpected: %s\nActual: %s", tC.expected, actual) - } - }) - } -} - -func TestExtractGitInfo_FailToGetProjectRemoteUrl(t *testing.T) { - getCurrentBranchName := func() (string, error) { - return "feature/abc", nil - } - refreshGitInfo := func() error { - return nil - } - testCases := []struct { - getProjectRemoteUrl func() (string, error) - expectedErrorMessage string - desc string - }{ - { - desc: "Error returned by function to get the project remote url", - getProjectRemoteUrl: func() (string, error) { - return "", errors.New("error when getting project remote url") - }, - expectedErrorMessage: "Could not get project Url: error when getting project remote url", - }, - { - desc: "Invalid project remote url", - getProjectRemoteUrl: func() (string, error) { - return "git@invalid", nil - }, - expectedErrorMessage: "Invalid Git URL format: git@invalid", - }, - } - for _, tC := range testCases { - t.Run(tC.desc, func(t *testing.T) { - _, actualErr := ExtractGitInfo(refreshGitInfo, tC.getProjectRemoteUrl, getCurrentBranchName) - if actualErr == nil { - t.Errorf("Expected an error, got none") - } - if actualErr.Error() != tC.expectedErrorMessage { - t.Errorf("\nExpected: %s\nActual: %s", tC.expectedErrorMessage, actualErr.Error()) - } - }) - } -} - -func TestExtractGitInfo_FailToGetCurrentBranchName(t *testing.T) { - expectedErrNestedMsg := "error when getting current branch name" - - refreshGitInfo := func() error { - return nil - } - _, actualErr := ExtractGitInfo(refreshGitInfo, - func() (string, error) { - return "git@custom-gitlab.com:namespace/project.git", nil - }, - func() (string, error) { - return "", errors.New(expectedErrNestedMsg) - }) - - if actualErr == nil { - t.Errorf("Expected an error, got none") - } - expectedErr := fmt.Errorf("Failed to get current branch: %s", expectedErrNestedMsg) - if actualErr.Error() != expectedErr.Error() { - t.Errorf("\nExpected: %s\nActual: %s", expectedErr, actualErr) - } -} - -func TestRefreshGitRemote_FailToRefreshRemote(t *testing.T) { - expectedErrNestedMsg := "error when fetching origin commits" - _, actualErr := ExtractGitInfo( - func() error { - return errors.New(expectedErrNestedMsg) - }, - func() (string, error) { - return "git@custom-gitlab.com:namespace/project.git", nil - }, - func() (string, error) { - return "feature/abc", nil - }, - ) - - if actualErr == nil { - t.Errorf("Expected an error, got none") - } - expectedErr := fmt.Errorf("Could not get latest information from remote: %s", expectedErrNestedMsg) - if actualErr.Error() != expectedErr.Error() { - t.Errorf("\nExpected: %s\nActual: %s", expectedErr, actualErr) - } -} diff --git a/cmd/info.go b/cmd/info.go index c153c4b..e733758 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -2,75 +2,33 @@ package main import ( "encoding/json" - "errors" - "fmt" - "io" "net/http" "github.com/xanzy/go-gitlab" ) -const mrUrl = "%s/api/v4/projects/%s/merge_requests/%d" - type InfoResponse struct { SuccessResponse Info *gitlab.MergeRequest `json:"info"` } -func (c *Client) Info() ([]byte, error) { - - url := fmt.Sprintf(mrUrl, c.gitlabInstance, c.projectId, c.mergeId) - req, err := http.NewRequest(http.MethodGet, url, nil) - - if err != nil { - return nil, fmt.Errorf("Failed to build read request: %w", err) - } - - req.Header.Set("PRIVATE-TOKEN", c.authToken) - req.Header.Set("Content-Type", "application/json") - - res, err := http.DefaultClient.Do(req) - - if err != nil { - return nil, fmt.Errorf("Failed to make info request: %w", err) - } - - defer res.Body.Close() - - if res.StatusCode < 200 || res.StatusCode >= 300 { - return nil, fmt.Errorf("Recieved non-200 response: %d", res.StatusCode) - } - - body, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("Failed to parse read response: %w", err) - } - - /* This response is parsed into a table in our Lua code */ - return body, nil - -} - -func InfoHandler(w http.ResponseWriter, r *http.Request) { +/* infoHandler fetches infomation about the current git project. The data returned here is used in many other API calls */ +func (a *api) infoHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - c := r.Context().Value("client").(Client) - if r.Method != http.MethodGet { - w.Header().Set("Allow", http.MethodGet) - c.handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) + w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) + handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed) return } - msg, err := c.Info() + mr, res, err := a.client.GetMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.GetMergeRequestsOptions{}) if err != nil { - c.handleError(w, err, "Could not get project info and initialize gitlab.nvim plugin", http.StatusBadRequest) + handleError(w, err, "Could not get project info", http.StatusInternalServerError) return } - var mergeRequest *gitlab.MergeRequest - err = json.Unmarshal(msg, &mergeRequest) - if err != nil { - c.handleError(w, err, "Could not unmarshal data from merge requests", http.StatusBadRequest) + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/info"}, "Could not get project info", res.StatusCode) return } @@ -80,11 +38,11 @@ func InfoHandler(w http.ResponseWriter, r *http.Request) { Message: "Merge requests retrieved", Status: http.StatusOK, }, - Info: mergeRequest, + Info: mr, } err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } diff --git a/cmd/info_test.go b/cmd/info_test.go new file mode 100644 index 0000000..c4b3db4 --- /dev/null +++ b/cmd/info_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "errors" + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +func getInfo(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + return &gitlab.MergeRequest{Title: "Some Title"}, makeResponse(http.StatusOK), nil +} + +func getInfoNon200(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + return nil, makeResponse(http.StatusSeeOther), nil +} + +func getInfoErr(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + return nil, nil, errors.New("Some error from Gitlab") +} + +func TestInfoHandler(t *testing.T) { + t.Run("Returns normal information", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/info", nil) + server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfo}) + data := serveRequest(t, server, request, InfoResponse{}) + assert(t, data.Info.Title, "Some Title") + assert(t, data.SuccessResponse.Message, "Merge requests retrieved") + assert(t, data.SuccessResponse.Status, http.StatusOK) + }) + + t.Run("Disallows non-GET method", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/info", nil) + server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfo}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkBadMethod(t, *data, http.MethodGet) + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/info", nil) + server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfoErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not get project info") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/info", nil) + server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfoNon200}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not get project info", "/info") + }) +} diff --git a/cmd/job.go b/cmd/job.go index 1f21bf4..334141f 100644 --- a/cmd/job.go +++ b/cmd/job.go @@ -2,7 +2,6 @@ package main import ( "encoding/json" - "errors" "io" "net/http" ) @@ -16,19 +15,19 @@ type JobTraceResponse struct { File string `json:"file"` } -func JobHandler(w http.ResponseWriter, r *http.Request) { +/* jobHandler returns a string that shows the output of a specific job run in a Gitlab pipeline */ +func (a *api) jobHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - c := r.Context().Value("client").(Client) - if r.Method != http.MethodGet { - w.Header().Set("Allow", http.MethodGet) - c.handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) + w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) + handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed) return } body, err := io.ReadAll(r.Body) if err != nil { - c.handleError(w, err, "Could not read request body", http.StatusBadRequest) + handleError(w, err, "Could not read request body", http.StatusBadRequest) + return } defer r.Body.Close() @@ -36,18 +35,27 @@ func JobHandler(w http.ResponseWriter, r *http.Request) { var jobTraceRequest JobTraceRequest err = json.Unmarshal(body, &jobTraceRequest) if err != nil { - c.handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest) + handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest) + return } - reader, _, err := c.git.Jobs.GetTraceFile(c.projectId, jobTraceRequest.JobId) + reader, res, err := a.client.GetTraceFile(a.projectInfo.ProjectId, jobTraceRequest.JobId) + if err != nil { - c.handleError(w, err, "Could not get trace file for job", http.StatusBadRequest) + handleError(w, err, "Could not get trace file for job", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/job"}, "Could not get trace file for job", res.StatusCode) + return } file, err := io.ReadAll(reader) if err != nil { - c.handleError(w, err, "Could not read job trace file", http.StatusBadRequest) + handleError(w, err, "Could not read job trace file", http.StatusBadRequest) + return } response := JobTraceResponse{ @@ -60,6 +68,6 @@ func JobHandler(w http.ResponseWriter, r *http.Request) { err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } diff --git a/cmd/job_test.go b/cmd/job_test.go new file mode 100644 index 0000000..da4a0e8 --- /dev/null +++ b/cmd/job_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "bytes" + "errors" + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +func getTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) { + return bytes.NewReader([]byte("Some data")), makeResponse(http.StatusOK), nil +} + +func getTraceFileErr(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) { + return nil, nil, errors.New("Some error from Gitlab") +} + +func getTraceFileNon200(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) { + return nil, makeResponse(http.StatusSeeOther), nil +} + +func TestJobHandler(t *testing.T) { + t.Run("Should read a job trace file", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) + server, _ := createRouterAndApi(fakeClient{getTraceFile: getTraceFile}) + data := serveRequest(t, server, request, JobTraceResponse{}) + assert(t, data.SuccessResponse.Message, "Log file read") + assert(t, data.SuccessResponse.Status, http.StatusOK) + assert(t, data.File, "Some data") + }) + + t.Run("Disallows non-GET methods", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/job", JobTraceRequest{}) + server, _ := createRouterAndApi(fakeClient{getTraceFile: getTraceFile}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkBadMethod(t, *data, http.MethodGet) + }) + + t.Run("Should handle errors from Gitlab", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) + server, _ := createRouterAndApi(fakeClient{getTraceFile: getTraceFileErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not get trace file for job") + }) + + t.Run("Should handle non-200s", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) + server, _ := createRouterAndApi(fakeClient{getTraceFile: getTraceFileNon200}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not get trace file for job", "/job") + }) +} diff --git a/cmd/list_discussions.go b/cmd/list_discussions.go index 0cbf69b..7f6fa42 100644 --- a/cmd/list_discussions.go +++ b/cmd/list_discussions.go @@ -1,8 +1,6 @@ package main import ( - "errors" - "fmt" "io" "net/http" "sort" @@ -38,16 +36,47 @@ func (n SortableDiscussions) Swap(i, j int) { n[i], n[j] = n[j], n[i] } -func (c *Client) ListDiscussions(blacklist []string) ([]*gitlab.Discussion, []*gitlab.Discussion, int, error) { +/* +listDiscussionsHandler lists all discusions for a given merge request, both those linked and unlinked to particular points in the code. +The responses are sorted by date created, and blacklisted users are not included +*/ +func (a *api) listDiscussionsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost { + w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) + handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + + if err != nil { + handleError(w, err, "Could not read request body", http.StatusBadRequest) + } + + defer r.Body.Close() + + var requestBody DiscussionsRequest + err = json.Unmarshal(body, &requestBody) + if err != nil { + handleError(w, err, "Could not unmarshal request body", http.StatusBadRequest) + } mergeRequestDiscussionOptions := gitlab.ListMergeRequestDiscussionsOptions{ Page: 1, PerPage: 250, } - discussions, res, err := c.git.Discussions.ListMergeRequestDiscussions(c.projectId, c.mergeId, &mergeRequestDiscussionOptions, nil) + + discussions, res, err := a.client.ListMergeRequestDiscussions(a.projectInfo.ProjectId, a.projectInfo.MergeId, &mergeRequestDiscussionOptions, nil) if err != nil { - return nil, nil, res.Response.StatusCode, fmt.Errorf("Listing discussions failed: %w", err) + handleError(w, err, "Could not list discussions", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/discussions/list"}, "Could not list discussions", res.StatusCode) + return } /* Filter out any discussions started by a blacklisted user @@ -55,7 +84,7 @@ func (c *Client) ListDiscussions(blacklist []string) ([]*gitlab.Discussion, []*g var unlinkedDiscussions []*gitlab.Discussion var linkedDiscussions []*gitlab.Discussion for _, discussion := range discussions { - if Contains(blacklist, discussion.Notes[0].Author.Username) > -1 { + if discussion.Notes == nil || len(discussion.Notes) == 0 || Contains(requestBody.Blacklist, discussion.Notes[0].Author.Username) > -1 { continue } for _, note := range discussion.Notes { @@ -75,43 +104,15 @@ func (c *Client) ListDiscussions(blacklist []string) ([]*gitlab.Discussion, []*g sort.Sort(sortedLinkedDiscussions) sort.Sort(sortedUnlinkedDiscussions) - return sortedLinkedDiscussions, sortedUnlinkedDiscussions, http.StatusOK, nil -} - -func ListDiscussionsHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - c := r.Context().Value("client").(Client) - - if r.Method != http.MethodPost { - w.Header().Set("Allow", http.MethodPost) - c.handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) + if err != nil { + handleError(w, err, "Could not list discussions", http.StatusBadRequest) return } - body, err := io.ReadAll(r.Body) - - if err != nil { - c.handleError(w, err, "Could not read request body", http.StatusBadRequest) - } - - var requestBody DiscussionsRequest - err = json.Unmarshal(body, &requestBody) - if err != nil { - c.handleError(w, err, "Could not unmarshal request body", http.StatusBadRequest) - } - - linkedDiscussions, unlinkedDiscussions, status, err := c.ListDiscussions(requestBody.Blacklist) - - if err != nil { - c.handleError(w, err, "Could not list discussions", http.StatusBadRequest) - return - } - - /* TODO: Check for non-200 statuses */ - w.WriteHeader(status) + w.WriteHeader(http.StatusOK) response := DiscussionsResponse{ SuccessResponse: SuccessResponse{ - Message: "Discussions successfully fetched.", + Message: "Discussions retrieved", Status: http.StatusOK, }, Discussions: linkedDiscussions, @@ -120,6 +121,6 @@ func ListDiscussionsHandler(w http.ResponseWriter, r *http.Request) { err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } diff --git a/cmd/list_discussions_test.go b/cmd/list_discussions_test.go new file mode 100644 index 0000000..321dba2 --- /dev/null +++ b/cmd/list_discussions_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "errors" + "net/http" + "testing" + "time" + + "github.com/xanzy/go-gitlab" +) + +func listMergeRequestDiscussions(pid interface{}, mergeRequest int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) { + now := time.Now() + newer := now.Add(time.Second * 100) + discussions := []*gitlab.Discussion{ + { + Notes: []*gitlab.Note{ + { + CreatedAt: &now, + Type: "DiffNote", + Author: Author{ + Username: "hcramer", + }, + }, + }, + }, + { + Notes: []*gitlab.Note{ + { + CreatedAt: &newer, + Type: "DiffNote", + Author: Author{ + Username: "hcramer2", + }, + }, + }, + }, + } + return discussions, makeResponse(http.StatusOK), nil +} + +func listMergeRequestDiscussionsErr(pid interface{}, mergeRequest int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) { + return nil, nil, errors.New("Some error from Gitlab") +} + +func listMergeRequestDiscussionsNon200(pid interface{}, mergeRequest int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) { + return nil, makeResponse(http.StatusSeeOther), nil +} + +func TestListDiscussionsHandler(t *testing.T) { + t.Run("Returns sorted discussions", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/discussions/list", DiscussionsRequest{}) + server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussions}) + data := serveRequest(t, server, request, DiscussionsResponse{}) + assert(t, data.SuccessResponse.Message, "Discussions retrieved") + assert(t, data.SuccessResponse.Status, http.StatusOK) + assert(t, data.Discussions[0].Notes[0].Author.Username, "hcramer2") /* Sorting applied */ + assert(t, data.Discussions[1].Notes[0].Author.Username, "hcramer") + }) + + t.Run("Uses blacklist to filter unwanted authors", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/discussions/list", DiscussionsRequest{Blacklist: []string{"hcramer"}}) + server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussions}) + data := serveRequest(t, server, request, DiscussionsResponse{}) + assert(t, data.SuccessResponse.Message, "Discussions retrieved") + assert(t, data.SuccessResponse.Status, http.StatusOK) + assert(t, len(data.Discussions), 1) + assert(t, data.Discussions[0].Notes[0].Author.Username, "hcramer2") + }) + + t.Run("Disallows non-POST method", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/discussions/list", DiscussionsRequest{}) + server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussions}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkBadMethod(t, *data, http.MethodPost) + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/discussions/list", DiscussionsRequest{}) + server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussionsErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not list discussions") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/discussions/list", DiscussionsRequest{}) + server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussionsNon200}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not list discussions", "/discussions/list") + }) +} diff --git a/cmd/main.go b/cmd/main.go index f0b4757..d495f6f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,96 +1,24 @@ package main import ( - "context" - "fmt" "log" - "net" - "net/http" - "os" - "time" ) func main() { - g, err := ExtractGitInfo(RefreshProjectInfo, GetProjectUrlFromNativeGitCmd, GetCurrentBranchNameFromNativeGitCmd) + gitInfo, err := extractGitInfo(RefreshProjectInfo, GetProjectUrlFromNativeGitCmd, GetCurrentBranchNameFromNativeGitCmd) if err != nil { log.Fatalf("Failure initializing plugin with `git` commands: %v", err) } - var c Client - if err := c.initGitlabClient(); err != nil { + err, client := initGitlabClient() + if err != nil { log.Fatalf("Failed to initialize Gitlab client: %v", err) } - if err := c.initProjectSettings(g); err != nil { + err, projectInfo := initProjectSettings(client, gitInfo) + if err != nil { log.Fatalf("Failed to initialize project settings: %v", err) } - m := http.NewServeMux() - m.Handle("/ping", http.HandlerFunc(PingHandler)) - m.Handle("/mr/summary", withGitlabContext(http.HandlerFunc(SummaryHandler), c)) - m.Handle("/mr/attachment", withGitlabContext(http.HandlerFunc(AttachmentHandler), c)) - m.Handle("/mr/reviewer", withGitlabContext(http.HandlerFunc(ReviewersHandler), c)) - m.Handle("/mr/revisions", withGitlabContext(http.HandlerFunc(RevisionsHandler), c)) - m.Handle("/mr/assignee", withGitlabContext(http.HandlerFunc(AssigneesHandler), c)) - m.Handle("/approve", withGitlabContext(http.HandlerFunc(ApproveHandler), c)) - m.Handle("/revoke", withGitlabContext(http.HandlerFunc(RevokeHandler), c)) - m.Handle("/info", withGitlabContext(http.HandlerFunc(InfoHandler), c)) - m.Handle("/discussions", withGitlabContext(http.HandlerFunc(ListDiscussionsHandler), c)) - m.Handle("/discussion/resolve", withGitlabContext(http.HandlerFunc(DiscussionResolveHandler), c)) - m.Handle("/comment", withGitlabContext(http.HandlerFunc(CommentHandler), c)) - m.Handle("/reply", withGitlabContext(http.HandlerFunc(ReplyHandler), c)) - m.Handle("/members", withGitlabContext(http.HandlerFunc(ProjectMembersHandler), c)) - m.Handle("/pipeline", withGitlabContext(http.HandlerFunc(PipelineHandler), c)) - m.Handle("/job", withGitlabContext(http.HandlerFunc(JobHandler), c)) - - port := os.Args[2] - if port == "" { - // port was not specified - port = "0" - } - addr := fmt.Sprintf("localhost:%s", port) - listener, err := net.Listen("tcp", addr) - if err != nil { - log.Fatal(err) - fmt.Fprintf(os.Stderr, "Error starting server: %s\n", err) - os.Exit(1) - } - listenerPort := listener.Addr().(*net.TCPAddr).Port - - errCh := make(chan error) - go func() { - err := http.Serve(listener, m) - errCh <- err - }() - - go func() { - for i := 0; i < 10; i++ { - resp, err := http.Get("http://localhost:" + fmt.Sprintf("%d", listenerPort) + "/ping") - if resp.StatusCode == 200 && err == nil { - /* This print is detected by the Lua code and used to fetch project information */ - fmt.Println("Server started on port: ", listenerPort) - return - } - // Wait for healthcheck to pass - at most 1 sec. - time.Sleep(100 * time.Microsecond) - } - errCh <- err - }() - - if err := <-errCh; err != nil { - fmt.Fprintf(os.Stderr, "Error starting server: %s\n", err) - os.Exit(1) - } -} - -func PingHandler(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fmt.Fprintln(w, "pong") -} - -func withGitlabContext(next http.HandlerFunc, c Client) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := context.WithValue(context.Background(), "client", c) //nolint:all - next.ServeHTTP(w, r.WithContext(ctx)) - }) + startServer(client, projectInfo) } diff --git a/cmd/members.go b/cmd/members.go index 6207c7e..53470ed 100644 --- a/cmd/members.go +++ b/cmd/members.go @@ -12,9 +12,14 @@ type ProjectMembersResponse struct { ProjectMembers []*gitlab.ProjectMember } -func ProjectMembersHandler(w http.ResponseWriter, r *http.Request) { - c := r.Context().Value("client").(Client) +/* projectMembersHandler returns all members of the current Gitlab project */ +func (a *api) projectMembersHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodGet { + w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) + handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed) + return + } projectMemberOptions := gitlab.ListProjectMembersOptions{ ListOptions: gitlab.ListOptions{ @@ -22,9 +27,16 @@ func ProjectMembersHandler(w http.ResponseWriter, r *http.Request) { }, } - projectMembers, res, err := c.git.ProjectMembers.ListAllProjectMembers(c.projectId, &projectMemberOptions) + projectMembers, res, err := a.client.ListAllProjectMembers(a.projectInfo.ProjectId, &projectMemberOptions) + if err != nil { - c.handleError(w, err, "Could not fetch project users", res.StatusCode) + handleError(w, err, "Could not retrieve project members", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/project/members"}, "Could not retrieve project members", res.StatusCode) + return } w.WriteHeader(http.StatusOK) @@ -32,13 +44,13 @@ func ProjectMembersHandler(w http.ResponseWriter, r *http.Request) { response := ProjectMembersResponse{ SuccessResponse: SuccessResponse{ Status: http.StatusOK, - Message: "Project users fetched successfully", + Message: "Project members retrieved", }, ProjectMembers: projectMembers, } err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } diff --git a/cmd/members_test.go b/cmd/members_test.go new file mode 100644 index 0000000..d33e627 --- /dev/null +++ b/cmd/members_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "errors" + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +func listAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) { + return []*gitlab.ProjectMember{}, makeResponse(http.StatusOK), nil +} + +func listAllProjectMembersErr(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) { + return nil, nil, errors.New("Some error from Gitlab") +} + +func listAllProjectMembersNon200(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) { + return nil, makeResponse(http.StatusSeeOther), nil +} + +func TestMembersHandler(t *testing.T) { + t.Run("Returns project members", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/project/members", nil) + server, _ := createRouterAndApi(fakeClient{listAllProjectMembers: listAllProjectMembers}) + data := serveRequest(t, server, request, ProjectMembersResponse{}) + assert(t, data.SuccessResponse.Message, "Project members retrieved") + assert(t, data.SuccessResponse.Status, http.StatusOK) + }) + + t.Run("Disallows non-GET method", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/project/members", nil) + server, _ := createRouterAndApi(fakeClient{listAllProjectMembers: listAllProjectMembers}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkBadMethod(t, *data, http.MethodGet) + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/project/members", nil) + server, _ := createRouterAndApi(fakeClient{listAllProjectMembers: listAllProjectMembersErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not retrieve project members") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/project/members", nil) + server, _ := createRouterAndApi(fakeClient{listAllProjectMembers: listAllProjectMembersNon200}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not retrieve project members", "/project/members") + }) +} diff --git a/cmd/pipeline.go b/cmd/pipeline.go index c4135e5..f76818e 100644 --- a/cmd/pipeline.go +++ b/cmd/pipeline.go @@ -2,16 +2,14 @@ package main import ( "encoding/json" - "io" + "fmt" "net/http" + "strconv" + "strings" "github.com/xanzy/go-gitlab" ) -type PipelineRequest struct { - PipelineId int `json:"pipeline_id"` -} - type RetriggerPipelineResponse struct { SuccessResponse Pipeline *gitlab.Pipeline @@ -22,80 +20,82 @@ type GetJobsResponse struct { Jobs []*gitlab.Job } -func PipelineHandler(w http.ResponseWriter, r *http.Request) { +/* +pipelineHandler fetches information about the current pipeline, and retriggers a pipeline run. For more detailed information +about a given job in a pipeline, see the jobHandler function +*/ +func (a *api) pipelineHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - GetJobs(w, r) + a.GetJobs(w, r) case http.MethodPost: - RetriggerPipeline(w, r) + a.RetriggerPipeline(w, r) default: - w.WriteHeader(http.StatusMethodNotAllowed) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s", http.MethodGet, http.MethodPost)) + handleError(w, InvalidRequestError{}, "Expected GET or POST", http.StatusMethodNotAllowed) } } -func GetJobs(w http.ResponseWriter, r *http.Request) { +func (a *api) GetJobs(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - c := r.Context().Value("client").(Client) - body, err := io.ReadAll(r.Body) + id := strings.TrimPrefix(r.URL.Path, "/pipeline/") + idInt, err := strconv.Atoi(id) + if err != nil { - c.handleError(w, err, "Could not read request body", http.StatusBadRequest) + handleError(w, err, "Could not convert pipeline ID to integer", http.StatusBadRequest) return } - defer r.Body.Close() + jobs, res, err := a.client.ListPipelineJobs(a.projectInfo.ProjectId, idInt, &gitlab.ListJobsOptions{}) - var pipelineRequest PipelineRequest - err = json.Unmarshal(body, &pipelineRequest) if err != nil { - c.handleError(w, err, "Could not read JSON", http.StatusBadRequest) + handleError(w, err, "Could not get pipeline jobs", http.StatusInternalServerError) + return } - jobs, res, err := c.git.Jobs.ListPipelineJobs(c.projectId, pipelineRequest.PipelineId, &gitlab.ListJobsOptions{}) - - if err != nil { - c.handleError(w, err, "Could not get pipeline jobs", res.StatusCode) + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/pipeline"}, "Could not get pipeline jobs", res.StatusCode) + return } w.WriteHeader(http.StatusOK) - response := GetJobsResponse{ SuccessResponse: SuccessResponse{ Status: http.StatusOK, - Message: "Jobs fetched successfully", + Message: "Pipeline jobs retrieved", }, Jobs: jobs, } err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } - } -func RetriggerPipeline(w http.ResponseWriter, r *http.Request) { +func (a *api) RetriggerPipeline(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - c := r.Context().Value("client").(Client) - body, err := io.ReadAll(r.Body) + id := strings.TrimPrefix(r.URL.Path, "/pipeline/") + + idInt, err := strconv.Atoi(id) if err != nil { - c.handleError(w, err, "Could not read request body", http.StatusBadRequest) + handleError(w, err, "Could not convert pipeline ID to integer", http.StatusBadRequest) return } - defer r.Body.Close() + pipeline, res, err := a.client.RetryPipelineBuild(a.projectInfo.ProjectId, idInt) - var pipelineRequest PipelineRequest - err = json.Unmarshal(body, &pipelineRequest) if err != nil { - c.handleError(w, err, "Could not read JSON", http.StatusBadRequest) + handleError(w, err, "Could not retrigger pipeline", http.StatusInternalServerError) + return } - pipeline, res, err := c.git.Pipelines.RetryPipelineBuild(c.projectId, pipelineRequest.PipelineId) - - if err != nil { - c.handleError(w, err, "Could not retrigger pipeline", res.StatusCode) + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/pipeline"}, "Could not retrigger pipeline", res.StatusCode) + return } w.WriteHeader(http.StatusOK) @@ -109,6 +109,6 @@ func RetriggerPipeline(w http.ResponseWriter, r *http.Request) { err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } diff --git a/cmd/pipeline_test.go b/cmd/pipeline_test.go new file mode 100644 index 0000000..344d506 --- /dev/null +++ b/cmd/pipeline_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "errors" + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +func listPipelineJobs(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) { + return []*gitlab.Job{}, makeResponse(http.StatusOK), nil +} + +func listPipelineJobsErr(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) { + return nil, nil, errors.New("Some error from Gitlab") +} + +func listPipelineJobsNon200(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) { + return nil, makeResponse(http.StatusSeeOther), nil +} + +func retryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) { + return &gitlab.Pipeline{}, makeResponse(http.StatusOK), nil +} + +func retryPipelineBuildErr(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) { + return nil, nil, errors.New("Some error from Gitlab") +} + +func retryPipelineBuildNon200(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) { + return nil, makeResponse(http.StatusSeeOther), nil +} + +func TestPipelineHandler(t *testing.T) { + t.Run("Gets all pipeline jobs", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/pipeline/1", nil) + server, _ := createRouterAndApi(fakeClient{listPipelineJobs: listPipelineJobs}) + data := serveRequest(t, server, request, GetJobsResponse{}) + assert(t, data.SuccessResponse.Message, "Pipeline jobs retrieved") + assert(t, data.SuccessResponse.Status, http.StatusOK) + }) + + t.Run("Disallows non-GET, non-POST methods", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/pipeline/1", nil) + server, _ := createRouterAndApi(fakeClient{listPipelineJobs: listPipelineJobs}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkBadMethod(t, *data, http.MethodGet, http.MethodPost) + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/pipeline/1", nil) + server, _ := createRouterAndApi(fakeClient{listPipelineJobs: listPipelineJobsErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not get pipeline jobs") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/pipeline/1", nil) + server, _ := createRouterAndApi(fakeClient{listPipelineJobs: listPipelineJobsNon200}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not get pipeline jobs", "/pipeline") + }) + + t.Run("Retriggers pipeline", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/pipeline/1", nil) + server, _ := createRouterAndApi(fakeClient{retryPipelineBuild: retryPipelineBuild}) + data := serveRequest(t, server, request, GetJobsResponse{}) + assert(t, data.SuccessResponse.Message, "Pipeline retriggered") + assert(t, data.SuccessResponse.Status, http.StatusOK) + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/pipeline/1", nil) + server, _ := createRouterAndApi(fakeClient{retryPipelineBuild: retryPipelineBuildErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not retrigger pipeline") + }) + + t.Run("Handles non-200s from Gitlab client on retrigger", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/pipeline/1", nil) + server, _ := createRouterAndApi(fakeClient{retryPipelineBuild: retryPipelineBuildNon200}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not retrigger pipeline", "/pipeline") + }) +} diff --git a/cmd/reply.go b/cmd/reply.go index 29d8041..b4a1f43 100644 --- a/cmd/reply.go +++ b/cmd/reply.go @@ -2,8 +2,6 @@ package main import ( "encoding/json" - "errors" - "fmt" "io" "net/http" "time" @@ -21,36 +19,18 @@ type ReplyResponse struct { Note *gitlab.Note `json:"note"` } -func (c *Client) Reply(r ReplyRequest) (*gitlab.Note, int, error) { - - now := time.Now() - options := gitlab.AddMergeRequestDiscussionNoteOptions{ - Body: gitlab.String(r.Reply), - CreatedAt: &now, - } - - note, res, err := c.git.Discussions.AddMergeRequestDiscussionNote(c.projectId, c.mergeId, r.DiscussionId, &options) - - if err != nil { - return nil, res.Response.StatusCode, fmt.Errorf("Could not leave reply: %w", err) - } - - return note, http.StatusOK, nil -} - -func ReplyHandler(w http.ResponseWriter, r *http.Request) { - c := r.Context().Value("client").(Client) +/* replyHandler sends a reply to a note or comment */ +func (a *api) replyHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodPost { - w.Header().Set("Allow", http.MethodPost) - c.handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) + w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) + handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) return } body, err := io.ReadAll(r.Body) if err != nil { - c.handleError(w, err, "Could not read request body", http.StatusBadRequest) + handleError(w, err, "Could not read request body", http.StatusBadRequest) return } @@ -59,21 +39,32 @@ func ReplyHandler(w http.ResponseWriter, r *http.Request) { err = json.Unmarshal(body, &replyRequest) if err != nil { - c.handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) + handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) return } - note, status, err := c.Reply(replyRequest) + now := time.Now() + options := gitlab.AddMergeRequestDiscussionNoteOptions{ + Body: gitlab.String(replyRequest.Reply), + CreatedAt: &now, + } + + note, res, err := a.client.AddMergeRequestDiscussionNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, replyRequest.DiscussionId, &options) if err != nil { - c.handleError(w, err, "Could not send reply", status) + handleError(w, err, "Could not leave reply", http.StatusInternalServerError) return } - w.WriteHeader(status) + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/reply"}, "Could not leave reply", res.StatusCode) + return + } + + w.WriteHeader(http.StatusOK) response := ReplyResponse{ SuccessResponse: SuccessResponse{ - Message: fmt.Sprintf("Replied: %s", note.Body), + Message: "Replied to comment", Status: http.StatusOK, }, Note: note, @@ -81,6 +72,6 @@ func ReplyHandler(w http.ResponseWriter, r *http.Request) { err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } diff --git a/cmd/reply_test.go b/cmd/reply_test.go new file mode 100644 index 0000000..9639cee --- /dev/null +++ b/cmd/reply_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "errors" + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +func addMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { + return &gitlab.Note{}, makeResponse(http.StatusOK), nil +} + +func addMergeRequestDiscussionNoteErr(pid interface{}, mergeRequest int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { + return nil, nil, errors.New("Some error from Gitlab") +} + +func addMergeRequestDiscussionNoteNon200(pid interface{}, mergeRequest int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { + return nil, makeResponse(http.StatusSeeOther), nil +} + +func TestReplyHandler(t *testing.T) { + t.Run("Sends a reply", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/reply", ReplyRequest{}) + server, _ := createRouterAndApi(fakeClient{addMergeRequestDiscussionNote: addMergeRequestDiscussionNote}) + data := serveRequest(t, server, request, ReplyResponse{}) + assert(t, data.SuccessResponse.Message, "Replied to comment") + assert(t, data.SuccessResponse.Status, http.StatusOK) + }) + + t.Run("Disallows non-POST methods", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/reply", ReplyRequest{}) + server, _ := createRouterAndApi(fakeClient{addMergeRequestDiscussionNote: addMergeRequestDiscussionNote}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkBadMethod(t, *data, http.MethodPost) + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/reply", ReplyRequest{}) + server, _ := createRouterAndApi(fakeClient{addMergeRequestDiscussionNote: addMergeRequestDiscussionNoteErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not leave reply") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/reply", ReplyRequest{}) + server, _ := createRouterAndApi(fakeClient{addMergeRequestDiscussionNote: addMergeRequestDiscussionNoteNon200}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not leave reply", "/reply") + }) +} diff --git a/cmd/resolve_discussion.go b/cmd/resolve_discussion.go index 5e4c3c1..04b0fa3 100644 --- a/cmd/resolve_discussion.go +++ b/cmd/resolve_discussion.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "io" "net/http" @@ -13,57 +14,61 @@ type DiscussionResolveRequest struct { Resolved bool `json:"resolved"` } -func DiscussionResolveHandler(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPut: - DiscussionResolve(w, r) - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } -} -func DiscussionResolve(w http.ResponseWriter, r *http.Request) { +/* discussionsResolveHandler sets a discussion to be "resolved" or not resolved, depending on the payload */ +func (a *api) discussionsResolveHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - c := r.Context().Value("client").(Client) + if r.Method != http.MethodPut { + w.Header().Set("Access-Control-Allow-Methods", http.MethodPut) + handleError(w, InvalidRequestError{}, "Expected PUT", http.StatusMethodNotAllowed) + return + } body, err := io.ReadAll(r.Body) if err != nil { - c.handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - defer r.Body.Close() - var resolveDiscussionRequest DiscussionResolveRequest - err = json.Unmarshal(body, &resolveDiscussionRequest) - if err != nil { - c.handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) + handleError(w, err, "Could not read request body", http.StatusBadRequest) return } - _, res, err := c.git.Discussions.ResolveMergeRequestDiscussion( - c.projectId, - c.mergeId, + defer r.Body.Close() + + var resolveDiscussionRequest DiscussionResolveRequest + err = json.Unmarshal(body, &resolveDiscussionRequest) + + if err != nil { + handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) + return + } + + _, res, err := a.client.ResolveMergeRequestDiscussion( + a.projectInfo.ProjectId, + a.projectInfo.MergeId, resolveDiscussionRequest.DiscussionID, &gitlab.ResolveMergeRequestDiscussionOptions{Resolved: &resolveDiscussionRequest.Resolved}, ) + friendlyName := "unresolve" + if resolveDiscussionRequest.Resolved { + friendlyName = "resolve" + } + if err != nil { - c.handleError(w, err, "Could not update resolve status of discussion", res.StatusCode) + handleError(w, err, fmt.Sprintf("Could not %s discussion", friendlyName), http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/discussions/resolve"}, fmt.Sprintf("Could not %s discussion", friendlyName), res.StatusCode) return } w.WriteHeader(http.StatusOK) - var message string - if resolveDiscussionRequest.Resolved { - message = "Discussion resolved" - } else { - message = "Discussion unresolved" - } response := SuccessResponse{ - Message: message, + Message: fmt.Sprintf("Discussion %sd", friendlyName), Status: http.StatusOK, } err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } diff --git a/cmd/reviewer.go b/cmd/reviewer.go index af81f00..78b1779 100644 --- a/cmd/reviewer.go +++ b/cmd/reviewer.go @@ -22,13 +22,18 @@ type ReviewersRequestResponse struct { Reviewers []int `json:"reviewers"` } -func ReviewersHandler(w http.ResponseWriter, r *http.Request) { - c := r.Context().Value("client").(Client) +/* reviewersHandler adds or removes reviewers from an MR */ +func (a *api) reviewersHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPut { + w.Header().Set("Access-Control-Allow-Methods", http.MethodPut) + handleError(w, InvalidRequestError{}, "Expected PUT", http.StatusMethodNotAllowed) + return + } body, err := io.ReadAll(r.Body) if err != nil { - c.handleError(w, err, "Could not read request body", http.StatusBadRequest) + handleError(w, err, "Could not read request body", http.StatusBadRequest) return } @@ -37,26 +42,25 @@ func ReviewersHandler(w http.ResponseWriter, r *http.Request) { err = json.Unmarshal(body, &reviewerUpdateRequest) if err != nil { - c.handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) + handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) return } - mr, res, err := c.git.MergeRequests.UpdateMergeRequest(c.projectId, c.mergeId, &gitlab.UpdateMergeRequestOptions{ + mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{ ReviewerIDs: &reviewerUpdateRequest.Ids, }) if err != nil { - c.handleError(w, err, "Could not modify merge request reviewers", http.StatusBadRequest) + handleError(w, err, "Could not modify merge request reviewers", http.StatusInternalServerError) return } - if res.StatusCode != http.StatusOK { - c.handleError(w, err, "Could not modify merge request reviewers", http.StatusBadRequest) + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/mr/reviewer"}, "Could not modify merge request reviewers", res.StatusCode) return } w.WriteHeader(http.StatusOK) - response := ReviewerUpdateResponse{ SuccessResponse: SuccessResponse{ Message: "Reviewers updated", @@ -67,6 +71,6 @@ func ReviewersHandler(w http.ResponseWriter, r *http.Request) { err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } diff --git a/cmd/revisions.go b/cmd/revisions.go index b870e8e..f85498d 100644 --- a/cmd/revisions.go +++ b/cmd/revisions.go @@ -2,7 +2,6 @@ package main import ( "encoding/json" - "errors" "net/http" "github.com/xanzy/go-gitlab" @@ -13,20 +12,27 @@ type RevisionsResponse struct { Revisions []*gitlab.MergeRequestDiffVersion } -func RevisionsHandler(w http.ResponseWriter, r *http.Request) { - c := r.Context().Value("client").(Client) +/* +revisionsHandler gets revision information about the current MR. This data is not used directly but is +a precursor API call for other functionality +*/ +func (a *api) revisionsHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodGet { - w.Header().Set("Allow", http.MethodGet) - c.handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) - w.WriteHeader(http.StatusMethodNotAllowed) + w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) + handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed) return } - versionInfo, _, err := c.git.MergeRequests.GetMergeRequestDiffVersions(c.projectId, c.mergeId, &gitlab.GetMergeRequestDiffVersionsOptions{}) + versionInfo, res, err := a.client.GetMergeRequestDiffVersions(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.GetMergeRequestDiffVersionsOptions{}) if err != nil { - c.handleError(w, err, "Could not get diff version info", http.StatusBadRequest) + handleError(w, err, "Could not get diff version info", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/mr/revisions"}, "Could not get diff version info", res.StatusCode) + return } w.WriteHeader(http.StatusOK) @@ -40,7 +46,7 @@ func RevisionsHandler(w http.ResponseWriter, r *http.Request) { err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } diff --git a/cmd/revoke.go b/cmd/revoke.go index b42e9ab..c6dc293 100644 --- a/cmd/revoke.go +++ b/cmd/revoke.go @@ -2,37 +2,38 @@ package main import ( "encoding/json" - "errors" "net/http" ) -func RevokeHandler(w http.ResponseWriter, r *http.Request) { - c := r.Context().Value("client").(Client) +/* revokeHandler revokes approval for the current merge request */ +func (a *api) revokeHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodPost { - w.Header().Set("Allow", http.MethodPost) - c.handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) - w.WriteHeader(http.StatusMethodNotAllowed) + w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) + handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) return } - res, err := c.git.MergeRequestApprovals.UnapproveMergeRequest(c.projectId, c.mergeId, nil, nil) + res, err := a.client.UnapproveMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, nil, nil) if err != nil { - c.handleError(w, err, "Could not revoke approval", http.StatusBadRequest) + handleError(w, err, "Could not revoke approval", http.StatusInternalServerError) return } - /* TODO: Check for non-200 status codes */ - w.WriteHeader(res.StatusCode) + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/revoke"}, "Could not revoke approval", res.StatusCode) + return + } + + w.WriteHeader(http.StatusOK) response := SuccessResponse{ - Message: "Success! Revoked MR approval.", + Message: "Success! Revoked MR approval", Status: http.StatusOK, } err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } diff --git a/cmd/server.go b/cmd/server.go new file mode 100644 index 0000000..75b137a --- /dev/null +++ b/cmd/server.go @@ -0,0 +1,154 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os" + "time" +) + +/* +startSever starts the server and runs concurrent goroutines +to handle potential shutdown requests and incoming HTTP requests. +*/ +func startServer(client *Client, projectInfo *ProjectInfo) { + + m, a := createRouterAndApi(client, + func(a *api) error { + a.projectInfo = projectInfo + return nil + }, + func(a *api) error { + a.fileReader = attachmentReader{} + return nil + }) + + l := createListener() + server := &http.Server{Handler: m} + + /* Starts the Go server */ + go func() { + err := server.Serve(l) + if err != nil { + fmt.Fprintf(os.Stderr, "Error starting server: %s\n", err) + os.Exit(1) + } + }() + + port := l.Addr().(*net.TCPAddr).Port + err := checkServer(port) + if err != nil { + fmt.Fprintf(os.Stderr, "Server did not respond: %s\n", err) + os.Exit(1) + } + + /* This print is detected by the Lua code */ + fmt.Println("Server started on port: ", port) + + /* Handles shutdown requests */ + <-a.sigCh + err = server.Shutdown(context.Background()) + if err != nil { + fmt.Fprintf(os.Stderr, "Server could not shut down gracefully: %s\n", err) + os.Exit(1) + } else { + os.Exit(0) + } +} + +/* +The api struct contains common configuration that's accessible to all handlers, such as the gitlab +client, the project information, and the channels for signaling error or shutdown requests + +The handlers for different Gitlab operations are are all methods on the api struct and interact +with the client value, which is a go-gitlab client. +*/ +type api struct { + client ClientInterface + projectInfo *ProjectInfo + fileReader FileReader + sigCh chan os.Signal +} + +type optFunc func(a *api) error + +/* +createRouterAndApi wires up the router and attaches all handlers to their respective routes. It also +iterates over all option functions to configure API fields such as the project information and default +file reader functionality +*/ +func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.ServeMux, api) { + m := http.NewServeMux() + a := api{ + client: client, + projectInfo: &ProjectInfo{}, + fileReader: nil, + sigCh: make(chan os.Signal, 1), + } + + /* Mutates the API struct as necessary with configuration functions */ + for _, optFunc := range optFuncs { + err := optFunc(&a) + if err != nil { + panic(err) + } + } + + m.Handle("/ping", http.HandlerFunc(pingHandler)) + m.HandleFunc("/shutdown", a.shutdownHandler) + m.HandleFunc("/approve", a.approveHandler) + m.HandleFunc("/comment", a.commentHandler) + m.HandleFunc("/discussions/list", a.listDiscussionsHandler) + m.HandleFunc("/discussions/resolve", a.discussionsResolveHandler) + m.HandleFunc("/info", a.infoHandler) + m.HandleFunc("/job", a.jobHandler) + m.HandleFunc("/mr/attachment", a.attachmentHandler) + m.HandleFunc("/mr/assignee", a.assigneesHandler) + m.HandleFunc("/mr/summary", a.summaryHandler) + m.HandleFunc("/mr/reviewer", a.reviewersHandler) + m.HandleFunc("/mr/revisions", a.revisionsHandler) + m.HandleFunc("/pipeline/", a.pipelineHandler) + m.HandleFunc("/project/members", a.projectMembersHandler) + m.HandleFunc("/reply", a.replyHandler) + m.HandleFunc("/revoke", a.revokeHandler) + + return m, a +} + +/* Used to check whether the server has started yet */ +func pingHandler(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "pong") +} + +/* checkServer pings the server repeatedly for 1 full second after startup in order to notify the plugin that the server is ready */ +func checkServer(port int) error { + for i := 0; i < 10; i++ { + resp, err := http.Get("http://localhost:" + fmt.Sprintf("%d", port) + "/ping") + if resp.StatusCode == 200 && err == nil { + return nil + } + time.Sleep(100 * time.Microsecond) + } + + return errors.New("Could not start server!") +} + +/* Creates a TCP listener on the port specified by the user or a random port */ +func createListener() (l net.Listener) { + port := os.Args[2] + if port == "" { + port = "0" + } + addr := fmt.Sprintf("localhost:%s", port) + l, err := net.Listen("tcp", addr) + if err != nil { + fmt.Fprintf(os.Stderr, "Error starting server: %s\n", err) + os.Exit(1) + } + + return l +} diff --git a/cmd/shutdown.go b/cmd/shutdown.go new file mode 100644 index 0000000..35b121f --- /dev/null +++ b/cmd/shutdown.go @@ -0,0 +1,59 @@ +package main + +import ( + "encoding/json" + "errors" + "io" + "net/http" +) + +type killer struct{} + +func (k killer) Signal() {} +func (k killer) String() string { + return "0" +} + +type ShutdownRequest struct { + Restart bool `json:"restart"` +} + +/* shutdownHandler will shutdown the HTTP server and exit the process by signaling to the shutdown channel */ +func (a *api) shutdownHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + handleError(w, err, "Could not read request body", http.StatusBadRequest) + return + } + + var shutdownRequest ShutdownRequest + err = json.Unmarshal(body, &shutdownRequest) + if err != nil { + handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest) + return + } + + var text = "Shut down server" + if shutdownRequest.Restart { + text = "Restarted server" + } + + w.WriteHeader(http.StatusOK) + response := SuccessResponse{ + Message: text, + Status: http.StatusOK, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } else { + a.sigCh <- killer{} + } +} diff --git a/cmd/start.go b/cmd/start.go deleted file mode 100644 index d589efc..0000000 --- a/cmd/start.go +++ /dev/null @@ -1,12 +0,0 @@ -package main - -import ( - "fmt" - "os" -) - -func (c *Client) Start() error { - processId := os.Getpid() - fmt.Println(processId) - return nil -} diff --git a/cmd/description.go b/cmd/summary.go similarity index 54% rename from cmd/description.go rename to cmd/summary.go index 447ad9b..0147294 100644 --- a/cmd/description.go +++ b/cmd/summary.go @@ -2,7 +2,6 @@ package main import ( "encoding/json" - "errors" "io" "net/http" @@ -19,18 +18,18 @@ type SummaryUpdateResponse struct { MergeRequest *gitlab.MergeRequest `json:"mr"` } -func SummaryHandler(w http.ResponseWriter, r *http.Request) { - c := r.Context().Value("client").(Client) +func (a *api) summaryHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPut { - w.Header().Set("Allow", http.MethodPut) - c.handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) + w.Header().Set("Access-Control-Allow-Methods", http.MethodPut) + handleError(w, InvalidRequestError{}, "Expected PUT", http.StatusMethodNotAllowed) return } body, err := io.ReadAll(r.Body) if err != nil { - c.handleError(w, err, "Could not read request body", http.StatusBadRequest) + handleError(w, err, "Could not read request body", http.StatusBadRequest) return } @@ -39,22 +38,22 @@ func SummaryHandler(w http.ResponseWriter, r *http.Request) { err = json.Unmarshal(body, &SummaryUpdateRequest) if err != nil { - c.handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) + handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) return } - mr, res, err := c.git.MergeRequests.UpdateMergeRequest(c.projectId, c.mergeId, &gitlab.UpdateMergeRequestOptions{ + mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{ Description: &SummaryUpdateRequest.Description, Title: &SummaryUpdateRequest.Title, }) if err != nil { - c.handleError(w, err, "Could not edit merge request summary", http.StatusBadRequest) + handleError(w, err, "Could not edit merge request summary", http.StatusInternalServerError) return } - if res.StatusCode != http.StatusOK { - c.handleError(w, err, "Could not edit merge request summary", http.StatusBadRequest) + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/summary"}, "Could not edit merge request summary", res.StatusCode) return } @@ -70,7 +69,7 @@ func SummaryHandler(w http.ResponseWriter, r *http.Request) { err = json.NewEncoder(w).Encode(response) if err != nil { - c.handleError(w, err, "Could not encode response", http.StatusInternalServerError) + handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } diff --git a/cmd/test.go b/cmd/test.go new file mode 100644 index 0000000..bb924a6 --- /dev/null +++ b/cmd/test.go @@ -0,0 +1,216 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/xanzy/go-gitlab" +) + +/* +The FakeHandlerClient is used to create a fake gitlab client for testing our handlers, where the gitlab APIs are all mocked depending on what is provided during the variable initialization, so that we can simulate different responses from Gitlab +*/ + +type fakeClient struct { + getMergeRequestFn func(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) + updateMergeRequestFn func(pid interface{}, mergeRequest int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) + unapprorveMergeRequestFn func(pid interface{}, mr int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + uploadFile func(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) + getMergeRequestDiffVersions func(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error) + approveMergeRequest func(pid interface{}, mr int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) + listMergeRequestDiscussions func(pid interface{}, mergeRequest int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) + resolveMergeRequestDiscussion func(pid interface{}, mergeRequest int, discussion string, opt *gitlab.ResolveMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) + createMergeRequestDiscussion func(pid interface{}, mergeRequest int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) + updateMergeRequestDiscussionNote func(pid interface{}, mergeRequest int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) + deleteMergeRequestDiscussionNote func(pid interface{}, mergeRequest int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + addMergeRequestDiscussionNote func(pid interface{}, mergeRequest int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) + listAllProjectMembers func(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) + retryPipelineBuild func(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) + listPipelineJobs func(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) + getTraceFile func(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) +} + +type Author struct { + ID int `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Name string `json:"name"` + State string `json:"state"` + AvatarURL string `json:"avatar_url"` + WebURL string `json:"web_url"` +} + +func (f fakeClient) GetMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + return f.getMergeRequestFn(pid, mergeRequest, opt, options...) +} + +func (f fakeClient) UpdateMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + return f.updateMergeRequestFn(pid, mergeRequest, opt, options...) +} + +func (f fakeClient) UnapproveMergeRequest(pid interface{}, mr int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return f.unapprorveMergeRequestFn(pid, mr, options...) +} + +func (f fakeClient) UploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) { + return f.uploadFile(pid, content, filename, options...) +} + +func (f fakeClient) GetMergeRequestDiffVersions(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error) { + return f.getMergeRequestDiffVersions(pid, mergeRequest, opt, options...) +} + +func (f fakeClient) ApproveMergeRequest(pid interface{}, mr int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) { + return f.approveMergeRequest(pid, mr, opt, options...) +} + +func (f fakeClient) ListMergeRequestDiscussions(pid interface{}, mergeRequest int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) { + return f.listMergeRequestDiscussions(pid, mergeRequest, opt, options...) + + // now := time.Now() + // later := now.Add(time.Second * 100) + // + // discussions := []*gitlab.Discussion{ + // { + // Notes: []*gitlab.Note{ + // { + // CreatedAt: &now, + // Type: "DiffNote", + // Author: Author{ + // Username: "hcramer", + // }, + // }, + // }, + // }, + // { + // Notes: []*gitlab.Note{ + // { + // CreatedAt: &later, + // Type: "DiffNote", + // Author: Author{ + // Username: "hcramer2", + // }, + // }, + // }, + // }, + // } + // return discussions, makeResponse(200), nil +} + +func (f fakeClient) ResolveMergeRequestDiscussion(pid interface{}, mergeRequest int, discussion string, opt *gitlab.ResolveMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) { + return f.resolveMergeRequestDiscussion(pid, mergeRequest, discussion, opt, options...) +} + +func (f fakeClient) CreateMergeRequestDiscussion(pid interface{}, mergeRequest int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) { + return f.createMergeRequestDiscussion(pid, mergeRequest, opt, options...) +} + +func (f fakeClient) UpdateMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { + return f.updateMergeRequestDiscussionNote(pid, mergeRequest, discussion, note, opt, options...) +} + +func (f fakeClient) DeleteMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return f.deleteMergeRequestDiscussionNote(pid, mergeRequest, discussion, note, options...) +} + +func (f fakeClient) AddMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { + return f.addMergeRequestDiscussionNote(pid, mergeRequest, discussion, opt, options...) +} + +func (f fakeClient) ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) { + return f.listAllProjectMembers(pid, opt, options...) +} + +func (f fakeClient) RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) { + return f.retryPipelineBuild(pid, pipeline, options...) +} + +func (f fakeClient) ListPipelineJobs(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) { + return f.listPipelineJobs(pid, pipelineID, opts, options...) +} + +func (f fakeClient) GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) { + return f.getTraceFile(pid, jobID, options...) +} + +/* The assert function is a helper function used to check two comparables */ +func assert[T comparable](t *testing.T, got T, want T) { + t.Helper() + if got != want { + t.Errorf("Got '%v' but wanted '%v'", got, want) + } +} + +/* Will create a new request with the given method, endpoint and body */ +func makeRequest(t *testing.T, method string, endpoint string, body any) *http.Request { + t.Helper() + + var reader io.Reader + if body != nil { + j, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + reader = bytes.NewReader(j) + } + + request, err := http.NewRequest(method, endpoint, reader) + if err != nil { + t.Fatal(err) + } + + return request +} + +/* Serves and parses the JSON from an endpoint into the given type */ +func serveRequest[T any](t *testing.T, s *http.ServeMux, request *http.Request, i T) *T { + t.Helper() + recorder := httptest.NewRecorder() + s.ServeHTTP(recorder, request) + result := recorder.Result() + decoder := json.NewDecoder(result.Body) + err := decoder.Decode(&i) + if err != nil { + t.Fatal(err) + return nil + } + + return &i +} + +/* Make response makes a simple response value with the right status code */ +func makeResponse(status int) *gitlab.Response { + return &gitlab.Response{ + Response: &http.Response{ + StatusCode: status, + }, + } +} + +func checkErrorFromGitlab(t *testing.T, data ErrorResponse, msg string) { + t.Helper() + assert(t, data.Status, http.StatusInternalServerError) + assert(t, data.Message, msg) + assert(t, data.Details, "Some error from Gitlab") +} + +func checkBadMethod(t *testing.T, data ErrorResponse, methods ...string) { + t.Helper() + assert(t, data.Status, http.StatusMethodNotAllowed) + assert(t, data.Details, "Invalid request type") + expectedMethods := strings.Join(methods, " or ") + assert(t, data.Message, fmt.Sprintf("Expected %s", expectedMethods)) +} + +func checkNon200(t *testing.T, data ErrorResponse, msg, endpoint string) { + t.Helper() + assert(t, data.Status, http.StatusSeeOther) + assert(t, data.Message, msg) + assert(t, data.Details, fmt.Sprintf("An error occurred on the %s endpoint", endpoint)) +} diff --git a/cmd/types.go b/cmd/types.go index 3be6fe7..50ea17d 100644 --- a/cmd/types.go +++ b/cmd/types.go @@ -1,5 +1,13 @@ package main +import ( + "bytes" + "fmt" + "io" + + "github.com/xanzy/go-gitlab" +) + type ErrorResponse struct { Message string `json:"message"` Details string `json:"details"` @@ -10,3 +18,37 @@ type SuccessResponse struct { Message string `json:"message"` Status int `json:"status"` } + +type GenericError struct { + endpoint string +} + +func (e GenericError) Error() string { + return fmt.Sprintf("An error occurred on the %s endpoint", e.endpoint) +} + +type InvalidRequestError struct{} + +func (e InvalidRequestError) Error() string { + return "Invalid request type" +} + +/* The ClientInterface interface implements all the methods that our handlers need */ +type ClientInterface interface { + GetMergeRequest(pid interface{}, mr int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) + UpdateMergeRequest(pid interface{}, mr int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) + UploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) + GetMergeRequestDiffVersions(pid interface{}, mr int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error) + ApproveMergeRequest(pid interface{}, mr int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) + UnapproveMergeRequest(pid interface{}, mr int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + ListMergeRequestDiscussions(pid interface{}, mergeRequest int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) + ResolveMergeRequestDiscussion(pid interface{}, mergeRequest int, discussion string, opt *gitlab.ResolveMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) + CreateMergeRequestDiscussion(pid interface{}, mergeRequest int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) + UpdateMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) + DeleteMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + AddMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) + ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) + RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) + ListPipelineJobs(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) + GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) +} diff --git a/lua/gitlab/actions/discussions.lua b/lua/gitlab/actions/discussions.lua index bb05039..2d95817 100644 --- a/lua/gitlab/actions/discussions.lua +++ b/lua/gitlab/actions/discussions.lua @@ -101,7 +101,7 @@ local M = { ---callback with data ---@param callback fun(data: DiscussionData): nil M.load_discussions = function(callback) - job.run_job("/discussions", "POST", { blacklist = state.settings.discussion_tree.blacklist }, function(data) + job.run_job("/discussions/list", "POST", { blacklist = state.settings.discussion_tree.blacklist }, function(data) M.discussions = data.discussions M.unlinked_discussions = data.unlinked_discussions callback(data) @@ -645,7 +645,7 @@ M.toggle_discussion_resolved = function(tree) resolved = not note.resolved, } - job.run_job("/discussion/resolve", "PUT", body, function(data) + job.run_job("/discussions/resolve", "PUT", body, function(data) u.notify(data.message, vim.log.levels.INFO) M.redraw_resolved_status(tree, note, not note.resolved) end) diff --git a/lua/gitlab/actions/pipeline.lua b/lua/gitlab/actions/pipeline.lua index 03ac662..2407210 100644 --- a/lua/gitlab/actions/pipeline.lua +++ b/lua/gitlab/actions/pipeline.lua @@ -34,8 +34,8 @@ M.open = function() if not pipeline then return end - local body = { pipeline_id = pipeline.id } - job.run_job("/pipeline", "GET", body, function(data) + + job.run_job("/pipeline/" .. pipeline.id, "GET", nil, function(data) local pipeline_jobs = u.reverse(type(data.Jobs) == "table" and data.Jobs or {}) M.pipeline_jobs = pipeline_jobs @@ -92,13 +92,12 @@ M.retrigger = function() if not pipeline then return end - local body = { pipeline_id = pipeline.id } if pipeline.status ~= "failed" then u.notify("Pipeline is not in a failed state!", vim.log.levels.WARN) return end - job.run_job("/pipeline", "POST", body, function() + job.run_job("/pipeline/" .. pipeline.id, "POST", nil, function() u.notify("Pipeline re-triggered!", vim.log.levels.INFO) end) end diff --git a/lua/gitlab/async.lua b/lua/gitlab/async.lua index 8f9b26f..0570d23 100644 --- a/lua/gitlab/async.lua +++ b/lua/gitlab/async.lua @@ -29,12 +29,13 @@ function async:fetch(dependencies, i, argTable) local dependency = dependencies[i] - -- Do not call endpoint unless refresh is required + -- If we have data already and refresh is not required, skip this API call if state[dependency.state] ~= nil and not dependency.refresh then self:fetch(dependencies, i + 1, argTable) return end + -- Call the API, set the data, and then call the next API job.run_job(dependency.endpoint, "GET", dependency.body, function(data) state[dependency.state] = data[dependency.key] self:fetch(dependencies, i + 1, argTable) @@ -54,11 +55,13 @@ M.sequence = function(dependencies, cb) end end + -- If go server is already running, then start fetching the values in sequence if state.go_server_running then handler:fetch(dependencies, 1, argTable) return end + -- Otherwise, start the go server and start fetching the values server.start(function() state.go_server_running = true handler:fetch(dependencies, 1, argTable) diff --git a/lua/gitlab/server.lua b/lua/gitlab/server.lua index 6be0baa..53f9ac0 100644 --- a/lua/gitlab/server.lua +++ b/lua/gitlab/server.lua @@ -3,6 +3,7 @@ -- to Gitlab and returning the data local state = require("gitlab.state") local u = require("gitlab.utils") +local job = require("gitlab.job") local M = {} -- Starts the Go server and call the callback provided @@ -60,7 +61,12 @@ M.start = function(callback) end end, on_exit = function(job_id, exit_code) - u.notify("Golang gitlab server exited: job_id: " .. job_id .. ", exit_code: " .. exit_code, vim.log.levels.ERROR) + if exit_code ~= 0 then + u.notify( + "Golang gitlab server exited: job_id: " .. job_id .. ", exit_code: " .. exit_code, + vim.log.levels.ERROR + ) + end end, }) if job_id <= 0 then @@ -95,4 +101,41 @@ M.build = function(override) return true end +-- Shuts down the Go server and clears out all old gitlab.nvim state +M.shutdown = function(cb) + if not state.go_server_running then + vim.notify("The gitlab.nvim server is not running", vim.log.levels.ERROR) + return + end + job.run_job("/shutdown", "POST", { restart = false }, function(data) + state.go_server_running = false + state.clear_data() + if cb then + cb() + else + u.notify(data.message, vim.log.levels.INFO) + end + end) +end + +-- Restarts the Go server and clears out all gitlab.nvim state +M.restart = function(cb) + if not state.go_server_running then + vim.notify("The gitlab.nvim server is not running", vim.log.levels.ERROR) + return + end + job.run_job("/shutdown", "POST", { restart = true }, function(data) + state.go_server_running = false + M.start(function() + state.go_server_running = true + state.clear_data() + if cb then + cb() + else + u.notify(data.message, vim.log.levels.INFO) + end + end) + end) +end + return M diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index c04fcfc..bda39e3 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -235,7 +235,21 @@ end M.dependencies = { info = { endpoint = "/info", key = "info", state = "INFO", refresh = false }, revisions = { endpoint = "/mr/revisions", key = "Revisions", state = "MR_REVISIONS", refresh = false }, - project_members = { endpoint = "/members", key = "ProjectMembers", state = "PROJECT_MEMBERS", refresh = false }, + project_members = { + endpoint = "/project/members", + key = "ProjectMembers", + state = "PROJECT_MEMBERS", + refresh = false, + }, } +-- This function clears out all of the previously fetched data. It's used +-- to reset the plugin state when the Go server is restarted +M.clear_data = function() + M.INFO = nil + for _, dep in ipairs(M.dependencies) do + M[dep.state] = nil + end +end + return M