Fix MR Selection, Go Code Refactor (#358)
refactor: Refactors the Go codebase into a more modular and idiomatic approach fix: require selection of specific MR when there are multiple targets for a given source branch feat: Allows for the passing of Gitlab's filter options when choosing an MR, improves MR selection feat: API to choose an MR from a list based on the provided username's involvement as an assignee/reviewer/author This is a #MINOR release
This commit is contained in:
committed by
GitHub
parent
6500ef1f2c
commit
ea2b2b2f5c
50
cmd/app/approve.go
Normal file
50
cmd/app/approve.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type MergeRequestApprover interface {
|
||||
ApproveMergeRequest(pid interface{}, mr int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type mergeRequestApproverService struct {
|
||||
data
|
||||
client MergeRequestApprover
|
||||
}
|
||||
|
||||
/* approveHandler approves a merge request. */
|
||||
func (a mergeRequestApproverService) handler(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
|
||||
}
|
||||
|
||||
_, res, err := a.client.ApproveMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, nil, nil)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not approve merge request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/mr/approve"}, "Could not approve merge request", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := SuccessResponse{
|
||||
Message: "Approved MR",
|
||||
Status: http.StatusOK,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
55
cmd/app/approve_test.go
Normal file
55
cmd/app/approve_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type fakeApproverClient struct {
|
||||
testBase
|
||||
}
|
||||
|
||||
func (f fakeApproverClient) ApproveMergeRequest(pid interface{}, mr int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &gitlab.MergeRequestApprovals{}, resp, nil
|
||||
}
|
||||
|
||||
func TestApproveHandler(t *testing.T) {
|
||||
t.Run("Approves merge request", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/approve", nil)
|
||||
client := fakeApproverClient{}
|
||||
svc := mergeRequestApproverService{testProjectData, client}
|
||||
data := getSuccessData(t, svc, request)
|
||||
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.MethodGet, "/mr/approve", nil)
|
||||
client := fakeApproverClient{}
|
||||
svc := mergeRequestApproverService{testProjectData, client}
|
||||
data := getFailData(t, svc, request)
|
||||
checkBadMethod(t, data, http.MethodPost)
|
||||
})
|
||||
|
||||
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/approve", nil)
|
||||
client := fakeApproverClient{testBase{errFromGitlab: true}}
|
||||
svc := mergeRequestApproverService{testProjectData, client}
|
||||
data := getFailData(t, svc, request)
|
||||
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, "/mr/approve", nil)
|
||||
client := fakeApproverClient{testBase{status: http.StatusSeeOther}}
|
||||
svc := mergeRequestApproverService{testProjectData, client}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not approve merge request", "/mr/approve")
|
||||
})
|
||||
}
|
||||
81
cmd/app/assignee.go
Normal file
81
cmd/app/assignee.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type AssigneeUpdateRequest struct {
|
||||
Ids []int `json:"ids"`
|
||||
}
|
||||
|
||||
type AssigneeUpdateResponse struct {
|
||||
SuccessResponse
|
||||
Assignees []*gitlab.BasicUser `json:"assignees"`
|
||||
}
|
||||
|
||||
type AssigneesRequestResponse struct {
|
||||
SuccessResponse
|
||||
Assignees []int `json:"assignees"`
|
||||
}
|
||||
|
||||
type assigneesService struct {
|
||||
data
|
||||
client MergeRequestUpdater
|
||||
}
|
||||
|
||||
/* assigneesHandler adds or removes assignees from a merge request. */
|
||||
func (a assigneesService) handler(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 {
|
||||
handleError(w, err, "Could not read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
var assigneeUpdateRequest AssigneeUpdateRequest
|
||||
err = json.Unmarshal(body, &assigneeUpdateRequest)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{
|
||||
AssigneeIDs: &assigneeUpdateRequest.Ids,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not modify merge request assignees", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
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",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
Assignees: mr.Assignees,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
57
cmd/app/assignee_test.go
Normal file
57
cmd/app/assignee_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type fakeAssigneeClient struct {
|
||||
testBase
|
||||
}
|
||||
|
||||
func (f fakeAssigneeClient) UpdateMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &gitlab.MergeRequest{}, resp, nil
|
||||
}
|
||||
|
||||
func TestAssigneeHandler(t *testing.T) {
|
||||
var updatePayload = AssigneeUpdateRequest{Ids: []int{1, 2}}
|
||||
|
||||
t.Run("Updates assignees", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPut, "/mr/assignee", updatePayload)
|
||||
client := fakeAssigneeClient{}
|
||||
svc := assigneesService{testProjectData, client}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Message, "Assignees updated")
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Disallows non-PUT method", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/mr/assignee", nil)
|
||||
client := fakeAssigneeClient{}
|
||||
svc := assigneesService{testProjectData, client}
|
||||
data := getFailData(t, svc, request)
|
||||
checkBadMethod(t, data, http.MethodPut)
|
||||
})
|
||||
|
||||
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPut, "/mr/approve", updatePayload)
|
||||
client := fakeAssigneeClient{testBase{errFromGitlab: true}}
|
||||
svc := assigneesService{testProjectData, client}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Could not modify merge request assignees")
|
||||
})
|
||||
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPut, "/mr/approve", updatePayload)
|
||||
client := fakeAssigneeClient{testBase{status: http.StatusSeeOther}}
|
||||
svc := assigneesService{testProjectData, client}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not modify merge request assignees", "/mr/assignee")
|
||||
})
|
||||
}
|
||||
116
cmd/app/attachment.go
Normal file
116
cmd/app/attachment.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type FileReader interface {
|
||||
ReadFile(path string) (io.Reader, error)
|
||||
}
|
||||
|
||||
type AttachmentRequest struct {
|
||||
FilePath string `json:"file_path"`
|
||||
FileName string `json:"file_name"`
|
||||
}
|
||||
|
||||
type AttachmentResponse struct {
|
||||
SuccessResponse
|
||||
Markdown string `json:"markdown"`
|
||||
Alt string `json:"alt"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type FileUploader interface {
|
||||
UploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type attachmentService struct {
|
||||
data
|
||||
fileReader FileReader
|
||||
client FileUploader
|
||||
}
|
||||
|
||||
/* attachmentHandler uploads an attachment (file, image, etc) to Gitlab and returns metadata about the upload. */
|
||||
func (a attachmentService) handler(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
|
||||
}
|
||||
|
||||
var attachmentRequest AttachmentRequest
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
err = json.Unmarshal(body, &attachmentRequest)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not unmarshal JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := a.fileReader.ReadFile(attachmentRequest.FilePath)
|
||||
if err != nil || file == nil {
|
||||
handleError(w, err, fmt.Sprintf("Could not read %s file", attachmentRequest.FileName), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/attachment"}, fmt.Sprintf("Could not upload %s to Gitlab", attachmentRequest.FileName), res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
response := AttachmentResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Status: http.StatusOK,
|
||||
Message: "File uploaded successfully",
|
||||
},
|
||||
Markdown: projectFile.Markdown,
|
||||
Alt: projectFile.Alt,
|
||||
Url: projectFile.URL,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
64
cmd/app/attachment_test.go
Normal file
64
cmd/app/attachment_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type fakeFileUploaderClient struct {
|
||||
testBase
|
||||
}
|
||||
|
||||
func (f fakeFileUploaderClient) UploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return &gitlab.ProjectFile{}, resp, nil
|
||||
}
|
||||
|
||||
type fakeFileReader struct{}
|
||||
|
||||
func (f fakeFileReader) ReadFile(path string) (io.Reader, error) {
|
||||
return &bytes.Reader{}, nil
|
||||
}
|
||||
|
||||
func TestAttachmentHandler(t *testing.T) {
|
||||
attachmentTestRequestData := AttachmentRequest{
|
||||
FileName: "some_file_name",
|
||||
FilePath: "some_file_path",
|
||||
}
|
||||
|
||||
t.Run("Returns 200-status response after upload", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData)
|
||||
svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
assert(t, data.Message, "File uploaded successfully")
|
||||
})
|
||||
|
||||
t.Run("Disallows non-POST method", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/attachment", nil)
|
||||
svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkBadMethod(t, data, http.MethodPost)
|
||||
})
|
||||
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData)
|
||||
svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
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, "/attachment", attachmentTestRequestData)
|
||||
svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not upload some_file_name to Gitlab", "/attachment")
|
||||
})
|
||||
}
|
||||
175
cmd/app/client.go
Normal file
175
cmd/app/client.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
|
||||
"github.com/harrisoncramer/gitlab.nvim/cmd/app/git"
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
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
|
||||
*gitlab.LabelsService
|
||||
*gitlab.AwardEmojiService
|
||||
*gitlab.UsersService
|
||||
*gitlab.DraftNotesService
|
||||
}
|
||||
|
||||
/* NewClient parses and validates the project settings and initializes the Gitlab client. */
|
||||
func NewClient() (error, *Client) {
|
||||
|
||||
if pluginOptions.GitlabUrl == "" {
|
||||
return errors.New("GitLab instance URL cannot be empty"), nil
|
||||
}
|
||||
|
||||
var apiCustUrl = fmt.Sprintf(pluginOptions.GitlabUrl + "/api/v4")
|
||||
|
||||
gitlabOptions := []gitlab.ClientOptionFunc{
|
||||
gitlab.WithBaseURL(apiCustUrl),
|
||||
}
|
||||
|
||||
if pluginOptions.Debug.Request {
|
||||
gitlabOptions = append(gitlabOptions, gitlab.WithRequestLogHook(requestLogger))
|
||||
}
|
||||
|
||||
if pluginOptions.Debug.Response {
|
||||
gitlabOptions = append(gitlabOptions, gitlab.WithResponseLogHook(responseLogger))
|
||||
}
|
||||
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: pluginOptions.ConnectionSettings.Insecure,
|
||||
},
|
||||
}
|
||||
|
||||
retryClient := retryablehttp.NewClient()
|
||||
retryClient.HTTPClient.Transport = tr
|
||||
gitlabOptions = append(gitlabOptions, gitlab.WithHTTPClient(retryClient.HTTPClient))
|
||||
|
||||
client, err := gitlab.NewClient(pluginOptions.AuthToken, gitlabOptions...)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create client: %v", err), nil
|
||||
}
|
||||
|
||||
return nil, &Client{
|
||||
MergeRequestsService: client.MergeRequests,
|
||||
MergeRequestApprovalsService: client.MergeRequestApprovals,
|
||||
DiscussionsService: client.Discussions,
|
||||
ProjectsService: client.Projects,
|
||||
ProjectMembersService: client.ProjectMembers,
|
||||
JobsService: client.Jobs,
|
||||
PipelinesService: client.Pipelines,
|
||||
LabelsService: client.Labels,
|
||||
AwardEmojiService: client.AwardEmoji,
|
||||
UsersService: client.Users,
|
||||
DraftNotesService: client.DraftNotes,
|
||||
}
|
||||
}
|
||||
|
||||
/* InitProjectSettings fetch the project ID using the client */
|
||||
func InitProjectSettings(c *Client, gitInfo git.GitData) (error, *ProjectInfo) {
|
||||
|
||||
opt := gitlab.GetProjectOptions{}
|
||||
project, _, err := c.GetProject(gitInfo.ProjectPath(), &opt)
|
||||
|
||||
if err != nil {
|
||||
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", gitInfo.RemoteUrl), err), nil
|
||||
}
|
||||
|
||||
projectId := fmt.Sprint(project.ID)
|
||||
|
||||
return nil, &ProjectInfo{
|
||||
ProjectId: projectId,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* 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,
|
||||
Details: err.Error(),
|
||||
Status: status,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode error response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
var requestLogger retryablehttp.RequestLogHook = func(l retryablehttp.Logger, r *http.Request, i int) {
|
||||
file := openLogFile()
|
||||
defer file.Close()
|
||||
|
||||
token := r.Header.Get("Private-Token")
|
||||
r.Header.Set("Private-Token", "REDACTED")
|
||||
res, err := httputil.DumpRequest(r, true)
|
||||
if err != nil {
|
||||
log.Fatalf("Error dumping request: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
r.Header.Set("Private-Token", token)
|
||||
|
||||
_, err = file.Write([]byte("\n-- REQUEST --\n")) //nolint:all
|
||||
_, err = file.Write(res) //nolint:all
|
||||
_, err = file.Write([]byte("\n")) //nolint:all
|
||||
}
|
||||
|
||||
var responseLogger retryablehttp.ResponseLogHook = func(l retryablehttp.Logger, response *http.Response) {
|
||||
file := openLogFile()
|
||||
defer file.Close()
|
||||
|
||||
res, err := httputil.DumpResponse(response, true)
|
||||
if err != nil {
|
||||
log.Fatalf("Error dumping response: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
_, err = file.Write([]byte("\n-- RESPONSE --\n")) //nolint:all
|
||||
_, err = file.Write(res) //nolint:all
|
||||
_, err = file.Write([]byte("\n")) //nolint:all
|
||||
}
|
||||
|
||||
func openLogFile() *os.File {
|
||||
file, err := os.OpenFile(pluginOptions.LogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("Log file %s does not exist", pluginOptions.LogPath)
|
||||
} else if os.IsPermission(err) {
|
||||
log.Printf("Permission denied for log file %s", pluginOptions.LogPath)
|
||||
} else {
|
||||
log.Printf("Error opening log file %s: %v", pluginOptions.LogPath, err)
|
||||
}
|
||||
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return file
|
||||
}
|
||||
215
cmd/app/comment.go
Normal file
215
cmd/app/comment.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type PostCommentRequest struct {
|
||||
Comment string `json:"comment"`
|
||||
PositionData
|
||||
}
|
||||
|
||||
type DeleteCommentRequest struct {
|
||||
NoteId int `json:"note_id"`
|
||||
DiscussionId string `json:"discussion_id"`
|
||||
}
|
||||
|
||||
type EditCommentRequest struct {
|
||||
Comment string `json:"comment"`
|
||||
NoteId int `json:"note_id"`
|
||||
DiscussionId string `json:"discussion_id"`
|
||||
Resolved bool `json:"resolved"`
|
||||
}
|
||||
|
||||
type CommentResponse struct {
|
||||
SuccessResponse
|
||||
Comment *gitlab.Note `json:"note"`
|
||||
Discussion *gitlab.Discussion `json:"discussion"`
|
||||
}
|
||||
|
||||
/* CommentWithPosition is a comment with an (optional) position data value embedded in it. The position data will be non-nil for range-based comments. */
|
||||
type CommentWithPosition struct {
|
||||
PositionData PositionData
|
||||
}
|
||||
|
||||
func (comment CommentWithPosition) GetPositionData() PositionData {
|
||||
return comment.PositionData
|
||||
}
|
||||
|
||||
type CommentManager interface {
|
||||
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)
|
||||
}
|
||||
|
||||
type commentService struct {
|
||||
data
|
||||
client CommentManager
|
||||
}
|
||||
|
||||
/* commentHandler creates, edits, and deletes discussions (comments, multi-line comments) */
|
||||
func (a commentService) handler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
a.postComment(w, r)
|
||||
case http.MethodPatch:
|
||||
a.editComment(w, r)
|
||||
case http.MethodDelete:
|
||||
a.deleteComment(w, r)
|
||||
default:
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/* deleteComment deletes a note, multiline comment, or comment, which are all considered discussion notes. */
|
||||
func (a commentService) deleteComment(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
var deleteCommentRequest DeleteCommentRequest
|
||||
err = json.Unmarshal(body, &deleteCommentRequest)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := a.client.DeleteMergeRequestDiscussionNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, deleteCommentRequest.DiscussionId, deleteCommentRequest.NoteId)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not delete comment", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/mr/comment"}, "Could not delete comment", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := SuccessResponse{
|
||||
Message: "Comment deleted successfully",
|
||||
Status: http.StatusOK,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
/* postComment creates a note, multiline comment, or comment. */
|
||||
func (a commentService) postComment(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
var postCommentRequest PostCommentRequest
|
||||
err = json.Unmarshal(body, &postCommentRequest)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
opt := gitlab.CreateMergeRequestDiscussionOptions{
|
||||
Body: &postCommentRequest.Comment,
|
||||
}
|
||||
|
||||
/* If we are leaving a comment on a line, leave position. Otherwise,
|
||||
we are leaving a note (unlinked comment) */
|
||||
|
||||
if postCommentRequest.FileName != "" {
|
||||
commentWithPositionData := CommentWithPosition{postCommentRequest.PositionData}
|
||||
opt.Position = buildCommentPosition(commentWithPositionData)
|
||||
}
|
||||
|
||||
discussion, res, err := a.client.CreateMergeRequestDiscussion(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not create discussion", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/mr/comment"}, "Could not create discussion", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := CommentResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: "Comment created successfully",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
Comment: discussion.Notes[0],
|
||||
Discussion: discussion,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
/* editComment changes the text of a comment or changes it's resolved status. */
|
||||
func (a commentService) editComment(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
var editCommentRequest EditCommentRequest
|
||||
err = json.Unmarshal(body, &editCommentRequest)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
options := gitlab.UpdateMergeRequestDiscussionNoteOptions{}
|
||||
options.Body = gitlab.Ptr(editCommentRequest.Comment)
|
||||
|
||||
note, res, err := a.client.UpdateMergeRequestDiscussionNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, editCommentRequest.DiscussionId, editCommentRequest.NoteId, &options)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not update comment", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/mr/comment"}, "Could not update comment", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := CommentResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: "Comment updated successfully",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
Comment: note,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
82
cmd/app/comment_helpers.go
Normal file
82
cmd/app/comment_helpers.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
/* 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"`
|
||||
NewLine int `json:"new_line"`
|
||||
}
|
||||
|
||||
/* LineRange represents the range of a note. */
|
||||
type LineRange struct {
|
||||
StartRange *LinePosition `json:"start"`
|
||||
EndRange *LinePosition `json:"end"`
|
||||
}
|
||||
|
||||
/* PositionData represents the position of a comment or note (relative to a file diff) */
|
||||
type PositionData struct {
|
||||
FileName string `json:"file_name"`
|
||||
NewLine *int `json:"new_line,omitempty"`
|
||||
OldLine *int `json:"old_line,omitempty"`
|
||||
HeadCommitSHA string `json:"head_commit_sha"`
|
||||
BaseCommitSHA string `json:"base_commit_sha"`
|
||||
StartCommitSHA string `json:"start_commit_sha"`
|
||||
Type string `json:"type"`
|
||||
LineRange *LineRange `json:"line_range,omitempty"`
|
||||
}
|
||||
|
||||
/* RequestWithPosition is an interface that abstracts the handling of position data for a comment or a draft comment */
|
||||
type RequestWithPosition interface {
|
||||
GetPositionData() PositionData
|
||||
}
|
||||
|
||||
/* buildCommentPosition takes a comment or draft comment request and builds the position data necessary for a location-based comment */
|
||||
func buildCommentPosition(commentWithPositionData RequestWithPosition) *gitlab.PositionOptions {
|
||||
positionData := commentWithPositionData.GetPositionData()
|
||||
|
||||
opt := &gitlab.PositionOptions{
|
||||
PositionType: &positionData.Type,
|
||||
StartSHA: &positionData.StartCommitSHA,
|
||||
HeadSHA: &positionData.HeadCommitSHA,
|
||||
BaseSHA: &positionData.BaseCommitSHA,
|
||||
NewPath: &positionData.FileName,
|
||||
OldPath: &positionData.FileName,
|
||||
NewLine: positionData.NewLine,
|
||||
OldLine: positionData.OldLine,
|
||||
}
|
||||
|
||||
if positionData.LineRange != nil {
|
||||
shaFormat := "%x_%d_%d"
|
||||
startFilenameSha := fmt.Sprintf(
|
||||
shaFormat,
|
||||
sha1.Sum([]byte(positionData.FileName)),
|
||||
positionData.LineRange.StartRange.OldLine,
|
||||
positionData.LineRange.StartRange.NewLine,
|
||||
)
|
||||
endFilenameSha := fmt.Sprintf(
|
||||
shaFormat,
|
||||
sha1.Sum([]byte(positionData.FileName)),
|
||||
positionData.LineRange.EndRange.OldLine,
|
||||
positionData.LineRange.EndRange.NewLine,
|
||||
)
|
||||
opt.LineRange = &gitlab.LineRangeOptions{
|
||||
Start: &gitlab.LinePositionOptions{
|
||||
Type: &positionData.LineRange.StartRange.Type,
|
||||
LineCode: &startFilenameSha,
|
||||
},
|
||||
End: &gitlab.LinePositionOptions{
|
||||
Type: &positionData.LineRange.EndRange.Type,
|
||||
LineCode: &endFilenameSha,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
124
cmd/app/comment_test.go
Normal file
124
cmd/app/comment_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type fakeCommentClient struct {
|
||||
testBase
|
||||
}
|
||||
|
||||
func (f fakeCommentClient) CreateMergeRequestDiscussion(pid interface{}, mergeRequest int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return &gitlab.Discussion{Notes: []*gitlab.Note{{}}}, resp, err
|
||||
}
|
||||
func (f fakeCommentClient) UpdateMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return &gitlab.Note{}, resp, err
|
||||
}
|
||||
|
||||
func (f fakeCommentClient) DeleteMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func TestPostComment(t *testing.T) {
|
||||
var testCommentCreationData = PostCommentRequest{Comment: "Some comment"}
|
||||
t.Run("Creates a new note (unlinked comment)", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData)
|
||||
svc := commentService{testProjectData, fakeCommentClient{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Message, "Comment created successfully")
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Creates a new comment", func(t *testing.T) {
|
||||
testCommentCreationData := PostCommentRequest{ // Re-create comment creation data to avoid mutating this variable in other tests
|
||||
Comment: "Some comment",
|
||||
PositionData: PositionData{
|
||||
FileName: "file.txt",
|
||||
},
|
||||
}
|
||||
request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData)
|
||||
svc := commentService{testProjectData, fakeCommentClient{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Message, "Comment created successfully")
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData)
|
||||
svc := commentService{testProjectData, fakeCommentClient{testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Could not create discussion")
|
||||
})
|
||||
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData)
|
||||
svc := commentService{testProjectData, fakeCommentClient{testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not create discussion", "/mr/comment")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteComment(t *testing.T) {
|
||||
var testCommentDeletionData = DeleteCommentRequest{NoteId: 3, DiscussionId: "abc123"}
|
||||
t.Run("Deletes a comment", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData)
|
||||
svc := commentService{testProjectData, fakeCommentClient{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Message, "Comment deleted successfully")
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
})
|
||||
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData)
|
||||
svc := commentService{testProjectData, fakeCommentClient{testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Could not delete comment")
|
||||
})
|
||||
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData)
|
||||
svc := commentService{testProjectData, fakeCommentClient{testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not delete comment", "/mr/comment")
|
||||
})
|
||||
}
|
||||
|
||||
func TestEditComment(t *testing.T) {
|
||||
var testEditCommentData = EditCommentRequest{Comment: "Some comment", NoteId: 3, DiscussionId: "abc123"}
|
||||
t.Run("Edits a comment", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData)
|
||||
svc := commentService{testProjectData, fakeCommentClient{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Message, "Comment updated successfully")
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
})
|
||||
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData)
|
||||
svc := commentService{testProjectData, fakeCommentClient{testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Could not update comment")
|
||||
})
|
||||
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData)
|
||||
svc := commentService{testProjectData, fakeCommentClient{testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not update comment", "/mr/comment")
|
||||
})
|
||||
}
|
||||
23
cmd/app/config.go
Normal file
23
cmd/app/config.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package app
|
||||
|
||||
type PluginOptions struct {
|
||||
GitlabUrl string `json:"gitlab_url"`
|
||||
Port int `json:"port"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
LogPath string `json:"log_path"`
|
||||
Debug struct {
|
||||
Request bool `json:"go_request"`
|
||||
Response bool `json:"go_response"`
|
||||
} `json:"debug"`
|
||||
ChosenTargetBranch *string `json:"chosen_target_branch,omitempty"`
|
||||
ConnectionSettings struct {
|
||||
Insecure bool `json:"insecure"`
|
||||
Remote string `json:"remote"`
|
||||
} `json:"connection_settings"`
|
||||
}
|
||||
|
||||
var pluginOptions PluginOptions
|
||||
|
||||
func SetPluginOptions(p PluginOptions) {
|
||||
pluginOptions = p
|
||||
}
|
||||
99
cmd/app/create_mr.go
Normal file
99
cmd/app/create_mr.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type CreateMrRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
TargetBranch string `json:"target_branch"`
|
||||
DeleteBranch bool `json:"delete_branch"`
|
||||
Squash bool `json:"squash"`
|
||||
TargetProjectID int `json:"forked_project_id,omitempty"`
|
||||
}
|
||||
|
||||
type MergeRequestCreator interface {
|
||||
CreateMergeRequest(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type mergeRequestCreatorService struct {
|
||||
data
|
||||
client MergeRequestCreator
|
||||
}
|
||||
|
||||
/* createMr creates a merge request */
|
||||
func (a mergeRequestCreatorService) handler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Methods", http.MethodGet)
|
||||
if r.Method != 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)
|
||||
return
|
||||
}
|
||||
|
||||
var createMrRequest CreateMrRequest
|
||||
err = json.Unmarshal(body, &createMrRequest)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not unmarshal request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if createMrRequest.Title == "" {
|
||||
handleError(w, errors.New("Title cannot be empty"), "Could not create MR", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if createMrRequest.TargetBranch == "" {
|
||||
handleError(w, errors.New("Target branch cannot be empty"), "Could not create MR", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
opts := gitlab.CreateMergeRequestOptions{
|
||||
Title: &createMrRequest.Title,
|
||||
Description: &createMrRequest.Description,
|
||||
TargetBranch: &createMrRequest.TargetBranch,
|
||||
SourceBranch: &a.gitInfo.BranchName,
|
||||
RemoveSourceBranch: &createMrRequest.DeleteBranch,
|
||||
Squash: &createMrRequest.Squash,
|
||||
}
|
||||
|
||||
if createMrRequest.TargetProjectID != 0 {
|
||||
opts.TargetProjectID = gitlab.Ptr(createMrRequest.TargetProjectID)
|
||||
}
|
||||
|
||||
_, res, err := a.client.CreateMergeRequest(a.projectInfo.ProjectId, &opts)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not create MR", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/create_mr"}, "Could not create MR", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
response := SuccessResponse{
|
||||
Status: http.StatusOK,
|
||||
Message: fmt.Sprintf("MR '%s' created", createMrRequest.Title),
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
80
cmd/app/create_mr_test.go
Normal file
80
cmd/app/create_mr_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type fakeMergeCreatorClient struct {
|
||||
testBase
|
||||
}
|
||||
|
||||
func (f fakeMergeCreatorClient) CreateMergeRequest(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &gitlab.MergeRequest{}, resp, nil
|
||||
}
|
||||
|
||||
func TestCreateMr(t *testing.T) {
|
||||
var testCreateMrRequestData = CreateMrRequest{
|
||||
Title: "Some title",
|
||||
Description: "Some description",
|
||||
TargetBranch: "main",
|
||||
DeleteBranch: false,
|
||||
Squash: false,
|
||||
}
|
||||
t.Run("Creates an MR", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData)
|
||||
svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Message, "MR 'Some title' created")
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Disallows non-POST methods", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/create_mr", testCreateMrRequestData)
|
||||
svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkBadMethod(t, data, http.MethodPost)
|
||||
})
|
||||
|
||||
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData)
|
||||
svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Could not create MR")
|
||||
})
|
||||
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData)
|
||||
svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not create MR", "/create_mr")
|
||||
})
|
||||
|
||||
t.Run("Handles missing titles", func(t *testing.T) {
|
||||
reqData := testCreateMrRequestData
|
||||
reqData.Title = ""
|
||||
request := makeRequest(t, http.MethodPost, "/create_mr", reqData)
|
||||
svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}}
|
||||
data := getFailData(t, svc, request)
|
||||
assert(t, data.Status, http.StatusBadRequest)
|
||||
assert(t, data.Message, "Could not create MR")
|
||||
assert(t, data.Details, "Title cannot be empty")
|
||||
})
|
||||
|
||||
t.Run("Handles missing target branch", func(t *testing.T) {
|
||||
reqData := testCreateMrRequestData
|
||||
reqData.TargetBranch = ""
|
||||
request := makeRequest(t, http.MethodPost, "/create_mr", reqData)
|
||||
svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}}
|
||||
data := getFailData(t, svc, request)
|
||||
assert(t, data.Status, http.StatusBadRequest)
|
||||
assert(t, data.Message, "Could not create MR")
|
||||
assert(t, data.Details, "Target branch cannot be empty")
|
||||
})
|
||||
}
|
||||
76
cmd/app/draft_note_publisher.go
Normal file
76
cmd/app/draft_note_publisher.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type DraftNotePublisher interface {
|
||||
PublishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
|
||||
PublishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
|
||||
}
|
||||
|
||||
type draftNotePublisherService struct {
|
||||
data
|
||||
client DraftNotePublisher
|
||||
}
|
||||
|
||||
func (a draftNotePublisherService) handler(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)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
var draftNotePublishRequest DraftNotePublishRequest
|
||||
err = json.Unmarshal(body, &draftNotePublishRequest)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var res *gitlab.Response
|
||||
if draftNotePublishRequest.PublishAll {
|
||||
res, err = a.client.PublishAllDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId)
|
||||
} else {
|
||||
if draftNotePublishRequest.Note == 0 {
|
||||
handleError(w, errors.New("No ID provided"), "Must provide Note ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
res, err = a.client.PublishDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, draftNotePublishRequest.Note)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not publish draft note(s)", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/mr/draft_notes/publish"}, "Could not publish dfaft note", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := SuccessResponse{
|
||||
Message: "Draft note(s) published",
|
||||
Status: http.StatusOK,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
74
cmd/app/draft_note_publisher_test.go
Normal file
74
cmd/app/draft_note_publisher_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type fakeDraftNotePublisher struct {
|
||||
testBase
|
||||
}
|
||||
|
||||
func (f fakeDraftNotePublisher) PublishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
|
||||
return f.handleGitlabError()
|
||||
}
|
||||
func (f fakeDraftNotePublisher) PublishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
|
||||
return f.handleGitlabError()
|
||||
}
|
||||
|
||||
func TestPublishDraftNote(t *testing.T) {
|
||||
var testDraftNotePublishRequest = DraftNotePublishRequest{Note: 3, PublishAll: false}
|
||||
t.Run("Publishes draft note", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest)
|
||||
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
assert(t, data.Message, "Draft note(s) published")
|
||||
})
|
||||
t.Run("Disallows non-POST method", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/mr/draft_notes/publish", testDraftNotePublishRequest)
|
||||
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkBadMethod(t, data, http.MethodPost)
|
||||
})
|
||||
t.Run("Handles bad ID", func(t *testing.T) {
|
||||
badData := testDraftNotePublishRequest
|
||||
badData.Note = 0
|
||||
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", badData)
|
||||
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}}
|
||||
data := getFailData(t, svc, request)
|
||||
assert(t, data.Status, http.StatusBadRequest)
|
||||
assert(t, data.Message, "Must provide Note ID")
|
||||
})
|
||||
t.Run("Handles error from Gitlab", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest)
|
||||
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Could not publish draft note(s)")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPublishAllDraftNotes(t *testing.T) {
|
||||
var testDraftNotePublishRequest = DraftNotePublishRequest{PublishAll: true}
|
||||
t.Run("Should publish all draft notes", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest)
|
||||
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
assert(t, data.Message, "Draft note(s) published")
|
||||
})
|
||||
t.Run("Disallows non-POST method", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/mr/draft_notes/publish", testDraftNotePublishRequest)
|
||||
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkBadMethod(t, data, http.MethodPost)
|
||||
})
|
||||
t.Run("Handles error from Gitlab", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest)
|
||||
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Could not publish draft note(s)")
|
||||
})
|
||||
}
|
||||
265
cmd/app/draft_notes.go
Normal file
265
cmd/app/draft_notes.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
/* The data coming from the client when creating a draft note is the same,
|
||||
as when they are creating a normal comment, but the Gitlab
|
||||
endpoints + resources we handle are different */
|
||||
|
||||
type PostDraftNoteRequest struct {
|
||||
Comment string `json:"comment"`
|
||||
DiscussionId string `json:"discussion_id,omitempty"`
|
||||
PositionData
|
||||
}
|
||||
|
||||
type UpdateDraftNoteRequest struct {
|
||||
Note string `json:"note"`
|
||||
Position gitlab.PositionOptions
|
||||
}
|
||||
|
||||
type DraftNotePublishRequest struct {
|
||||
Note int `json:"note,omitempty"`
|
||||
PublishAll bool `json:"publish_all"`
|
||||
}
|
||||
|
||||
type DraftNoteResponse struct {
|
||||
SuccessResponse
|
||||
DraftNote *gitlab.DraftNote `json:"draft_note"`
|
||||
}
|
||||
|
||||
type ListDraftNotesResponse struct {
|
||||
SuccessResponse
|
||||
DraftNotes []*gitlab.DraftNote `json:"draft_notes"`
|
||||
}
|
||||
|
||||
/* DraftNoteWithPosition is a draft comment with an (optional) position data value embedded in it. The position data will be non-nil for range-based draft comments. */
|
||||
type DraftNoteWithPosition struct {
|
||||
PositionData PositionData
|
||||
}
|
||||
|
||||
func (draftNote DraftNoteWithPosition) GetPositionData() PositionData {
|
||||
return draftNote.PositionData
|
||||
}
|
||||
|
||||
type DraftNoteManager interface {
|
||||
ListDraftNotes(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error)
|
||||
CreateDraftNote(pid interface{}, mergeRequest int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error)
|
||||
DeleteDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
|
||||
UpdateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type draftNoteService struct {
|
||||
data
|
||||
client DraftNoteManager
|
||||
}
|
||||
|
||||
/* draftNoteHandler creates, edits, and deletes draft notes */
|
||||
func (a draftNoteService) handler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
a.listDraftNotes(w, r)
|
||||
case http.MethodPost:
|
||||
a.postDraftNote(w, r)
|
||||
case http.MethodPatch:
|
||||
a.updateDraftNote(w, r)
|
||||
case http.MethodDelete:
|
||||
a.deleteDraftNote(w, r)
|
||||
default:
|
||||
w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s, %s, %s", http.MethodDelete, http.MethodPost, http.MethodPatch, http.MethodGet))
|
||||
handleError(w, InvalidRequestError{}, "Expected DELETE, GET, POST or PATCH", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
/* listDraftNotes lists all draft notes for the currently authenticated user */
|
||||
func (a draftNoteService) listDraftNotes(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
opt := gitlab.ListDraftNotesOptions{}
|
||||
draftNotes, res, err := a.client.ListDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not get draft notes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not get draft notes", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := ListDraftNotesResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: "Draft notes fetched successfully",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
DraftNotes: draftNotes,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
/* postDraftNote creates a draft note */
|
||||
func (a draftNoteService) postDraftNote(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
var postDraftNoteRequest PostDraftNoteRequest
|
||||
err = json.Unmarshal(body, &postDraftNoteRequest)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
opt := gitlab.CreateDraftNoteOptions{
|
||||
Note: &postDraftNoteRequest.Comment,
|
||||
}
|
||||
|
||||
// Draft notes can be posted in "response" to existing discussions
|
||||
if postDraftNoteRequest.DiscussionId != "" {
|
||||
opt.InReplyToDiscussionID = gitlab.Ptr(postDraftNoteRequest.DiscussionId)
|
||||
}
|
||||
|
||||
if postDraftNoteRequest.FileName != "" {
|
||||
draftNoteWithPosition := DraftNoteWithPosition{postDraftNoteRequest.PositionData}
|
||||
opt.Position = buildCommentPosition(draftNoteWithPosition)
|
||||
}
|
||||
|
||||
draftNote, res, err := a.client.CreateDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not create draft note", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not create draft note", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := DraftNoteResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: "Draft note created successfully",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
DraftNote: draftNote,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
/* deleteDraftNote deletes a draft note */
|
||||
func (a draftNoteService) deleteDraftNote(w http.ResponseWriter, r *http.Request) {
|
||||
suffix := strings.TrimPrefix(r.URL.Path, "/mr/draft_notes/")
|
||||
id, err := strconv.Atoi(suffix)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not parse draft note ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := a.client.DeleteDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, id)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not delete draft note", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: fmt.Sprintf("/mr/draft_notes/%d", id)}, "Could not delete draft note", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := SuccessResponse{
|
||||
Message: "Draft note deleted",
|
||||
Status: http.StatusOK,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
/* updateDraftNote edits the text of a draft comment */
|
||||
func (a draftNoteService) updateDraftNote(w http.ResponseWriter, r *http.Request) {
|
||||
suffix := strings.TrimPrefix(r.URL.Path, "/mr/draft_notes/")
|
||||
id, err := strconv.Atoi(suffix)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not parse draft note ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
var updateDraftNoteRequest UpdateDraftNoteRequest
|
||||
err = json.Unmarshal(body, &updateDraftNoteRequest)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if updateDraftNoteRequest.Note == "" {
|
||||
handleError(w, errors.New("Draft note text missing"), "Must provide draft note text", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
opt := gitlab.UpdateDraftNoteOptions{
|
||||
Note: &updateDraftNoteRequest.Note,
|
||||
Position: &updateDraftNoteRequest.Position,
|
||||
}
|
||||
|
||||
draftNote, res, err := a.client.UpdateDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, id, &opt)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not update draft note", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: fmt.Sprintf("/mr/draft_notes/%d", id)}, "Could not update draft note", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := DraftNoteResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: "Draft note updated",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
DraftNote: draftNote,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
153
cmd/app/draft_notes_test.go
Normal file
153
cmd/app/draft_notes_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type fakeDraftNoteManager struct {
|
||||
testBase
|
||||
}
|
||||
|
||||
func (f fakeDraftNoteManager) ListDraftNotes(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return []*gitlab.DraftNote{}, resp, err
|
||||
}
|
||||
|
||||
func (f fakeDraftNoteManager) CreateDraftNote(pid interface{}, mergeRequest int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &gitlab.DraftNote{}, resp, err
|
||||
}
|
||||
|
||||
func (f fakeDraftNoteManager) DeleteDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
|
||||
return f.handleGitlabError()
|
||||
}
|
||||
|
||||
func (f fakeDraftNoteManager) UpdateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &gitlab.DraftNote{}, resp, err
|
||||
}
|
||||
|
||||
func TestListDraftNotes(t *testing.T) {
|
||||
t.Run("Lists all draft notes", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil)
|
||||
svc := draftNoteService{testProjectData, fakeDraftNoteManager{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
assert(t, data.Message, "Draft notes fetched successfully")
|
||||
})
|
||||
t.Run("Handles error from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil)
|
||||
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Could not get draft notes")
|
||||
})
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil)
|
||||
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not get draft notes", "/mr/draft_notes/")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostDraftNote(t *testing.T) {
|
||||
var testPostDraftNoteRequestData = PostDraftNoteRequest{Comment: "Some comment"}
|
||||
t.Run("Posts new draft note", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData)
|
||||
svc := draftNoteService{testProjectData, fakeDraftNoteManager{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
assert(t, data.Message, "Draft note created successfully")
|
||||
})
|
||||
t.Run("Handles error from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData)
|
||||
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Could not create draft note")
|
||||
})
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData)
|
||||
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not create draft note", "/mr/draft_notes/")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteDraftNote(t *testing.T) {
|
||||
t.Run("Deletes new draft note", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil)
|
||||
svc := draftNoteService{testProjectData, fakeDraftNoteManager{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
assert(t, data.Message, "Draft note deleted")
|
||||
})
|
||||
t.Run("Handles error from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil)
|
||||
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Could not delete draft note")
|
||||
})
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil)
|
||||
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not delete draft note", "/mr/draft_notes/3")
|
||||
})
|
||||
t.Run("Handles bad ID", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/blah", nil)
|
||||
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
assert(t, data.Message, "Could not parse draft note ID")
|
||||
assert(t, data.Status, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEditDraftNote(t *testing.T) {
|
||||
var testUpdateDraftNoteRequest = UpdateDraftNoteRequest{Note: "Some new note"}
|
||||
t.Run("Edits new draft note", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", testUpdateDraftNoteRequest)
|
||||
svc := draftNoteService{testProjectData, fakeDraftNoteManager{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
assert(t, data.Message, "Draft note updated")
|
||||
})
|
||||
t.Run("Handles error from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", testUpdateDraftNoteRequest)
|
||||
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Could not update draft note")
|
||||
})
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", testUpdateDraftNoteRequest)
|
||||
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not update draft note", "/mr/draft_notes/3")
|
||||
})
|
||||
t.Run("Handles bad ID", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/blah", testUpdateDraftNoteRequest)
|
||||
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
assert(t, data.Message, "Could not parse draft note ID")
|
||||
assert(t, data.Status, http.StatusBadRequest)
|
||||
})
|
||||
t.Run("Handles empty note", func(t *testing.T) {
|
||||
requestData := testUpdateDraftNoteRequest
|
||||
requestData.Note = ""
|
||||
request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", requestData)
|
||||
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
assert(t, data.Message, "Must provide draft note text")
|
||||
assert(t, data.Status, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
186
cmd/app/emoji.go
Normal file
186
cmd/app/emoji.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type Emoji struct {
|
||||
Unicode string `json:"unicode"`
|
||||
UnicodeAlternates []string `json:"unicode_alternates"`
|
||||
Name string `json:"name"`
|
||||
Shortname string `json:"shortname"`
|
||||
Category string `json:"category"`
|
||||
Aliases []string `json:"aliases"`
|
||||
AliasesASCII []string `json:"aliases_ascii"`
|
||||
Keywords []string `json:"keywords"`
|
||||
Moji string `json:"moji"`
|
||||
}
|
||||
|
||||
type EmojiMap map[string]Emoji
|
||||
|
||||
type CreateNoteEmojiPost struct {
|
||||
Emoji string `json:"emoji"`
|
||||
NoteId int `json:"note_id"`
|
||||
}
|
||||
|
||||
type CreateEmojiResponse struct {
|
||||
SuccessResponse
|
||||
Emoji *gitlab.AwardEmoji
|
||||
}
|
||||
|
||||
type EmojiManager interface {
|
||||
DeleteMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, awardID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
|
||||
CreateMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, opt *gitlab.CreateAwardEmojiOptions, options ...gitlab.RequestOptionFunc) (*gitlab.AwardEmoji, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type emojiService struct {
|
||||
data
|
||||
client EmojiManager
|
||||
}
|
||||
|
||||
func (a emojiService) handler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
a.postEmojiOnNote(w, r)
|
||||
case http.MethodDelete:
|
||||
a.deleteEmojiFromNote(w, r)
|
||||
default:
|
||||
w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s", http.MethodDelete, http.MethodPost))
|
||||
handleError(w, InvalidRequestError{}, "Expected DELETE or POST", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
/* deleteEmojiFromNote deletes an emoji from a note based on the emoji (awardable) ID and the note's ID */
|
||||
func (a emojiService) deleteEmojiFromNote(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
suffix := strings.TrimPrefix(r.URL.Path, "/mr/awardable/note/")
|
||||
ids := strings.Split(suffix, "/")
|
||||
|
||||
noteId, err := strconv.Atoi(ids[0])
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not convert note ID to integer", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
awardableId, err := strconv.Atoi(ids[1])
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not convert awardable ID to integer", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := a.client.DeleteMergeRequestAwardEmojiOnNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, noteId, awardableId)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not delete awardable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/pipeline"}, "Could not delete awardable", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := SuccessResponse{
|
||||
Message: "Emoji deleted",
|
||||
Status: http.StatusOK,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
/* postEmojiOnNote adds an emojis to a note based on the note's ID */
|
||||
func (a emojiService) postEmojiOnNote(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
var emojiPost CreateNoteEmojiPost
|
||||
err = json.Unmarshal(body, &emojiPost)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not unmarshal request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
awardEmoji, res, err := a.client.CreateMergeRequestAwardEmojiOnNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, emojiPost.NoteId, &gitlab.CreateAwardEmojiOptions{
|
||||
Name: emojiPost.Emoji,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not post emoji", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/mr/awardable/note"}, "Could not post emoji", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := CreateEmojiResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: "Merge requests retrieved",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
Emoji: awardEmoji,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
attachEmojis reads the emojis from our external JSON file
|
||||
and attaches them to the data so that they can be looked up later
|
||||
*/
|
||||
func attachEmojis(a *data, fr FileReader) error {
|
||||
|
||||
e, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
binPath := path.Dir(e)
|
||||
filePath := fmt.Sprintf("%s/config/emojis.json", binPath)
|
||||
|
||||
reader, err := fr.ReadFile(filePath)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("Could not find emojis at %s", filePath)
|
||||
}
|
||||
|
||||
bytes, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return errors.New("Could not read emoji file")
|
||||
}
|
||||
|
||||
var emojiMap EmojiMap
|
||||
err = json.Unmarshal(bytes, &emojiMap)
|
||||
if err != nil {
|
||||
return errors.New("Could not unmarshal emojis")
|
||||
}
|
||||
|
||||
a.emojiMap = emojiMap
|
||||
return nil
|
||||
}
|
||||
131
cmd/app/git/git.go
Normal file
131
cmd/app/git/git.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type GitManager interface {
|
||||
RefreshProjectInfo(remote string) error
|
||||
GetProjectUrlFromNativeGitCmd(remote string) (url string, err error)
|
||||
GetCurrentBranchNameFromNativeGitCmd() (string, error)
|
||||
GetLatestCommitOnRemote(remote string, branchName string) (string, error)
|
||||
}
|
||||
|
||||
type GitData struct {
|
||||
RemoteUrl string
|
||||
Namespace string
|
||||
ProjectName string
|
||||
BranchName string
|
||||
}
|
||||
|
||||
type Git struct{}
|
||||
|
||||
/*
|
||||
projectPath returns the Gitlab project full path, which isn't necessarily the same as its name.
|
||||
See https://docs.gitlab.com/ee/api/rest/index.html#namespaced-path-encoding for more information.
|
||||
*/
|
||||
func (g GitData) ProjectPath() string {
|
||||
return g.Namespace + "/" + g.ProjectName
|
||||
}
|
||||
|
||||
/*
|
||||
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 NewGitData(remote string, g GitManager) (GitData, error) {
|
||||
err := g.RefreshProjectInfo(remote)
|
||||
if err != nil {
|
||||
return GitData{}, fmt.Errorf("Could not get latest information from remote: %v", err)
|
||||
}
|
||||
|
||||
url, err := g.GetProjectUrlFromNativeGitCmd(remote)
|
||||
if err != nil {
|
||||
return GitData{}, fmt.Errorf("Could not get project Url: %v", err)
|
||||
}
|
||||
|
||||
/*
|
||||
This should match following formats:
|
||||
namespace: namespace, projectName: dummy-test-repo:
|
||||
https://gitlab.com/namespace/dummy-test-repo.git
|
||||
git@gitlab.com:namespace/dummy-test-repo.git
|
||||
ssh://git@gitlab.com/namespace/dummy-test-repo.git
|
||||
|
||||
namespace: namespace/subnamespace, projectName: dummy-test-repo:
|
||||
ssh://git@gitlab.com/namespace/subnamespace/dummy-test-repo
|
||||
https://git@gitlab.com/namespace/subnamespace/dummy-test-repo.git
|
||||
git@git@gitlab.com:namespace/subnamespace/dummy-test-repo.git
|
||||
*/
|
||||
re := regexp.MustCompile(`(?:^https?:\/\/|^ssh:\/\/|^git@)(?:[^\/:]+)(?::\d+)?[\/:](.*)\/([^\/]+?)(?:\.git)?$`)
|
||||
matches := re.FindStringSubmatch(url)
|
||||
if len(matches) != 3 {
|
||||
return GitData{}, fmt.Errorf("Invalid Git URL format: %s", url)
|
||||
}
|
||||
|
||||
namespace := matches[1]
|
||||
projectName := matches[2]
|
||||
|
||||
branchName, err := g.GetCurrentBranchNameFromNativeGitCmd()
|
||||
if err != nil {
|
||||
return GitData{}, fmt.Errorf("Failed to get current branch: %v", err)
|
||||
}
|
||||
|
||||
return GitData{
|
||||
RemoteUrl: url,
|
||||
Namespace: namespace,
|
||||
ProjectName: projectName,
|
||||
BranchName: branchName,
|
||||
},
|
||||
nil
|
||||
}
|
||||
|
||||
/* Gets the current branch name */
|
||||
func (g Git) GetCurrentBranchNameFromNativeGitCmd() (res string, e error) {
|
||||
gitCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
|
||||
|
||||
output, err := gitCmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Error running git rev-parse: %w", err)
|
||||
}
|
||||
|
||||
branchName := strings.TrimSpace(string(output))
|
||||
|
||||
return branchName, nil
|
||||
}
|
||||
|
||||
/* Gets the project SSH or HTTPS url */
|
||||
func (g Git) GetProjectUrlFromNativeGitCmd(remote string) (string, error) {
|
||||
cmd := exec.Command("git", "remote", "get-url", remote)
|
||||
url, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Could not get remote")
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(url)), nil
|
||||
}
|
||||
|
||||
/* Pulls down latest commit information from Gitlab */
|
||||
func (g Git) RefreshProjectInfo(remote string) error {
|
||||
cmd := exec.Command("git", "fetch", remote)
|
||||
_, err := cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to run `git fetch %s`: %v", remote, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g Git) GetLatestCommitOnRemote(remote string, branchName string) (string, error) {
|
||||
cmd := exec.Command("git", "log", "-1", "--format=%H", fmt.Sprintf("%s/%s", remote, branchName))
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Failed to run `git log -1 --format=%%H " + fmt.Sprintf("%s/%s", remote, branchName))
|
||||
}
|
||||
|
||||
commit := strings.TrimSpace(string(out))
|
||||
return commit, nil
|
||||
}
|
||||
219
cmd/app/git/git_test.go
Normal file
219
cmd/app/git/git_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type FakeGitManager struct {
|
||||
RemoteUrl string
|
||||
BranchName string
|
||||
ProjectName string
|
||||
Namespace string
|
||||
}
|
||||
|
||||
func (f FakeGitManager) RefreshProjectInfo(remote string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f FakeGitManager) GetCurrentBranchNameFromNativeGitCmd() (string, error) {
|
||||
return f.BranchName, nil
|
||||
}
|
||||
|
||||
func (f FakeGitManager) GetLatestCommitOnRemote(remote string, branchName string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f FakeGitManager) GetProjectUrlFromNativeGitCmd(string) (url string, err error) {
|
||||
return f.RemoteUrl, nil
|
||||
}
|
||||
|
||||
type TestCase struct {
|
||||
desc string
|
||||
branch string
|
||||
projectName string
|
||||
namespace string
|
||||
remote string
|
||||
}
|
||||
|
||||
func TestExtractGitInfo_Success(t *testing.T) {
|
||||
testCases := []TestCase{
|
||||
{
|
||||
desc: "Project configured in SSH under a single folder",
|
||||
remote: "git@custom-gitlab.com:namespace-1/project-name.git",
|
||||
branch: "feature/abc",
|
||||
projectName: "project-name",
|
||||
namespace: "namespace-1",
|
||||
},
|
||||
{
|
||||
desc: "Project configured in SSH under a single folder without .git extension",
|
||||
remote: "git@custom-gitlab.com:namespace-1/project-name",
|
||||
branch: "feature/abc",
|
||||
projectName: "project-name",
|
||||
namespace: "namespace-1",
|
||||
},
|
||||
{
|
||||
desc: "Project configured in SSH under one nested folder",
|
||||
remote: "git@custom-gitlab.com:namespace-1/namespace-2/project-name.git",
|
||||
branch: "feature/abc",
|
||||
projectName: "project-name",
|
||||
namespace: "namespace-1/namespace-2",
|
||||
},
|
||||
{
|
||||
desc: "Project configured in SSH under two nested folders",
|
||||
remote: "git@custom-gitlab.com:namespace-1/namespace-2/namespace-3/project-name.git",
|
||||
branch: "feature/abc",
|
||||
projectName: "project-name",
|
||||
namespace: "namespace-1/namespace-2/namespace-3",
|
||||
},
|
||||
{
|
||||
desc: "Project configured in SSH:// under a single folder",
|
||||
remote: "ssh://custom-gitlab.com/namespace-1/project-name.git",
|
||||
branch: "feature/abc",
|
||||
projectName: "project-name",
|
||||
namespace: "namespace-1",
|
||||
},
|
||||
{
|
||||
desc: "Project configured in SSH:// under a single folder without .git extension",
|
||||
remote: "ssh://custom-gitlab.com/namespace-1/project-name",
|
||||
branch: "feature/abc",
|
||||
projectName: "project-name",
|
||||
namespace: "namespace-1",
|
||||
},
|
||||
{
|
||||
desc: "Project configured in SSH:// under two nested folders",
|
||||
remote: "ssh://custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git",
|
||||
branch: "feature/abc",
|
||||
projectName: "project-name",
|
||||
namespace: "namespace-1/namespace-2/namespace-3",
|
||||
},
|
||||
{
|
||||
desc: "Project configured in SSH:// and have a custom port",
|
||||
remote: "ssh://custom-gitlab.com:2222/namespace-1/project-name",
|
||||
branch: "feature/abc",
|
||||
projectName: "project-name",
|
||||
namespace: "namespace-1",
|
||||
},
|
||||
{
|
||||
desc: "Project configured in HTTP and under a single folder without .git extension",
|
||||
remote: "http://custom-gitlab.com/namespace-1/project-name",
|
||||
branch: "feature/abc",
|
||||
projectName: "project-name",
|
||||
namespace: "namespace-1",
|
||||
},
|
||||
{
|
||||
desc: "Project configured in HTTPS and under a single folder",
|
||||
remote: "https://custom-gitlab.com/namespace-1/project-name.git",
|
||||
branch: "feature/abc",
|
||||
projectName: "project-name",
|
||||
namespace: "namespace-1",
|
||||
},
|
||||
{
|
||||
desc: "Project configured in HTTPS and under a nested folder",
|
||||
remote: "https://custom-gitlab.com/namespace-1/namespace-2/project-name.git",
|
||||
branch: "feature/abc",
|
||||
projectName: "project-name",
|
||||
namespace: "namespace-1/namespace-2",
|
||||
},
|
||||
{
|
||||
desc: "Project configured in HTTPS and under two nested folders",
|
||||
remote: "https://custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git",
|
||||
branch: "feature/abc",
|
||||
projectName: "project-name",
|
||||
namespace: "namespace-1/namespace-2/namespace-3",
|
||||
},
|
||||
}
|
||||
for _, tC := range testCases {
|
||||
t.Run(tC.desc, func(t *testing.T) {
|
||||
g := FakeGitManager{
|
||||
Namespace: tC.namespace,
|
||||
ProjectName: tC.projectName,
|
||||
BranchName: tC.branch,
|
||||
RemoteUrl: tC.remote,
|
||||
}
|
||||
data, err := NewGitData(tC.remote, g)
|
||||
if err != nil {
|
||||
t.Errorf("No error was expected, got %s", err)
|
||||
}
|
||||
if data.RemoteUrl != tC.remote {
|
||||
t.Errorf("\nExpected Remote URL: %s\nActual: %s", tC.remote, data.RemoteUrl)
|
||||
}
|
||||
if data.BranchName != tC.branch {
|
||||
t.Errorf("\nExpected Branch Name: %s\nActual: %s", tC.branch, data.BranchName)
|
||||
}
|
||||
if data.ProjectName != tC.projectName {
|
||||
t.Errorf("\nExpected Project Name: %s\nActual: %s", tC.projectName, data.ProjectName)
|
||||
}
|
||||
if data.Namespace != tC.namespace {
|
||||
t.Errorf("\nExpected Namespace: %s\nActual: %s", tC.namespace, data.Namespace)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type FailTestCase struct {
|
||||
desc string
|
||||
errMsg string
|
||||
expectedErr string
|
||||
}
|
||||
|
||||
type failingUrlManager struct {
|
||||
errMsg string
|
||||
FakeGitManager
|
||||
}
|
||||
|
||||
func (f failingUrlManager) GetProjectUrlFromNativeGitCmd(string) (string, error) {
|
||||
return "", errors.New(f.errMsg)
|
||||
}
|
||||
|
||||
func TestExtractGitInfo_FailToGetProjectRemoteUrl(t *testing.T) {
|
||||
tC := FailTestCase{
|
||||
desc: "Error returned by function to get the project remote url",
|
||||
errMsg: "Some error",
|
||||
expectedErr: "Could not get project Url: Some error",
|
||||
}
|
||||
t.Run(tC.desc, func(t *testing.T) {
|
||||
g := failingUrlManager{
|
||||
errMsg: tC.errMsg,
|
||||
}
|
||||
_, err := NewGitData("", g)
|
||||
if err == nil {
|
||||
t.Errorf("Expected an error, got none")
|
||||
}
|
||||
if err.Error() != tC.expectedErr {
|
||||
t.Errorf("\nExpected: %s\nActual: %s", tC.expectedErr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type failingBranchManager struct {
|
||||
errMsg string
|
||||
FakeGitManager
|
||||
}
|
||||
|
||||
func (f failingBranchManager) GetCurrentBranchNameFromNativeGitCmd() (string, error) {
|
||||
return "", errors.New(f.errMsg)
|
||||
}
|
||||
|
||||
func TestExtractGitInfo_FailToGetCurrentBranchName(t *testing.T) {
|
||||
tC := FailTestCase{
|
||||
desc: "Error returned by function to get the project remote url",
|
||||
errMsg: "Some error",
|
||||
expectedErr: "Failed to get current branch: Some error",
|
||||
}
|
||||
t.Run(tC.desc, func(t *testing.T) {
|
||||
g := failingBranchManager{
|
||||
FakeGitManager: FakeGitManager{
|
||||
RemoteUrl: "git@custom-gitlab.com:namespace-1/project-name.git",
|
||||
},
|
||||
errMsg: tC.errMsg,
|
||||
}
|
||||
_, err := NewGitData("", g)
|
||||
if err == nil {
|
||||
t.Errorf("Expected an error, got none")
|
||||
}
|
||||
if err.Error() != tC.expectedErr {
|
||||
t.Errorf("\nExpected: %s\nActual: %s", tC.expectedErr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
57
cmd/app/info.go
Normal file
57
cmd/app/info.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type InfoResponse struct {
|
||||
SuccessResponse
|
||||
Info *gitlab.MergeRequest `json:"info"`
|
||||
}
|
||||
|
||||
type MergeRequestGetter interface {
|
||||
GetMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type infoService struct {
|
||||
data
|
||||
client MergeRequestGetter
|
||||
}
|
||||
|
||||
/* infoHandler fetches infomation about the current git project. The data returned here is used in many other API calls */
|
||||
func (a infoService) handler(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
|
||||
}
|
||||
|
||||
mr, res, err := a.client.GetMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.GetMergeRequestsOptions{})
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not get project info", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/mr/info"}, "Could not get project info", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := InfoResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: "Merge requests retrieved",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
Info: mr,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
49
cmd/app/info_test.go
Normal file
49
cmd/app/info_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type fakeMergeRequestGetter struct {
|
||||
testBase
|
||||
}
|
||||
|
||||
func (f fakeMergeRequestGetter) GetMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return &gitlab.MergeRequest{}, resp, err
|
||||
}
|
||||
|
||||
func TestInfoHandler(t *testing.T) {
|
||||
t.Run("Returns normal information", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/mr/info", nil)
|
||||
svc := infoService{testProjectData, fakeMergeRequestGetter{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Message, "Merge requests retrieved")
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
})
|
||||
t.Run("Disallows non-GET methods", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/info", nil)
|
||||
svc := infoService{testProjectData, fakeMergeRequestGetter{}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkBadMethod(t, data, http.MethodGet)
|
||||
})
|
||||
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/mr/info", nil)
|
||||
svc := infoService{testProjectData, fakeMergeRequestGetter{testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
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, "/mr/info", nil)
|
||||
svc := infoService{testProjectData, fakeMergeRequestGetter{testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not get project info", "/mr/info")
|
||||
})
|
||||
}
|
||||
85
cmd/app/job.go
Normal file
85
cmd/app/job.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type JobTraceRequest struct {
|
||||
JobId int `json:"job_id"`
|
||||
}
|
||||
|
||||
type JobTraceResponse struct {
|
||||
SuccessResponse
|
||||
File string `json:"file"`
|
||||
}
|
||||
|
||||
type TraceFileGetter interface {
|
||||
GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type traceFileService struct {
|
||||
data
|
||||
client TraceFileGetter
|
||||
}
|
||||
|
||||
/* jobHandler returns a string that shows the output of a specific job run in a Gitlab pipeline */
|
||||
func (a traceFileService) handler(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
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
var jobTraceRequest JobTraceRequest
|
||||
err = json.Unmarshal(body, &jobTraceRequest)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reader, res, err := a.client.GetTraceFile(a.projectInfo.ProjectId, jobTraceRequest.JobId)
|
||||
|
||||
if err != nil {
|
||||
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 {
|
||||
handleError(w, err, "Could not read job trace file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response := JobTraceResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Status: http.StatusOK,
|
||||
Message: "Log file read",
|
||||
},
|
||||
File: string(file),
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
71
cmd/app/job_test.go
Normal file
71
cmd/app/job_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type fakeTraceFileGetter struct {
|
||||
testBase
|
||||
}
|
||||
|
||||
func getTraceFileData(t *testing.T, svc ServiceWithHandler, request *http.Request) JobTraceResponse {
|
||||
res := httptest.NewRecorder()
|
||||
svc.handler(res, request)
|
||||
|
||||
var data JobTraceResponse
|
||||
err := json.Unmarshal(res.Body.Bytes(), &data)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (f fakeTraceFileGetter) GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
re := bytes.NewReader([]byte("Some data"))
|
||||
return re, resp, err
|
||||
}
|
||||
|
||||
// var jobId = 0
|
||||
func TestJobHandler(t *testing.T) {
|
||||
t.Run("Should read a job trace file", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{})
|
||||
client := fakeTraceFileGetter{}
|
||||
svc := traceFileService{testProjectData, client}
|
||||
data := getTraceFileData(t, svc, request)
|
||||
assert(t, data.Message, "Log file read")
|
||||
assert(t, data.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{})
|
||||
client := fakeTraceFileGetter{}
|
||||
svc := traceFileService{testProjectData, client}
|
||||
data := getFailData(t, svc, request)
|
||||
checkBadMethod(t, data, http.MethodGet)
|
||||
})
|
||||
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{})
|
||||
client := fakeTraceFileGetter{testBase{errFromGitlab: true}}
|
||||
svc := traceFileService{testProjectData, client}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Could not get trace file for job")
|
||||
})
|
||||
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{})
|
||||
client := fakeTraceFileGetter{testBase{status: http.StatusSeeOther}}
|
||||
svc := traceFileService{testProjectData, client}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not get trace file for job", "/job")
|
||||
})
|
||||
}
|
||||
140
cmd/app/label.go
Normal file
140
cmd/app/label.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type LabelUpdateRequest struct {
|
||||
Labels []string `json:"labels"`
|
||||
}
|
||||
|
||||
type Label struct {
|
||||
Name string
|
||||
Color string
|
||||
}
|
||||
|
||||
type LabelUpdateResponse struct {
|
||||
SuccessResponse
|
||||
Labels gitlab.Labels `json:"labels"`
|
||||
}
|
||||
|
||||
type LabelsRequestResponse struct {
|
||||
SuccessResponse
|
||||
Labels []Label `json:"labels"`
|
||||
}
|
||||
|
||||
type LabelManager interface {
|
||||
UpdateMergeRequest(interface{}, int, *gitlab.UpdateMergeRequestOptions, ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
|
||||
ListLabels(interface{}, *gitlab.ListLabelsOptions, ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type labelService struct {
|
||||
data
|
||||
client LabelManager
|
||||
}
|
||||
|
||||
/* labelsHandler adds or removes labels from a merge request, and returns all labels for the current project */
|
||||
func (a labelService) handler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
a.getLabels(w, r)
|
||||
case http.MethodPut:
|
||||
a.updateLabels(w, r)
|
||||
default:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s", http.MethodPut, http.MethodGet))
|
||||
handleError(w, InvalidRequestError{}, "Expected GET or PUT", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (a labelService) getLabels(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
labels, res, err := a.client.ListLabels(a.projectInfo.ProjectId, &gitlab.ListLabelsOptions{})
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not modify merge request labels", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/mr/label"}, "Could not modify merge request labels", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
/* Hacky, but convert them to the correct response */
|
||||
convertedLabels := make([]Label, len(labels))
|
||||
for i, labelPtr := range labels {
|
||||
convertedLabels[i] = Label{
|
||||
Name: labelPtr.Name,
|
||||
Color: labelPtr.Color,
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := LabelsRequestResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: "Labels updated",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
Labels: convertedLabels,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (a labelService) updateLabels(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
var labelUpdateRequest LabelUpdateRequest
|
||||
err = json.Unmarshal(body, &labelUpdateRequest)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var labels = gitlab.LabelOptions(labelUpdateRequest.Labels)
|
||||
mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{
|
||||
Labels: &labels,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not modify merge request labels", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/mr/label"}, "Could not modify merge request labels", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := LabelUpdateResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: "Labels updated",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
Labels: mr.Labels,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
215
cmd/app/list_discussions.go
Normal file
215
cmd/app/list_discussions.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"encoding/json"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
func Contains[T comparable](elems []T, v T) bool {
|
||||
for _, s := range elems {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type DiscussionsRequest struct {
|
||||
Blacklist []string `json:"blacklist"`
|
||||
}
|
||||
|
||||
type DiscussionsResponse struct {
|
||||
SuccessResponse
|
||||
Discussions []*gitlab.Discussion `json:"discussions"`
|
||||
UnlinkedDiscussions []*gitlab.Discussion `json:"unlinked_discussions"`
|
||||
Emojis map[int][]*gitlab.AwardEmoji `json:"emojis"`
|
||||
}
|
||||
|
||||
type SortableDiscussions []*gitlab.Discussion
|
||||
|
||||
func (n SortableDiscussions) Len() int {
|
||||
return len(n)
|
||||
}
|
||||
|
||||
func (d SortableDiscussions) Less(i int, j int) bool {
|
||||
iTime := d[i].Notes[len(d[i].Notes)-1].CreatedAt
|
||||
jTime := d[j].Notes[len(d[j].Notes)-1].CreatedAt
|
||||
return iTime.After(*jTime)
|
||||
}
|
||||
|
||||
func (n SortableDiscussions) Swap(i, j int) {
|
||||
n[i], n[j] = n[j], n[i]
|
||||
}
|
||||
|
||||
type DiscussionsLister interface {
|
||||
ListMergeRequestDiscussions(pid interface{}, mergeRequest int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error)
|
||||
ListMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type discussionsListerService struct {
|
||||
data
|
||||
client DiscussionsLister
|
||||
}
|
||||
|
||||
/*
|
||||
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 discussionsListerService) handler(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 := a.client.ListMergeRequestDiscussions(a.projectInfo.ProjectId, a.projectInfo.MergeId, &mergeRequestDiscussionOptions)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not list discussions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/mr/discussions/list"}, "Could not list discussions", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
/* Filter out any discussions started by a blacklisted user
|
||||
and system discussions, then return them sorted by created date */
|
||||
var unlinkedDiscussions []*gitlab.Discussion
|
||||
var linkedDiscussions []*gitlab.Discussion
|
||||
|
||||
for _, discussion := range discussions {
|
||||
if discussion.Notes == nil || len(discussion.Notes) == 0 || Contains(requestBody.Blacklist, discussion.Notes[0].Author.Username) {
|
||||
continue
|
||||
}
|
||||
for _, note := range discussion.Notes {
|
||||
if note.Type == gitlab.NoteTypeValue("DiffNote") {
|
||||
linkedDiscussions = append(linkedDiscussions, discussion)
|
||||
break
|
||||
} else if !note.System && note.Position == nil {
|
||||
unlinkedDiscussions = append(unlinkedDiscussions, discussion)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Collect IDs in order to fetch emojis */
|
||||
var noteIds []int
|
||||
for _, discussion := range discussions {
|
||||
for _, note := range discussion.Notes {
|
||||
noteIds = append(noteIds, note.ID)
|
||||
}
|
||||
}
|
||||
|
||||
emojis, err := a.fetchEmojisForNotesAndComments(noteIds)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not fetch emojis", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sortedLinkedDiscussions := SortableDiscussions(linkedDiscussions)
|
||||
sortedUnlinkedDiscussions := SortableDiscussions(unlinkedDiscussions)
|
||||
|
||||
sort.Sort(sortedLinkedDiscussions)
|
||||
sort.Sort(sortedUnlinkedDiscussions)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := DiscussionsResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: "Discussions retrieved",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
Discussions: linkedDiscussions,
|
||||
UnlinkedDiscussions: unlinkedDiscussions,
|
||||
Emojis: emojis,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Fetches emojis for a set of notes and comments in parallel and returns a map of note IDs to their emojis.
|
||||
Gitlab's API does not allow for fetching notes for an entire discussion thread so we have to do it per-note.
|
||||
*/
|
||||
func (a discussionsListerService) fetchEmojisForNotesAndComments(noteIDs []int) (map[int][]*gitlab.AwardEmoji, error) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
emojis := make(map[int][]*gitlab.AwardEmoji)
|
||||
mu := &sync.Mutex{}
|
||||
errs := make(chan error, len(noteIDs))
|
||||
emojiChan := make(chan struct {
|
||||
noteID int
|
||||
emojis []*gitlab.AwardEmoji
|
||||
}, len(noteIDs))
|
||||
|
||||
for _, noteID := range noteIDs {
|
||||
wg.Add(1)
|
||||
go func(noteID int) {
|
||||
defer wg.Done()
|
||||
emojis, _, err := a.client.ListMergeRequestAwardEmojiOnNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, noteID, &gitlab.ListAwardEmojiOptions{})
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
emojiChan <- struct {
|
||||
noteID int
|
||||
emojis []*gitlab.AwardEmoji
|
||||
}{noteID, emojis}
|
||||
}(noteID)
|
||||
}
|
||||
|
||||
/* Close the channels when all goroutines finish */
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
close(emojiChan)
|
||||
}()
|
||||
|
||||
/* Collect emojis */
|
||||
for e := range emojiChan {
|
||||
mu.Lock()
|
||||
emojis[e.noteID] = e.emojis
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
/* Check if any errors occurred */
|
||||
if len(errs) > 0 {
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return emojis, nil
|
||||
}
|
||||
113
cmd/app/list_discussions_test.go
Normal file
113
cmd/app/list_discussions_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type fakeDiscussionsLister struct {
|
||||
testBase
|
||||
badEmojiResponse bool
|
||||
}
|
||||
|
||||
func (f fakeDiscussionsLister) ListMergeRequestDiscussions(pid interface{}, mergeRequest int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
newer := now.Add(time.Second * 100)
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
testListDiscussionsResponse := []*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 testListDiscussionsResponse, resp, err
|
||||
}
|
||||
|
||||
func (f fakeDiscussionsLister) ListMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if f.badEmojiResponse {
|
||||
return nil, nil, errors.New("Some error from emoji service")
|
||||
}
|
||||
return []*gitlab.AwardEmoji{}, resp, err
|
||||
}
|
||||
|
||||
func getDiscussionsList(t *testing.T, svc ServiceWithHandler, request *http.Request) DiscussionsResponse {
|
||||
res := httptest.NewRecorder()
|
||||
svc.handler(res, request)
|
||||
|
||||
var data DiscussionsResponse
|
||||
err := json.Unmarshal(res.Body.Bytes(), &data)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestListDiscussions(t *testing.T) {
|
||||
t.Run("Returns sorted discussions", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{})
|
||||
svc := discussionsListerService{testProjectData, fakeDiscussionsLister{}}
|
||||
data := getDiscussionsList(t, svc, request)
|
||||
assert(t, data.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, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{"hcramer"}})
|
||||
svc := discussionsListerService{testProjectData, fakeDiscussionsLister{}}
|
||||
data := getDiscussionsList(t, svc, request)
|
||||
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-GET methods", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/mr/discussions/list", DiscussionsRequest{})
|
||||
svc := discussionsListerService{testProjectData, fakeDiscussionsLister{}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkBadMethod(t, data, http.MethodPost)
|
||||
})
|
||||
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{})
|
||||
svc := discussionsListerService{testProjectData, fakeDiscussionsLister{testBase: testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Could not list discussions")
|
||||
})
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{})
|
||||
svc := discussionsListerService{testProjectData, fakeDiscussionsLister{testBase: testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not list discussions", "/mr/discussions/list")
|
||||
})
|
||||
t.Run("Handles error from emoji service", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{})
|
||||
svc := discussionsListerService{testProjectData, fakeDiscussionsLister{badEmojiResponse: true}}
|
||||
data := getFailData(t, svc, request)
|
||||
assert(t, data.Message, "Could not fetch emojis")
|
||||
assert(t, data.Details, "Some error from emoji service")
|
||||
})
|
||||
}
|
||||
65
cmd/app/members.go
Normal file
65
cmd/app/members.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type ProjectMembersResponse struct {
|
||||
SuccessResponse
|
||||
ProjectMembers []*gitlab.ProjectMember
|
||||
}
|
||||
|
||||
type ProjectMemberLister interface {
|
||||
ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type projectMemberService struct {
|
||||
data
|
||||
client ProjectMemberLister
|
||||
}
|
||||
|
||||
/* projectMembersHandler returns all members of the current Gitlab project */
|
||||
func (a projectMemberService) handler(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{
|
||||
PerPage: 100,
|
||||
},
|
||||
}
|
||||
|
||||
projectMembers, res, err := a.client.ListAllProjectMembers(a.projectInfo.ProjectId, &projectMemberOptions)
|
||||
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
response := ProjectMembersResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Status: http.StatusOK,
|
||||
Message: "Project members retrieved",
|
||||
},
|
||||
ProjectMembers: projectMembers,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
48
cmd/app/members_test.go
Normal file
48
cmd/app/members_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type fakeMemberLister struct {
|
||||
testBase
|
||||
}
|
||||
|
||||
func (f fakeMemberLister) ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return []*gitlab.ProjectMember{}, resp, err
|
||||
}
|
||||
|
||||
func TestMembersHandler(t *testing.T) {
|
||||
t.Run("Returns project members", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/project/members", nil)
|
||||
svc := projectMemberService{testProjectData, fakeMemberLister{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
assert(t, data.Message, "Project members retrieved")
|
||||
})
|
||||
t.Run("Disallows non-GET methods", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/project/members", nil)
|
||||
svc := projectMemberService{testProjectData, fakeMemberLister{}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkBadMethod(t, data, http.MethodGet)
|
||||
})
|
||||
t.Run("Handles error from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/project/members", nil)
|
||||
svc := projectMemberService{testProjectData, fakeMemberLister{testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
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)
|
||||
svc := projectMemberService{testProjectData, fakeMemberLister{testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not retrieve project members", "/project/members")
|
||||
})
|
||||
}
|
||||
80
cmd/app/merge_mr.go
Normal file
80
cmd/app/merge_mr.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type AcceptMergeRequestRequest struct {
|
||||
Squash bool `json:"squash"`
|
||||
SquashMessage string `json:"squash_message"`
|
||||
DeleteBranch bool `json:"delete_branch"`
|
||||
}
|
||||
|
||||
type MergeRequestAccepter interface {
|
||||
AcceptMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type mergeRequestAccepterService struct {
|
||||
data
|
||||
client MergeRequestAccepter
|
||||
}
|
||||
|
||||
/* acceptAndMergeHandler merges a given merge request into the target branch */
|
||||
func (a mergeRequestAccepterService) handler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Methods", http.MethodGet)
|
||||
if r.Method != 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)
|
||||
return
|
||||
}
|
||||
|
||||
var acceptAndMergeRequest AcceptMergeRequestRequest
|
||||
err = json.Unmarshal(body, &acceptAndMergeRequest)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not unmarshal request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
opts := gitlab.AcceptMergeRequestOptions{
|
||||
Squash: &acceptAndMergeRequest.Squash,
|
||||
ShouldRemoveSourceBranch: &acceptAndMergeRequest.DeleteBranch,
|
||||
}
|
||||
|
||||
if acceptAndMergeRequest.SquashMessage != "" {
|
||||
opts.SquashCommitMessage = &acceptAndMergeRequest.SquashMessage
|
||||
}
|
||||
|
||||
_, res, err := a.client.AcceptMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opts)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not merge MR", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/mr/merge"}, "Could not merge MR", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
response := SuccessResponse{
|
||||
Status: http.StatusOK,
|
||||
Message: "MR merged successfully",
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
50
cmd/app/merge_mr_test.go
Normal file
50
cmd/app/merge_mr_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type fakeMergeRequestAccepter struct {
|
||||
testBase
|
||||
}
|
||||
|
||||
func (f fakeMergeRequestAccepter) AcceptMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return &gitlab.MergeRequest{}, resp, err
|
||||
}
|
||||
|
||||
func TestAcceptAndMergeHandler(t *testing.T) {
|
||||
var testAcceptMergeRequestPayload = AcceptMergeRequestRequest{Squash: false, SquashMessage: "Squash me!", DeleteBranch: false}
|
||||
t.Run("Accepts and merges a merge request", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload)
|
||||
svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Message, "MR merged successfully")
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
})
|
||||
t.Run("Disallows non-POST methods", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPut, "/mr/merge", testAcceptMergeRequestPayload)
|
||||
svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkBadMethod(t, data, http.MethodPost)
|
||||
})
|
||||
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload)
|
||||
svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Could not merge MR")
|
||||
})
|
||||
t.Run("Handles non-200s from Gitlab", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload)
|
||||
svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not merge MR", "/mr/merge")
|
||||
})
|
||||
}
|
||||
86
cmd/app/merge_requests.go
Normal file
86
cmd/app/merge_requests.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type ListMergeRequestResponse struct {
|
||||
SuccessResponse
|
||||
MergeRequests []*gitlab.MergeRequest `json:"merge_requests"`
|
||||
}
|
||||
|
||||
type MergeRequestLister interface {
|
||||
ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type mergeRequestListerService struct {
|
||||
data
|
||||
client MergeRequestLister
|
||||
}
|
||||
|
||||
func (a mergeRequestListerService) handler(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)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
var listMergeRequestRequest gitlab.ListProjectMergeRequestsOptions
|
||||
err = json.Unmarshal(body, &listMergeRequestRequest)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if listMergeRequestRequest.State == nil {
|
||||
listMergeRequestRequest.State = gitlab.Ptr("opened")
|
||||
}
|
||||
|
||||
if listMergeRequestRequest.Scope == nil {
|
||||
listMergeRequestRequest.Scope = gitlab.Ptr("all")
|
||||
}
|
||||
|
||||
mergeRequests, res, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, &listMergeRequestRequest)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Failed to list merge requests", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/merge_requests"}, "Failed to list merge requests", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
if len(mergeRequests) == 0 {
|
||||
handleError(w, errors.New("No merge requests found"), "No merge requests found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := ListMergeRequestResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: "Merge requests fetched successfully",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
MergeRequests: mergeRequests,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
162
cmd/app/merge_requests_by_username.go
Normal file
162
cmd/app/merge_requests_by_username.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type MergeRequestListerByUsername interface {
|
||||
ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type mergeRequestListerByUsernameService struct {
|
||||
data
|
||||
client MergeRequestListerByUsername
|
||||
}
|
||||
|
||||
type MergeRequestByUsernameRequest struct {
|
||||
UserId int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
State string `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
func (a mergeRequestListerByUsernameService) handler(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)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
var request MergeRequestByUsernameRequest
|
||||
err = json.Unmarshal(body, &request)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if request.Username == "" {
|
||||
handleError(w, errors.New("username is a required payload field"), "username is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if request.UserId == 0 {
|
||||
handleError(w, errors.New("user_id is a required payload field"), "user_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if request.State == "" {
|
||||
request.State = "opened"
|
||||
}
|
||||
|
||||
payloads := []gitlab.ListProjectMergeRequestsOptions{
|
||||
{
|
||||
AuthorUsername: gitlab.Ptr(request.Username),
|
||||
State: gitlab.Ptr(request.State),
|
||||
Scope: gitlab.Ptr("all"),
|
||||
},
|
||||
{
|
||||
ReviewerUsername: gitlab.Ptr(request.Username),
|
||||
State: gitlab.Ptr(request.State),
|
||||
Scope: gitlab.Ptr("all"),
|
||||
},
|
||||
{
|
||||
AssigneeID: gitlab.AssigneeID(request.UserId),
|
||||
State: gitlab.Ptr(request.State),
|
||||
Scope: gitlab.Ptr("all"),
|
||||
},
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
mrs []*gitlab.MergeRequest
|
||||
err error
|
||||
}
|
||||
|
||||
mrChan := make(chan apiResponse, len(payloads))
|
||||
wg := sync.WaitGroup{}
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(mrChan)
|
||||
}()
|
||||
|
||||
for _, payload := range payloads {
|
||||
wg.Add(1)
|
||||
go func(p gitlab.ListProjectMergeRequestsOptions) {
|
||||
defer wg.Done()
|
||||
mrs, err := a.getMrs(&p)
|
||||
mrChan <- apiResponse{mrs, err}
|
||||
}(payload)
|
||||
}
|
||||
|
||||
var mergeRequests []*gitlab.MergeRequest
|
||||
existingIds := make(map[int]bool)
|
||||
var errs []error
|
||||
for res := range mrChan {
|
||||
if res.err != nil {
|
||||
errs = append(errs, res.err)
|
||||
} else {
|
||||
for _, mr := range res.mrs {
|
||||
if !existingIds[mr.ID] {
|
||||
mergeRequests = append(mergeRequests, mr)
|
||||
existingIds[mr.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
combinedErr := ""
|
||||
for _, err := range errs {
|
||||
combinedErr += err.Error() + "; "
|
||||
}
|
||||
handleError(w, errors.New(combinedErr), "An error occurred", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(mergeRequests) == 0 {
|
||||
handleError(w, fmt.Errorf("%s did not have any MRs", request.Username), "No MRs found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := ListMergeRequestResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: fmt.Sprintf("Merge requests fetched for %s", request.Username),
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
MergeRequests: mergeRequests,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (a mergeRequestListerByUsernameService) getMrs(payload *gitlab.ListProjectMergeRequestsOptions) ([]*gitlab.MergeRequest, error) {
|
||||
mrs, res, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, payload)
|
||||
if err != nil {
|
||||
return []*gitlab.MergeRequest{}, err
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
return []*gitlab.MergeRequest{}, GenericError{endpoint: "/merge_requests_by_username"}
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
return mrs, err
|
||||
}
|
||||
87
cmd/app/merge_requests_by_username_test.go
Normal file
87
cmd/app/merge_requests_by_username_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type fakeMergeRequestListerByUsername struct {
|
||||
testBase
|
||||
emptyResponse bool
|
||||
}
|
||||
|
||||
func (f fakeMergeRequestListerByUsername) ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if f.emptyResponse {
|
||||
return []*gitlab.MergeRequest{}, resp, err
|
||||
}
|
||||
|
||||
return []*gitlab.MergeRequest{{IID: 10}}, resp, err
|
||||
}
|
||||
|
||||
func TestListMergeRequestByUsername(t *testing.T) {
|
||||
var testListMrsByUsernamePayload = MergeRequestByUsernameRequest{Username: "hcramer", UserId: 1234, State: "opened"}
|
||||
t.Run("Gets merge requests by username", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload)
|
||||
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Message, "Merge requests fetched for hcramer")
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Should handle no merge requests", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload)
|
||||
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{emptyResponse: true}}
|
||||
data := getFailData(t, svc, request)
|
||||
assert(t, data.Message, "No MRs found")
|
||||
assert(t, data.Details, "hcramer did not have any MRs")
|
||||
assert(t, data.Status, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("Should require username", func(t *testing.T) {
|
||||
missingUsernamePayload := testListMrsByUsernamePayload
|
||||
missingUsernamePayload.Username = ""
|
||||
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", missingUsernamePayload)
|
||||
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}}
|
||||
data := getFailData(t, svc, request)
|
||||
assert(t, data.Message, "username is required")
|
||||
assert(t, data.Details, "username is a required payload field")
|
||||
assert(t, data.Status, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("Should require User ID for assignee call", func(t *testing.T) {
|
||||
missingUsernamePayload := testListMrsByUsernamePayload
|
||||
missingUsernamePayload.UserId = 0
|
||||
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", missingUsernamePayload)
|
||||
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}}
|
||||
data := getFailData(t, svc, request)
|
||||
assert(t, data.Message, "user_id is required")
|
||||
assert(t, data.Details, "user_id is a required payload field")
|
||||
assert(t, data.Status, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("Should handle error from Gitlab", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload)
|
||||
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{testBase: testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
assert(t, data.Message, "An error occurred")
|
||||
assert(t, data.Details, strings.Repeat("Some error from Gitlab; ", 3))
|
||||
assert(t, data.Status, http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
t.Run("Handles non-200 from Gitlab", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload)
|
||||
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{testBase: testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
assert(t, data.Message, "An error occurred")
|
||||
assert(t, data.Details, strings.Repeat("An error occurred on the /merge_requests_by_username endpoint; ", 3))
|
||||
assert(t, data.Status, http.StatusInternalServerError)
|
||||
})
|
||||
}
|
||||
58
cmd/app/merge_requests_test.go
Normal file
58
cmd/app/merge_requests_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type fakeMergeRequestLister struct {
|
||||
testBase
|
||||
emptyResponse bool
|
||||
}
|
||||
|
||||
func (f fakeMergeRequestLister) ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if f.emptyResponse {
|
||||
return []*gitlab.MergeRequest{}, resp, err
|
||||
}
|
||||
|
||||
return []*gitlab.MergeRequest{{IID: 10}}, resp, err
|
||||
}
|
||||
|
||||
func TestMergeRequestHandler(t *testing.T) {
|
||||
var testListMergeRequestsRequest = gitlab.ListProjectMergeRequestsOptions{}
|
||||
t.Run("Should fetch merge requests", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest)
|
||||
svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
assert(t, data.Message, "Merge requests fetched successfully")
|
||||
})
|
||||
t.Run("Handles error from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest)
|
||||
svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{testBase: testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Failed to list merge requests")
|
||||
assert(t, data.Status, http.StatusInternalServerError)
|
||||
})
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest)
|
||||
svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{testBase: testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Failed to list merge requests", "/merge_requests")
|
||||
assert(t, data.Status, http.StatusSeeOther)
|
||||
})
|
||||
t.Run("Should handle not having any merge requests with 404", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest)
|
||||
svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{emptyResponse: true}}
|
||||
data := getFailData(t, svc, request)
|
||||
assert(t, data.Message, "No merge requests found")
|
||||
assert(t, data.Status, http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
173
cmd/app/pipeline.go
Normal file
173
cmd/app/pipeline.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/harrisoncramer/gitlab.nvim/cmd/app/git"
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type RetriggerPipelineResponse struct {
|
||||
SuccessResponse
|
||||
LatestPipeline *gitlab.Pipeline `json:"latest_pipeline"`
|
||||
}
|
||||
|
||||
type PipelineWithJobs struct {
|
||||
Jobs []*gitlab.Job `json:"jobs"`
|
||||
LatestPipeline *gitlab.PipelineInfo `json:"latest_pipeline"`
|
||||
}
|
||||
|
||||
type GetPipelineAndJobsResponse struct {
|
||||
SuccessResponse
|
||||
Pipeline PipelineWithJobs `json:"latest_pipeline"`
|
||||
}
|
||||
|
||||
type PipelineManager interface {
|
||||
ListProjectPipelines(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error)
|
||||
ListPipelineJobs(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error)
|
||||
RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type pipelineService struct {
|
||||
data
|
||||
client PipelineManager
|
||||
gitService git.GitManager
|
||||
}
|
||||
|
||||
/*
|
||||
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 pipelineService) handler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
a.GetPipelineAndJobs(w, r)
|
||||
case http.MethodPost:
|
||||
a.RetriggerPipeline(w, r)
|
||||
default:
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/* Gets the latest pipeline for a given commit, returns an error if there is no pipeline */
|
||||
func (a pipelineService) GetLastPipeline(commit string) (*gitlab.PipelineInfo, error) {
|
||||
|
||||
l := &gitlab.ListProjectPipelinesOptions{
|
||||
SHA: gitlab.Ptr(commit),
|
||||
Sort: gitlab.Ptr("desc"),
|
||||
}
|
||||
|
||||
pipes, res, err := a.client.ListProjectPipelines(a.projectInfo.ProjectId, l)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
return nil, errors.New("Could not get pipelines")
|
||||
}
|
||||
|
||||
if len(pipes) == 0 {
|
||||
return nil, errors.New("No pipeline running or available for commit " + commit)
|
||||
}
|
||||
|
||||
return pipes[0], nil
|
||||
}
|
||||
|
||||
/* Gets the latest pipeline and job information for the current branch */
|
||||
func (a pipelineService) GetPipelineAndJobs(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
commit, err := a.gitService.GetLatestCommitOnRemote(pluginOptions.ConnectionSettings.Remote, a.gitInfo.BranchName)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Error getting commit on remote branch", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
pipeline, err := a.GetLastPipeline(commit)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, fmt.Sprintf("Failed to get latest pipeline for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if pipeline == nil {
|
||||
handleError(w, GenericError{endpoint: "/pipeline"}, fmt.Sprintf("No pipeline found for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
jobs, res, err := a.client.ListPipelineJobs(a.projectInfo.ProjectId, pipeline.ID, &gitlab.ListJobsOptions{})
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not get pipeline jobs", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/pipeline"}, "Could not get pipeline jobs", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := GetPipelineAndJobsResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Status: http.StatusOK,
|
||||
Message: "Pipeline retrieved",
|
||||
},
|
||||
Pipeline: PipelineWithJobs{
|
||||
LatestPipeline: pipeline,
|
||||
Jobs: jobs,
|
||||
},
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (a pipelineService) RetriggerPipeline(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
id := strings.TrimPrefix(r.URL.Path, "/pipeline/trigger/")
|
||||
|
||||
idInt, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not convert pipeline ID to integer", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pipeline, res, err := a.client.RetryPipelineBuild(a.projectInfo.ProjectId, idInt)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not retrigger pipeline", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/pipeline"}, "Could not retrigger pipeline", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := RetriggerPipelineResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: "Pipeline retriggered",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
LatestPipeline: pipeline,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
86
cmd/app/pipeline_test.go
Normal file
86
cmd/app/pipeline_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type fakePipelineManager struct {
|
||||
testBase
|
||||
}
|
||||
|
||||
func (f fakePipelineManager) ListProjectPipelines(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return []*gitlab.PipelineInfo{{ID: 1234}}, resp, err
|
||||
}
|
||||
|
||||
func (f fakePipelineManager) ListPipelineJobs(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return []*gitlab.Job{}, resp, err
|
||||
}
|
||||
|
||||
func (f fakePipelineManager) RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &gitlab.Pipeline{}, resp, err
|
||||
}
|
||||
|
||||
func TestPipelineGetter(t *testing.T) {
|
||||
t.Run("Gets all pipeline jobs", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/pipeline", nil)
|
||||
svc := pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Message, "Pipeline retrieved")
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
})
|
||||
t.Run("Disallows non-GET, non-POST methods", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPatch, "/pipeline", nil)
|
||||
svc := pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkBadMethod(t, data, http.MethodGet, http.MethodPost)
|
||||
})
|
||||
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/pipeline", nil)
|
||||
svc := pipelineService{testProjectData, fakePipelineManager{testBase{errFromGitlab: true}}, FakeGitManager{}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Failed to get latest pipeline for some-branch branch")
|
||||
})
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/pipeline", nil)
|
||||
svc := pipelineService{testProjectData, fakePipelineManager{testBase: testBase{status: http.StatusSeeOther}}, FakeGitManager{}}
|
||||
data := getFailData(t, svc, request)
|
||||
assert(t, data.Message, "Failed to get latest pipeline for some-branch branch") // Expected, we treat this as an error
|
||||
})
|
||||
}
|
||||
|
||||
func TestPipelineTrigger(t *testing.T) {
|
||||
t.Run("Retriggers pipeline", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil)
|
||||
svc := pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Message, "Pipeline retriggered")
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
})
|
||||
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil)
|
||||
svc := pipelineService{testProjectData, fakePipelineManager{testBase{errFromGitlab: true}}, FakeGitManager{}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Could not retrigger pipeline")
|
||||
})
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil)
|
||||
svc := pipelineService{testProjectData, fakePipelineManager{testBase: testBase{status: http.StatusSeeOther}}, FakeGitManager{}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not retrigger pipeline", "/pipeline")
|
||||
})
|
||||
}
|
||||
87
cmd/app/reply.go
Normal file
87
cmd/app/reply.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type ReplyRequest struct {
|
||||
DiscussionId string `json:"discussion_id"`
|
||||
Reply string `json:"reply"`
|
||||
IsDraft bool `json:"is_draft"`
|
||||
}
|
||||
|
||||
type ReplyResponse struct {
|
||||
SuccessResponse
|
||||
Note *gitlab.Note `json:"note"`
|
||||
}
|
||||
|
||||
type ReplyManager interface {
|
||||
AddMergeRequestDiscussionNote(interface{}, int, string, *gitlab.AddMergeRequestDiscussionNoteOptions, ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type replyService struct {
|
||||
data
|
||||
client ReplyManager
|
||||
}
|
||||
|
||||
/* replyHandler sends a reply to a note or comment */
|
||||
func (a replyService) handler(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)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
var replyRequest ReplyRequest
|
||||
err = json.Unmarshal(body, &replyRequest)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
options := gitlab.AddMergeRequestDiscussionNoteOptions{
|
||||
Body: gitlab.Ptr(replyRequest.Reply),
|
||||
CreatedAt: &now,
|
||||
}
|
||||
|
||||
note, res, err := a.client.AddMergeRequestDiscussionNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, replyRequest.DiscussionId, &options)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not leave reply", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/mr/reply"}, "Could not leave reply", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := ReplyResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: "Replied to comment",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
Note: note,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
45
cmd/app/reply_test.go
Normal file
45
cmd/app/reply_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type fakeReplyManager struct {
|
||||
testBase
|
||||
}
|
||||
|
||||
func (f fakeReplyManager) AddMergeRequestDiscussionNote(interface{}, int, string, *gitlab.AddMergeRequestDiscussionNoteOptions, ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) {
|
||||
resp, err := f.handleGitlabError()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return &gitlab.Note{}, resp, err
|
||||
}
|
||||
|
||||
func TestReplyHandler(t *testing.T) {
|
||||
var testReplyRequest = ReplyRequest{DiscussionId: "abc123", Reply: "Some Reply", IsDraft: false}
|
||||
t.Run("Sends a reply", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest)
|
||||
svc := replyService{testProjectData, fakeReplyManager{}}
|
||||
data := getSuccessData(t, svc, request)
|
||||
assert(t, data.Message, "Replied to comment")
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
})
|
||||
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest)
|
||||
svc := replyService{testProjectData, fakeReplyManager{testBase{errFromGitlab: true}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkErrorFromGitlab(t, data, "Could not leave reply")
|
||||
})
|
||||
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest)
|
||||
svc := replyService{testProjectData, fakeReplyManager{testBase{status: http.StatusSeeOther}}}
|
||||
data := getFailData(t, svc, request)
|
||||
checkNon200(t, data, "Could not leave reply", "/mr/reply")
|
||||
})
|
||||
}
|
||||
83
cmd/app/resolve_discussion.go
Normal file
83
cmd/app/resolve_discussion.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type DiscussionResolveRequest struct {
|
||||
DiscussionID string `json:"discussion_id"`
|
||||
Resolved bool `json:"resolved"`
|
||||
}
|
||||
|
||||
type DiscussionResolver interface {
|
||||
ResolveMergeRequestDiscussion(pid interface{}, mergeRequest int, discussion string, opt *gitlab.ResolveMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type discussionsResolutionService struct {
|
||||
data
|
||||
client DiscussionResolver
|
||||
}
|
||||
|
||||
/* discussionsResolveHandler sets a discussion to be "resolved" or not resolved, depending on the payload */
|
||||
func (a discussionsResolutionService) handler(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 {
|
||||
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 {
|
||||
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 {
|
||||
handleError(w, err, fmt.Sprintf("Could not %s discussion", friendlyName), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/mr/discussions/resolve"}, fmt.Sprintf("Could not %s discussion", friendlyName), res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := SuccessResponse{
|
||||
Message: fmt.Sprintf("Discussion %sd", friendlyName),
|
||||
Status: http.StatusOK,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
30
cmd/app/response_types.go
Normal file
30
cmd/app/response_types.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ErrorResponse struct {
|
||||
Message string `json:"message"`
|
||||
Details string `json:"details"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
85
cmd/app/reviewer.go
Normal file
85
cmd/app/reviewer.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type ReviewerUpdateRequest struct {
|
||||
Ids []int `json:"ids"`
|
||||
}
|
||||
|
||||
type ReviewerUpdateResponse struct {
|
||||
SuccessResponse
|
||||
Reviewers []*gitlab.BasicUser `json:"reviewers"`
|
||||
}
|
||||
|
||||
type ReviewersRequestResponse struct {
|
||||
SuccessResponse
|
||||
Reviewers []int `json:"reviewers"`
|
||||
}
|
||||
|
||||
type MergeRequestUpdater interface {
|
||||
UpdateMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type reviewerService struct {
|
||||
data
|
||||
client MergeRequestUpdater
|
||||
}
|
||||
|
||||
/* reviewersHandler adds or removes reviewers from an MR */
|
||||
func (a reviewerService) handler(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 {
|
||||
handleError(w, err, "Could not read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
var reviewerUpdateRequest ReviewerUpdateRequest
|
||||
err = json.Unmarshal(body, &reviewerUpdateRequest)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{
|
||||
ReviewerIDs: &reviewerUpdateRequest.Ids,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not modify merge request reviewers", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
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",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
Reviewers: mr.Reviewers,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
61
cmd/app/revisions.go
Normal file
61
cmd/app/revisions.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type RevisionsResponse struct {
|
||||
SuccessResponse
|
||||
Revisions []*gitlab.MergeRequestDiffVersion
|
||||
}
|
||||
|
||||
type RevisionsGetter interface {
|
||||
GetMergeRequestDiffVersions(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type revisionsService struct {
|
||||
data
|
||||
client RevisionsGetter
|
||||
}
|
||||
|
||||
/*
|
||||
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 revisionsService) handler(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
|
||||
}
|
||||
|
||||
versionInfo, res, err := a.client.GetMergeRequestDiffVersions(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.GetMergeRequestDiffVersionsOptions{})
|
||||
if err != nil {
|
||||
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)
|
||||
response := RevisionsResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: "Revisions fetched successfully",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
Revisions: versionInfo,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
}
|
||||
50
cmd/app/revoke.go
Normal file
50
cmd/app/revoke.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type MergeRequestRevoker interface {
|
||||
UnapproveMergeRequest(interface{}, int, ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
|
||||
}
|
||||
|
||||
type mergeRequestRevokerService struct {
|
||||
data
|
||||
client MergeRequestRevoker
|
||||
}
|
||||
|
||||
/* revokeHandler revokes approval for the current merge request */
|
||||
func (a mergeRequestRevokerService) handler(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
|
||||
}
|
||||
|
||||
res, err := a.client.UnapproveMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, nil, nil)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not revoke approval", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/mr/revoke"}, "Could not revoke approval", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response := SuccessResponse{
|
||||
Message: "Success! Revoked MR approval",
|
||||
Status: http.StatusOK,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
199
cmd/app/server.go
Normal file
199
cmd/app/server.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/harrisoncramer/gitlab.nvim/cmd/app/git"
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
/*
|
||||
startSever starts the server and runs concurrent goroutines
|
||||
to handle potential shutdown requests and incoming HTTP requests.
|
||||
*/
|
||||
func StartServer(client *Client, projectInfo *ProjectInfo, GitInfo git.GitData) {
|
||||
|
||||
s := shutdown{
|
||||
sigCh: make(chan os.Signal, 1),
|
||||
}
|
||||
|
||||
fr := attachmentReader{}
|
||||
r := CreateRouter(
|
||||
client,
|
||||
projectInfo,
|
||||
s,
|
||||
func(a *data) error { a.projectInfo = projectInfo; return nil },
|
||||
func(a *data) error { a.gitInfo = &GitInfo; return nil },
|
||||
func(a *data) error { err := attachEmojis(a, fr); return err },
|
||||
)
|
||||
l := createListener()
|
||||
|
||||
server := &http.Server{Handler: r}
|
||||
|
||||
/* Starts the Go server */
|
||||
go func() {
|
||||
err := server.Serve(l)
|
||||
if err != nil {
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
os.Exit(0)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Server did not respond: %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 */
|
||||
s.WatchForShutdown(server)
|
||||
}
|
||||
|
||||
/*
|
||||
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
|
||||
*/
|
||||
|
||||
type data struct {
|
||||
projectInfo *ProjectInfo
|
||||
gitInfo *git.GitData
|
||||
emojiMap EmojiMap
|
||||
}
|
||||
|
||||
type optFunc func(a *data) error
|
||||
|
||||
func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s ShutdownHandler, optFuncs ...optFunc) *http.ServeMux {
|
||||
m := http.NewServeMux()
|
||||
|
||||
d := data{
|
||||
projectInfo: &ProjectInfo{},
|
||||
gitInfo: &git.GitData{},
|
||||
}
|
||||
|
||||
/* Mutates the API struct as necessary with configuration functions */
|
||||
for _, optFunc := range optFuncs {
|
||||
err := optFunc(&d)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
m.HandleFunc("/mr/approve", withMr(mergeRequestApproverService{d, gitlabClient}, d, gitlabClient))
|
||||
m.HandleFunc("/mr/comment", withMr(commentService{d, gitlabClient}, d, gitlabClient))
|
||||
m.HandleFunc("/mr/merge", withMr(mergeRequestAccepterService{d, gitlabClient}, d, gitlabClient))
|
||||
m.HandleFunc("/mr/discussions/list", withMr(discussionsListerService{d, gitlabClient}, d, gitlabClient))
|
||||
m.HandleFunc("/mr/discussions/resolve", withMr(discussionsResolutionService{d, gitlabClient}, d, gitlabClient))
|
||||
m.HandleFunc("/mr/info", withMr(infoService{d, gitlabClient}, d, gitlabClient))
|
||||
m.HandleFunc("/mr/assignee", withMr(assigneesService{d, gitlabClient}, d, gitlabClient))
|
||||
m.HandleFunc("/mr/summary", withMr(summaryService{d, gitlabClient}, d, gitlabClient))
|
||||
m.HandleFunc("/mr/reviewer", withMr(reviewerService{d, gitlabClient}, d, gitlabClient))
|
||||
m.HandleFunc("/mr/revisions", withMr(revisionsService{d, gitlabClient}, d, gitlabClient))
|
||||
m.HandleFunc("/mr/reply", withMr(replyService{d, gitlabClient}, d, gitlabClient))
|
||||
m.HandleFunc("/mr/label", withMr(labelService{d, gitlabClient}, d, gitlabClient))
|
||||
m.HandleFunc("/mr/revoke", withMr(mergeRequestRevokerService{d, gitlabClient}, d, gitlabClient))
|
||||
m.HandleFunc("/mr/awardable/note/", withMr(emojiService{d, gitlabClient}, d, gitlabClient))
|
||||
m.HandleFunc("/mr/draft_notes/", withMr(draftNoteService{d, gitlabClient}, d, gitlabClient))
|
||||
m.HandleFunc("/mr/draft_notes/publish", withMr(draftNotePublisherService{d, gitlabClient}, d, gitlabClient))
|
||||
|
||||
m.HandleFunc("/pipeline", pipelineService{d, gitlabClient, git.Git{}}.handler)
|
||||
m.HandleFunc("/pipeline/trigger/", pipelineService{d, gitlabClient, git.Git{}}.handler)
|
||||
m.HandleFunc("/users/me", meService{d, gitlabClient}.handler)
|
||||
m.HandleFunc("/attachment", attachmentService{data: d, client: gitlabClient, fileReader: attachmentReader{}}.handler)
|
||||
m.HandleFunc("/create_mr", mergeRequestCreatorService{d, gitlabClient}.handler)
|
||||
m.HandleFunc("/job", traceFileService{d, gitlabClient}.handler)
|
||||
m.HandleFunc("/project/members", projectMemberService{d, gitlabClient}.handler)
|
||||
m.HandleFunc("/merge_requests", mergeRequestListerService{d, gitlabClient}.handler)
|
||||
m.HandleFunc("/merge_requests_by_username", mergeRequestListerByUsernameService{d, gitlabClient}.handler)
|
||||
|
||||
m.HandleFunc("/shutdown", s.shutdownHandler)
|
||||
m.Handle("/ping", http.HandlerFunc(pingHandler))
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
addr := fmt.Sprintf("localhost:%d", pluginOptions.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
|
||||
}
|
||||
|
||||
type ServiceWithHandler interface {
|
||||
handler(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
/* withMr is a Middlware that gets the current merge request ID and attaches it to the projectInfo */
|
||||
func withMr(svc ServiceWithHandler, c data, client MergeRequestLister) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// If the merge request is already attached, skip the middleware logic
|
||||
if c.projectInfo.MergeId == 0 {
|
||||
options := gitlab.ListProjectMergeRequestsOptions{
|
||||
Scope: gitlab.Ptr("all"),
|
||||
SourceBranch: &c.gitInfo.BranchName,
|
||||
TargetBranch: pluginOptions.ChosenTargetBranch,
|
||||
}
|
||||
|
||||
mergeRequests, _, err := client.ListProjectMergeRequests(c.projectInfo.ProjectId, &options)
|
||||
if err != nil {
|
||||
handleError(w, fmt.Errorf("Failed to list merge requests: %w", err), "Failed to list merge requests", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(mergeRequests) == 0 {
|
||||
err := fmt.Errorf("No merge requests found for branch '%s'", c.gitInfo.BranchName)
|
||||
handleError(w, err, "No merge requests found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(mergeRequests) > 1 {
|
||||
err := errors.New("Please call gitlab.choose_merge_request()")
|
||||
handleError(w, err, "Multiple MRs found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
mergeIdInt := mergeRequests[0].IID
|
||||
c.projectInfo.MergeId = mergeIdInt
|
||||
}
|
||||
|
||||
// Call the next handler if middleware succeeds
|
||||
svc.handler(w, r)
|
||||
}
|
||||
}
|
||||
83
cmd/app/shutdown.go
Normal file
83
cmd/app/shutdown.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
type killer struct{}
|
||||
|
||||
func (k killer) Signal() {}
|
||||
func (k killer) String() string {
|
||||
return "0"
|
||||
}
|
||||
|
||||
type ShutdownHandler interface {
|
||||
WatchForShutdown(server *http.Server)
|
||||
shutdownHandler(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
type shutdown struct {
|
||||
sigCh chan os.Signal
|
||||
}
|
||||
|
||||
func (s shutdown) WatchForShutdown(server *http.Server) {
|
||||
/* Handles shutdown requests */
|
||||
<-s.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)
|
||||
}
|
||||
}
|
||||
|
||||
type ShutdownRequest struct {
|
||||
Restart bool `json:"restart"`
|
||||
}
|
||||
|
||||
/* shutdownHandler will shutdown the HTTP server and exit the process by signaling to the shutdown channel */
|
||||
func (s shutdown) 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 {
|
||||
s.sigCh <- killer{}
|
||||
}
|
||||
}
|
||||
80
cmd/app/summary.go
Normal file
80
cmd/app/summary.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type SummaryUpdateRequest struct {
|
||||
Description string `json:"description"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type SummaryUpdateResponse struct {
|
||||
SuccessResponse
|
||||
MergeRequest *gitlab.MergeRequest `json:"mr"`
|
||||
}
|
||||
|
||||
type summaryService struct {
|
||||
data
|
||||
client MergeRequestUpdater
|
||||
}
|
||||
|
||||
func (a summaryService) handler(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 {
|
||||
handleError(w, err, "Could not read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
var SummaryUpdateRequest SummaryUpdateRequest
|
||||
err = json.Unmarshal(body, &SummaryUpdateRequest)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{
|
||||
Description: &SummaryUpdateRequest.Description,
|
||||
Title: &SummaryUpdateRequest.Title,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not edit merge request summary", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/summary"}, "Could not edit merge request summary", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
response := SummaryUpdateResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: "Summary updated",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
MergeRequest: mr,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
}
|
||||
149
cmd/app/test_helpers.go
Normal file
149
cmd/app/test_helpers.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/harrisoncramer/gitlab.nvim/cmd/app/git"
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
var errorFromGitlab = errors.New("Some error from Gitlab")
|
||||
|
||||
/* 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
|
||||
}
|
||||
|
||||
/* 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,
|
||||
Body: http.NoBody,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var testProjectData = data{
|
||||
projectInfo: &ProjectInfo{},
|
||||
gitInfo: &git.GitData{
|
||||
BranchName: "some-branch",
|
||||
},
|
||||
}
|
||||
|
||||
func getSuccessData(t *testing.T, svc ServiceWithHandler, request *http.Request) SuccessResponse {
|
||||
res := httptest.NewRecorder()
|
||||
svc.handler(res, request)
|
||||
|
||||
var data SuccessResponse
|
||||
err := json.Unmarshal(res.Body.Bytes(), &data)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func getFailData(t *testing.T, svc ServiceWithHandler, request *http.Request) ErrorResponse {
|
||||
res := httptest.NewRecorder()
|
||||
svc.handler(res, request)
|
||||
|
||||
var data ErrorResponse
|
||||
err := json.Unmarshal(res.Body.Bytes(), &data)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
type testBase struct {
|
||||
errFromGitlab bool
|
||||
status int
|
||||
}
|
||||
|
||||
// Helper for easily mocking bad responses or errors from Gitlab
|
||||
func (f *testBase) handleGitlabError() (*gitlab.Response, error) {
|
||||
if f.errFromGitlab {
|
||||
return nil, errorFromGitlab
|
||||
}
|
||||
if f.status == 0 {
|
||||
f.status = 200
|
||||
}
|
||||
return makeResponse(f.status), nil
|
||||
}
|
||||
|
||||
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, errorFromGitlab.Error())
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
type FakeGitManager struct {
|
||||
RemoteUrl string
|
||||
BranchName string
|
||||
ProjectName string
|
||||
Namespace string
|
||||
}
|
||||
|
||||
func (f FakeGitManager) RefreshProjectInfo(remote string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f FakeGitManager) GetCurrentBranchNameFromNativeGitCmd() (string, error) {
|
||||
return f.BranchName, nil
|
||||
}
|
||||
|
||||
func (f FakeGitManager) GetLatestCommitOnRemote(remote string, branchName string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f FakeGitManager) GetProjectUrlFromNativeGitCmd(string) (url string, err error) {
|
||||
return f.RemoteUrl, nil
|
||||
}
|
||||
56
cmd/app/user.go
Normal file
56
cmd/app/user.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type UserResponse struct {
|
||||
SuccessResponse
|
||||
User *gitlab.User `json:"user"`
|
||||
}
|
||||
|
||||
type MeGetter interface {
|
||||
CurrentUser(options ...gitlab.RequestOptionFunc) (*gitlab.User, *gitlab.Response, error)
|
||||
}
|
||||
|
||||
type meService struct {
|
||||
data
|
||||
client MeGetter
|
||||
}
|
||||
|
||||
func (a meService) handler(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
|
||||
}
|
||||
|
||||
user, res, err := a.client.CurrentUser()
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Failed to get current user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, err, "User API returned non-200 status", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
response := UserResponse{
|
||||
SuccessResponse: SuccessResponse{
|
||||
Message: "User fetched successfully",
|
||||
Status: http.StatusOK,
|
||||
},
|
||||
User: user,
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user