fix: date fixes; go middleware refactors; regex fixes; etc (#368)

fix: format of date when MR was closed or merged (#367)
refactor: Add Payload Validators + Middleware In Go Code (#366)
fix: Add better checks for leaving comments (#369)
fix: regex support for http credentials embedded in remote url (#372)
fix: Comment on single line selects two lines (#371)

This is a #PATCH release.
This commit is contained in:
Harrison (Harry) Cramer
2024-09-14 16:53:00 -04:00
committed by GitHub
parent f1faf603b0
commit 22bfd0c83e
61 changed files with 1527 additions and 1284 deletions

View File

@@ -17,14 +17,7 @@ type mergeRequestApproverService struct {
} }
/* approveHandler approves a merge request. */ /* approveHandler approves a merge request. */
func (a mergeRequestApproverService) handler(w http.ResponseWriter, r *http.Request) { func (a mergeRequestApproverService) ServeHTTP(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) _, res, err := a.client.ApproveMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, nil, nil)
if err != nil { if err != nil {
@@ -33,15 +26,12 @@ func (a mergeRequestApproverService) handler(w http.ResponseWriter, r *http.Requ
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/approve"}, "Could not approve merge request", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not approve merge request", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := SuccessResponse{ response := SuccessResponse{Message: "Approved MR"}
Message: "Approved MR",
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)
if err != nil { if err != nil {

View File

@@ -23,33 +23,36 @@ func TestApproveHandler(t *testing.T) {
t.Run("Approves merge request", func(t *testing.T) { t.Run("Approves merge request", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/approve", nil) request := makeRequest(t, http.MethodPost, "/mr/approve", nil)
client := fakeApproverClient{} client := fakeApproverClient{}
svc := mergeRequestApproverService{testProjectData, client} svc := middleware(
mergeRequestApproverService{testProjectData, client},
withMr(testProjectData, fakeMergeRequestLister{}),
withMethodCheck(http.MethodPost),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Message, "Approved MR") 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) { t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/approve", nil) request := makeRequest(t, http.MethodPost, "/mr/approve", nil)
client := fakeApproverClient{testBase{errFromGitlab: true}} client := fakeApproverClient{testBase{errFromGitlab: true}}
svc := mergeRequestApproverService{testProjectData, client} svc := middleware(
data := getFailData(t, svc, request) mergeRequestApproverService{testProjectData, client},
withMr(testProjectData, fakeMergeRequestLister{}),
withMethodCheck(http.MethodPost),
)
data, _ := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not approve merge request") checkErrorFromGitlab(t, data, "Could not approve merge request")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/approve", nil) request := makeRequest(t, http.MethodPost, "/mr/approve", nil)
client := fakeApproverClient{testBase{status: http.StatusSeeOther}} client := fakeApproverClient{testBase{status: http.StatusSeeOther}}
svc := mergeRequestApproverService{testProjectData, client} svc := middleware(
data := getFailData(t, svc, request) mergeRequestApproverService{testProjectData, client},
withMr(testProjectData, fakeMergeRequestLister{}),
withMethodCheck(http.MethodPost),
)
data, _ := getFailData(t, svc, request)
checkNon200(t, data, "Could not approve merge request", "/mr/approve") checkNon200(t, data, "Could not approve merge request", "/mr/approve")
}) })
} }

View File

@@ -2,14 +2,14 @@ package app
import ( import (
"encoding/json" "encoding/json"
"io" "errors"
"net/http" "net/http"
"github.com/xanzy/go-gitlab" "github.com/xanzy/go-gitlab"
) )
type AssigneeUpdateRequest struct { type AssigneeUpdateRequest struct {
Ids []int `json:"ids"` Ids []int `json:"ids" validate:"required"`
} }
type AssigneeUpdateResponse struct { type AssigneeUpdateResponse struct {
@@ -17,37 +17,18 @@ type AssigneeUpdateResponse struct {
Assignees []*gitlab.BasicUser `json:"assignees"` Assignees []*gitlab.BasicUser `json:"assignees"`
} }
type AssigneesRequestResponse struct {
SuccessResponse
Assignees []int `json:"assignees"`
}
type assigneesService struct { type assigneesService struct {
data data
client MergeRequestUpdater client MergeRequestUpdater
} }
/* assigneesHandler adds or removes assignees from a merge request. */ /* assigneesHandler adds or removes assignees from a merge request. */
func (a assigneesService) handler(w http.ResponseWriter, r *http.Request) { func (a assigneesService) ServeHTTP(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) assigneeUpdateRequest, ok := r.Context().Value(payload("payload")).(*AssigneeUpdateRequest)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close() if !ok {
var assigneeUpdateRequest AssigneeUpdateRequest handleError(w, errors.New("Could not get payload from context"), "Bad payload", http.StatusInternalServerError)
err = json.Unmarshal(body, &assigneeUpdateRequest)
if err != nil {
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
return return
} }
@@ -61,17 +42,14 @@ func (a assigneesService) handler(w http.ResponseWriter, r *http.Request) {
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/assignee"}, "Could not modify merge request assignees", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not modify merge request assignees", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := AssigneeUpdateResponse{ response := AssigneeUpdateResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Assignees updated"},
Message: "Assignees updated", Assignees: mr.Assignees,
Status: http.StatusOK,
},
Assignees: mr.Assignees,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)

View File

@@ -24,34 +24,39 @@ func TestAssigneeHandler(t *testing.T) {
t.Run("Updates assignees", func(t *testing.T) { t.Run("Updates assignees", func(t *testing.T) {
request := makeRequest(t, http.MethodPut, "/mr/assignee", updatePayload) request := makeRequest(t, http.MethodPut, "/mr/assignee", updatePayload)
client := fakeAssigneeClient{} svc := middleware(
svc := assigneesService{testProjectData, client} assigneesService{testProjectData, fakeAssigneeClient{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPut: &AssigneeUpdateRequest{}}),
withMethodCheck(http.MethodPut),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Message, "Assignees updated") 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) { t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPut, "/mr/approve", updatePayload) request := makeRequest(t, http.MethodPut, "/mr/assignee", updatePayload)
client := fakeAssigneeClient{testBase{errFromGitlab: true}} client := fakeAssigneeClient{testBase{errFromGitlab: true}}
svc := assigneesService{testProjectData, client} svc := middleware(
data := getFailData(t, svc, request) assigneesService{testProjectData, client},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPut: &AssigneeUpdateRequest{}}),
withMethodCheck(http.MethodPut),
)
data, _ := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not modify merge request assignees") checkErrorFromGitlab(t, data, "Could not modify merge request assignees")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPut, "/mr/approve", updatePayload) request := makeRequest(t, http.MethodPut, "/mr/assignee", updatePayload)
client := fakeAssigneeClient{testBase{status: http.StatusSeeOther}} client := fakeAssigneeClient{testBase{status: http.StatusSeeOther}}
svc := assigneesService{testProjectData, client} svc := middleware(
data := getFailData(t, svc, request) assigneesService{testProjectData, client},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPut: &AssigneeUpdateRequest{}}),
withMethodCheck(http.MethodPut),
)
data, _ := getFailData(t, svc, request)
checkNon200(t, data, "Could not modify merge request assignees", "/mr/assignee") checkNon200(t, data, "Could not modify merge request assignees", "/mr/assignee")
}) })
} }

View File

@@ -16,8 +16,8 @@ type FileReader interface {
} }
type AttachmentRequest struct { type AttachmentRequest struct {
FilePath string `json:"file_path"` FilePath string `json:"file_path" validate:"required"`
FileName string `json:"file_name"` FileName string `json:"file_name" validate:"required"`
} }
type AttachmentResponse struct { type AttachmentResponse struct {
@@ -58,55 +58,31 @@ type attachmentService struct {
} }
/* attachmentHandler uploads an attachment (file, image, etc) to Gitlab and returns metadata about the upload. */ /* 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) { func (a attachmentService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") payload := r.Context().Value(payload("payload")).(*AttachmentRequest)
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 file, err := a.fileReader.ReadFile(payload.FilePath)
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 { if err != nil || file == nil {
handleError(w, err, fmt.Sprintf("Could not read %s file", attachmentRequest.FileName), http.StatusInternalServerError) handleError(w, err, fmt.Sprintf("Could not read %s file", payload.FileName), http.StatusInternalServerError)
return return
} }
projectFile, res, err := a.client.UploadFile(a.projectInfo.ProjectId, file, attachmentRequest.FileName) projectFile, res, err := a.client.UploadFile(a.projectInfo.ProjectId, file, payload.FileName)
if err != nil { if err != nil {
handleError(w, err, fmt.Sprintf("Could not upload %s to Gitlab", attachmentRequest.FileName), http.StatusInternalServerError) handleError(w, err, fmt.Sprintf("Could not upload %s to Gitlab", payload.FileName), http.StatusInternalServerError)
return return
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/attachment"}, fmt.Sprintf("Could not upload %s to Gitlab", attachmentRequest.FileName), res.StatusCode) handleError(w, GenericError{r.URL.Path}, fmt.Sprintf("Could not upload %s to Gitlab", payload.FileName), res.StatusCode)
return return
} }
response := AttachmentResponse{ response := AttachmentResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "File uploaded successfully"},
Status: http.StatusOK, Markdown: projectFile.Markdown,
Message: "File uploaded successfully", Alt: projectFile.Alt,
}, Url: projectFile.URL,
Markdown: projectFile.Markdown,
Alt: projectFile.Alt,
Url: projectFile.URL,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)

View File

@@ -36,29 +36,34 @@ func TestAttachmentHandler(t *testing.T) {
t.Run("Returns 200-status response after upload", func(t *testing.T) { t.Run("Returns 200-status response after upload", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData) request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData)
svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{}} svc := middleware(
attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{}},
withPayloadValidation(methodToPayload{http.MethodPost: &AttachmentRequest{}}),
withMethodCheck(http.MethodPost),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "File uploaded successfully") 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) { t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData) request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData)
svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{testBase{errFromGitlab: true}}} svc := middleware(
data := getFailData(t, svc, request) attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{testBase{errFromGitlab: true}}},
withPayloadValidation(methodToPayload{http.MethodPost: &AttachmentRequest{}}),
withMethodCheck(http.MethodPost),
)
data, _ := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not upload some_file_name to Gitlab") checkErrorFromGitlab(t, data, "Could not upload some_file_name to Gitlab")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData) request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData)
svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{testBase{status: http.StatusSeeOther}}} svc := middleware(
data := getFailData(t, svc, request) attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{testBase{status: http.StatusSeeOther}}},
withPayloadValidation(methodToPayload{http.MethodPost: &AttachmentRequest{}}),
withMethodCheck(http.MethodPost),
)
data, _ := getFailData(t, svc, request)
checkNon200(t, data, "Could not upload some_file_name to Gitlab", "/attachment") checkNon200(t, data, "Could not upload some_file_name to Gitlab", "/attachment")
}) })
} }

View File

@@ -5,10 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"net/http" "net/http"
"net/http/httputil"
"os"
"github.com/harrisoncramer/gitlab.nvim/cmd/app/git" "github.com/harrisoncramer/gitlab.nvim/cmd/app/git"
"github.com/hashicorp/go-retryablehttp" "github.com/hashicorp/go-retryablehttp"
@@ -48,12 +45,19 @@ func NewClient() (error, *Client) {
gitlab.WithBaseURL(apiCustUrl), gitlab.WithBaseURL(apiCustUrl),
} }
if pluginOptions.Debug.Request { if pluginOptions.Debug.GitlabRequest {
gitlabOptions = append(gitlabOptions, gitlab.WithRequestLogHook(requestLogger)) gitlabOptions = append(gitlabOptions, gitlab.WithRequestLogHook(
func(l retryablehttp.Logger, r *http.Request, i int) {
logRequest("REQUEST TO GITLAB", r)
},
))
} }
if pluginOptions.Debug.Response { if pluginOptions.Debug.GitlabResponse {
gitlabOptions = append(gitlabOptions, gitlab.WithResponseLogHook(responseLogger)) gitlabOptions = append(gitlabOptions, gitlab.WithResponseLogHook(func(l retryablehttp.Logger, response *http.Response) {
logResponse("RESPONSE FROM GITLAB", response)
},
))
} }
tr := &http.Transport{ tr := &http.Transport{
@@ -106,7 +110,6 @@ func InitProjectSettings(c *Client, gitInfo git.GitData) (error, *ProjectInfo) {
return nil, &ProjectInfo{ return nil, &ProjectInfo{
ProjectId: projectId, ProjectId: projectId,
} }
} }
/* handleError is a utililty handler that returns errors to the client along with their statuses and messages */ /* handleError is a utililty handler that returns errors to the client along with their statuses and messages */
@@ -115,7 +118,6 @@ func handleError(w http.ResponseWriter, err error, message string, status int) {
response := ErrorResponse{ response := ErrorResponse{
Message: message, Message: message,
Details: err.Error(), Details: err.Error(),
Status: status,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)
@@ -123,53 +125,3 @@ func handleError(w http.ResponseWriter, err error, message string, status int) {
handleError(w, err, "Could not encode error response", http.StatusInternalServerError) 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
}

View File

@@ -2,45 +2,17 @@ package app
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io"
"net/http" "net/http"
"github.com/xanzy/go-gitlab" "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 { type CommentResponse struct {
SuccessResponse SuccessResponse
Comment *gitlab.Note `json:"note"` Comment *gitlab.Note `json:"note"`
Discussion *gitlab.Discussion `json:"discussion"` 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 { type CommentManager interface {
CreateMergeRequestDiscussion(pid interface{}, mergeRequest int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) CreateMergeRequestDiscussion(pid interface{}, mergeRequest int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error)
UpdateMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) UpdateMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error)
@@ -53,7 +25,7 @@ type commentService struct {
} }
/* commentHandler creates, edits, and deletes discussions (comments, multi-line comments) */ /* commentHandler creates, edits, and deletes discussions (comments, multi-line comments) */
func (a commentService) handler(w http.ResponseWriter, r *http.Request) { func (a commentService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
switch r.Method { switch r.Method {
case http.MethodPost: case http.MethodPost:
@@ -62,30 +34,19 @@ func (a commentService) handler(w http.ResponseWriter, r *http.Request) {
a.editComment(w, r) a.editComment(w, r)
case http.MethodDelete: case http.MethodDelete:
a.deleteComment(w, r) 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)
} }
} }
type DeleteCommentRequest struct {
NoteId int `json:"note_id" validate:"required"`
DiscussionId string `json:"discussion_id" validate:"required"`
}
/* deleteComment deletes a note, multiline comment, or comment, which are all considered discussion notes. */ /* deleteComment deletes a note, multiline comment, or comment, which are all considered discussion notes. */
func (a commentService) deleteComment(w http.ResponseWriter, r *http.Request) { func (a commentService) deleteComment(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) payload := r.Context().Value(payload("payload")).(*DeleteCommentRequest)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close() res, err := a.client.DeleteMergeRequestDiscussionNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, payload.DiscussionId, payload.NoteId)
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 { if err != nil {
handleError(w, err, "Could not delete comment", http.StatusInternalServerError) handleError(w, err, "Could not delete comment", http.StatusInternalServerError)
@@ -93,15 +54,12 @@ func (a commentService) deleteComment(w http.ResponseWriter, r *http.Request) {
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/comment"}, "Could not delete comment", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not delete comment", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := SuccessResponse{ response := SuccessResponse{Message: "Comment deleted successfully"}
Message: "Comment deleted successfully",
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)
if err != nil { if err != nil {
@@ -109,32 +67,33 @@ func (a commentService) deleteComment(w http.ResponseWriter, r *http.Request) {
} }
} }
type PostCommentRequest struct {
Comment string `json:"comment" validate:"required"`
PositionData
}
/* 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
}
/* postComment creates a note, multiline comment, or comment. */ /* postComment creates a note, multiline comment, or comment. */
func (a commentService) postComment(w http.ResponseWriter, r *http.Request) { func (a commentService) postComment(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) payload := r.Context().Value(payload("payload")).(*PostCommentRequest)
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{ opt := gitlab.CreateMergeRequestDiscussionOptions{
Body: &postCommentRequest.Comment, Body: &payload.Comment,
} }
/* If we are leaving a comment on a line, leave position. Otherwise, /* If we are leaving a comment on a line, leave position. Otherwise,
we are leaving a note (unlinked comment) */ we are leaving a note (unlinked comment) */
if postCommentRequest.FileName != "" { if payload.FileName != "" {
commentWithPositionData := CommentWithPosition{postCommentRequest.PositionData} commentWithPositionData := CommentWithPosition{payload.PositionData}
opt.Position = buildCommentPosition(commentWithPositionData) opt.Position = buildCommentPosition(commentWithPositionData)
} }
@@ -146,18 +105,15 @@ func (a commentService) postComment(w http.ResponseWriter, r *http.Request) {
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/comment"}, "Could not create discussion", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not create discussion", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := CommentResponse{ response := CommentResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Comment created successfully"},
Message: "Comment created successfully", Comment: discussion.Notes[0],
Status: http.StatusOK, Discussion: discussion,
},
Comment: discussion.Notes[0],
Discussion: discussion,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)
@@ -166,28 +122,23 @@ func (a commentService) postComment(w http.ResponseWriter, r *http.Request) {
} }
} }
type EditCommentRequest struct {
Comment string `json:"comment" validate:"required"`
NoteId int `json:"note_id" validate:"required"`
DiscussionId string `json:"discussion_id" validate:"required"`
Resolved bool `json:"resolved"`
}
/* editComment changes the text of a comment or changes it's resolved status. */ /* editComment changes the text of a comment or changes it's resolved status. */
func (a commentService) editComment(w http.ResponseWriter, r *http.Request) { func (a commentService) editComment(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil { payload := r.Context().Value(payload("payload")).(*EditCommentRequest)
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return options := gitlab.UpdateMergeRequestDiscussionNoteOptions{
Body: gitlab.Ptr(payload.Comment),
} }
defer r.Body.Close() note, res, err := a.client.UpdateMergeRequestDiscussionNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, payload.DiscussionId, payload.NoteId, &options)
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 { if err != nil {
handleError(w, err, "Could not update comment", http.StatusInternalServerError) handleError(w, err, "Could not update comment", http.StatusInternalServerError)
@@ -195,17 +146,14 @@ func (a commentService) editComment(w http.ResponseWriter, r *http.Request) {
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/comment"}, "Could not update comment", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not update comment", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := CommentResponse{ response := CommentResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Comment updated successfully"},
Message: "Comment updated successfully", Comment: note,
Status: http.StatusOK,
},
Comment: note,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)

View File

@@ -40,10 +40,18 @@ func TestPostComment(t *testing.T) {
var testCommentCreationData = PostCommentRequest{Comment: "Some comment"} var testCommentCreationData = PostCommentRequest{Comment: "Some comment"}
t.Run("Creates a new note (unlinked comment)", func(t *testing.T) { t.Run("Creates a new note (unlinked comment)", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData)
svc := commentService{testProjectData, fakeCommentClient{}} svc := middleware(
commentService{testProjectData, fakeCommentClient{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{
http.MethodPost: &PostCommentRequest{},
http.MethodDelete: &DeleteCommentRequest{},
http.MethodPatch: &EditCommentRequest{},
}),
withMethodCheck(http.MethodPost, http.MethodDelete, http.MethodPatch),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Message, "Comment created successfully") assert(t, data.Message, "Comment created successfully")
assert(t, data.Status, http.StatusOK)
}) })
t.Run("Creates a new comment", func(t *testing.T) { t.Run("Creates a new comment", func(t *testing.T) {
@@ -54,23 +62,49 @@ func TestPostComment(t *testing.T) {
}, },
} }
request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData)
svc := commentService{testProjectData, fakeCommentClient{}} svc := middleware(
commentService{testProjectData, fakeCommentClient{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{
http.MethodPost: &PostCommentRequest{},
http.MethodDelete: &DeleteCommentRequest{},
http.MethodPatch: &EditCommentRequest{},
}),
withMethodCheck(http.MethodPost, http.MethodDelete, http.MethodPatch),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Message, "Comment created successfully") assert(t, data.Message, "Comment created successfully")
assert(t, data.Status, http.StatusOK)
}) })
t.Run("Handles errors from Gitlab client", func(t *testing.T) { t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData)
svc := commentService{testProjectData, fakeCommentClient{testBase{errFromGitlab: true}}} svc := middleware(
data := getFailData(t, svc, request) commentService{testProjectData, fakeCommentClient{testBase{errFromGitlab: true}}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{
http.MethodPost: &PostCommentRequest{},
http.MethodDelete: &DeleteCommentRequest{},
http.MethodPatch: &EditCommentRequest{},
}),
withMethodCheck(http.MethodPost, http.MethodDelete, http.MethodPatch),
)
data, _ := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not create discussion") checkErrorFromGitlab(t, data, "Could not create discussion")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData)
svc := commentService{testProjectData, fakeCommentClient{testBase{status: http.StatusSeeOther}}} svc := middleware(
data := getFailData(t, svc, request) commentService{testProjectData, fakeCommentClient{testBase{status: http.StatusSeeOther}}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{
http.MethodPost: &PostCommentRequest{},
http.MethodDelete: &DeleteCommentRequest{},
http.MethodPatch: &EditCommentRequest{},
}),
withMethodCheck(http.MethodPost, http.MethodDelete, http.MethodPatch),
)
data, _ := getFailData(t, svc, request)
checkNon200(t, data, "Could not create discussion", "/mr/comment") checkNon200(t, data, "Could not create discussion", "/mr/comment")
}) })
} }
@@ -79,23 +113,18 @@ func TestDeleteComment(t *testing.T) {
var testCommentDeletionData = DeleteCommentRequest{NoteId: 3, DiscussionId: "abc123"} var testCommentDeletionData = DeleteCommentRequest{NoteId: 3, DiscussionId: "abc123"}
t.Run("Deletes a comment", func(t *testing.T) { t.Run("Deletes a comment", func(t *testing.T) {
request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData) request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData)
svc := commentService{testProjectData, fakeCommentClient{}} svc := middleware(
commentService{testProjectData, fakeCommentClient{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{
http.MethodPost: &PostCommentRequest{},
http.MethodDelete: &DeleteCommentRequest{},
http.MethodPatch: &EditCommentRequest{},
}),
withMethodCheck(http.MethodPost, http.MethodDelete, http.MethodPatch),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Message, "Comment deleted successfully") 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")
}) })
} }
@@ -103,22 +132,17 @@ func TestEditComment(t *testing.T) {
var testEditCommentData = EditCommentRequest{Comment: "Some comment", NoteId: 3, DiscussionId: "abc123"} var testEditCommentData = EditCommentRequest{Comment: "Some comment", NoteId: 3, DiscussionId: "abc123"}
t.Run("Edits a comment", func(t *testing.T) { t.Run("Edits a comment", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData) request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData)
svc := commentService{testProjectData, fakeCommentClient{}} svc := middleware(
commentService{testProjectData, fakeCommentClient{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{
http.MethodPost: &PostCommentRequest{},
http.MethodDelete: &DeleteCommentRequest{},
http.MethodPatch: &EditCommentRequest{},
}),
withMethodCheck(http.MethodPost, http.MethodDelete, http.MethodPatch),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Message, "Comment updated successfully") 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")
}) })
} }

View File

@@ -6,8 +6,10 @@ type PluginOptions struct {
AuthToken string `json:"auth_token"` AuthToken string `json:"auth_token"`
LogPath string `json:"log_path"` LogPath string `json:"log_path"`
Debug struct { Debug struct {
Request bool `json:"go_request"` Request bool `json:"request"`
Response bool `json:"go_response"` Response bool `json:"response"`
GitlabRequest bool `json:"gitlab_request"`
GitlabResponse bool `json:"gitlab_response"`
} `json:"debug"` } `json:"debug"`
ChosenTargetBranch *string `json:"chosen_target_branch,omitempty"` ChosenTargetBranch *string `json:"chosen_target_branch,omitempty"`
ConnectionSettings struct { ConnectionSettings struct {

View File

@@ -2,21 +2,19 @@ package app
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"github.com/xanzy/go-gitlab" "github.com/xanzy/go-gitlab"
) )
type CreateMrRequest struct { type CreateMrRequest struct {
Title string `json:"title"` Title string `json:"title" validate:"required"`
TargetBranch string `json:"target_branch" validate:"required"`
Description string `json:"description"` Description string `json:"description"`
TargetBranch string `json:"target_branch"` TargetProjectID int `json:"forked_project_id,omitempty"`
DeleteBranch bool `json:"delete_branch"` DeleteBranch bool `json:"delete_branch"`
Squash bool `json:"squash"` Squash bool `json:"squash"`
TargetProjectID int `json:"forked_project_id,omitempty"`
} }
type MergeRequestCreator interface { type MergeRequestCreator interface {
@@ -29,36 +27,9 @@ type mergeRequestCreatorService struct {
} }
/* createMr creates a merge request */ /* createMr creates a merge request */
func (a mergeRequestCreatorService) handler(w http.ResponseWriter, r *http.Request) { func (a mergeRequestCreatorService) ServeHTTP(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) createMrRequest := r.Context().Value(payload("payload")).(*CreateMrRequest)
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{ opts := gitlab.CreateMergeRequestOptions{
Title: &createMrRequest.Title, Title: &createMrRequest.Title,
@@ -81,14 +52,11 @@ func (a mergeRequestCreatorService) handler(w http.ResponseWriter, r *http.Reque
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/create_mr"}, "Could not create MR", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not create MR", res.StatusCode)
return return
} }
response := SuccessResponse{ response := SuccessResponse{Message: fmt.Sprintf("MR '%s' created", createMrRequest.Title)}
Status: http.StatusOK,
Message: fmt.Sprintf("MR '%s' created", createMrRequest.Title),
}
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)

View File

@@ -29,30 +29,34 @@ func TestCreateMr(t *testing.T) {
} }
t.Run("Creates an MR", func(t *testing.T) { t.Run("Creates an MR", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData) request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData)
svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}} svc := middleware(
mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}},
withPayloadValidation(methodToPayload{http.MethodPost: &CreateMrRequest{}}),
withMethodCheck(http.MethodPost),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Message, "MR 'Some title' created") 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) { t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData) request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData)
svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{testBase{errFromGitlab: true}}} svc := middleware(
data := getFailData(t, svc, request) mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{testBase{errFromGitlab: true}}},
withPayloadValidation(methodToPayload{http.MethodPost: &CreateMrRequest{}}),
withMethodCheck(http.MethodPost),
)
data, _ := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not create MR") checkErrorFromGitlab(t, data, "Could not create MR")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData) request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData)
svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{testBase{status: http.StatusSeeOther}}} svc := middleware(
data := getFailData(t, svc, request) mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{testBase{status: http.StatusSeeOther}}},
withPayloadValidation(methodToPayload{http.MethodPost: &CreateMrRequest{}}),
withMethodCheck(http.MethodPost),
)
data, _ := getFailData(t, svc, request)
checkNon200(t, data, "Could not create MR", "/create_mr") checkNon200(t, data, "Could not create MR", "/create_mr")
}) })
@@ -60,21 +64,27 @@ func TestCreateMr(t *testing.T) {
reqData := testCreateMrRequestData reqData := testCreateMrRequestData
reqData.Title = "" reqData.Title = ""
request := makeRequest(t, http.MethodPost, "/create_mr", reqData) request := makeRequest(t, http.MethodPost, "/create_mr", reqData)
svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}} svc := middleware(
data := getFailData(t, svc, request) mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}},
assert(t, data.Status, http.StatusBadRequest) withPayloadValidation(methodToPayload{http.MethodPost: &CreateMrRequest{}}),
assert(t, data.Message, "Could not create MR") withMethodCheck(http.MethodPost),
assert(t, data.Details, "Title cannot be empty") )
data, _ := getFailData(t, svc, request)
assert(t, data.Message, "Invalid payload")
assert(t, data.Details, "Title is required")
}) })
t.Run("Handles missing target branch", func(t *testing.T) { t.Run("Handles missing target branch", func(t *testing.T) {
reqData := testCreateMrRequestData reqData := testCreateMrRequestData
reqData.TargetBranch = "" reqData.TargetBranch = ""
request := makeRequest(t, http.MethodPost, "/create_mr", reqData) request := makeRequest(t, http.MethodPost, "/create_mr", reqData)
svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}} svc := middleware(
data := getFailData(t, svc, request) mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}},
assert(t, data.Status, http.StatusBadRequest) withPayloadValidation(methodToPayload{http.MethodPost: &CreateMrRequest{}}),
assert(t, data.Message, "Could not create MR") withMethodCheck(http.MethodPost),
assert(t, data.Details, "Target branch cannot be empty") )
data, _ := getFailData(t, svc, request)
assert(t, data.Message, "Invalid payload")
assert(t, data.Details, "TargetBranch is required")
}) })
} }

View File

@@ -2,8 +2,6 @@ package app
import ( import (
"encoding/json" "encoding/json"
"errors"
"io"
"net/http" "net/http"
"github.com/xanzy/go-gitlab" "github.com/xanzy/go-gitlab"
@@ -19,38 +17,19 @@ type draftNotePublisherService struct {
client DraftNotePublisher client DraftNotePublisher
} }
func (a draftNotePublisherService) handler(w http.ResponseWriter, r *http.Request) { type DraftNotePublishRequest struct {
w.Header().Set("Content-Type", "application/json") Note int `json:"note,omitempty"`
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) func (a draftNotePublisherService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err != nil { payload := r.Context().Value(payload("payload")).(*DraftNotePublishRequest)
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 var res *gitlab.Response
if draftNotePublishRequest.PublishAll { var err error
res, err = a.client.PublishAllDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId) if payload.Note != 0 {
res, err = a.client.PublishDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, payload.Note)
} else { } else {
if draftNotePublishRequest.Note == 0 { res, err = a.client.PublishAllDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId)
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 { if err != nil {
@@ -59,15 +38,12 @@ func (a draftNotePublisherService) handler(w http.ResponseWriter, r *http.Reques
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/draft_notes/publish"}, "Could not publish dfaft note", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not publish dfaft note", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := SuccessResponse{ response := SuccessResponse{Message: "Draft note(s) published"}
Message: "Draft note(s) published",
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)
if err != nil { if err != nil {

View File

@@ -19,56 +19,53 @@ func (f fakeDraftNotePublisher) PublishDraftNote(pid interface{}, mergeRequest i
} }
func TestPublishDraftNote(t *testing.T) { func TestPublishDraftNote(t *testing.T) {
var testDraftNotePublishRequest = DraftNotePublishRequest{Note: 3, PublishAll: false} var testDraftNotePublishRequest = DraftNotePublishRequest{Note: 3}
t.Run("Publishes draft note", func(t *testing.T) { t.Run("Publishes draft note", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest)
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}} svc := middleware(
draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPost: &DraftNotePublishRequest{}}),
withMethodCheck(http.MethodPost),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "Draft note(s) published") 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) { t.Run("Handles error from Gitlab", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest)
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{testBase{errFromGitlab: true}}} svc := middleware(
data := getFailData(t, svc, request) draftNotePublisherService{testProjectData, fakeDraftNotePublisher{testBase: testBase{errFromGitlab: true}}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPost: &DraftNotePublishRequest{}}),
withMethodCheck(http.MethodPost),
)
data, _ := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not publish draft note(s)") checkErrorFromGitlab(t, data, "Could not publish draft note(s)")
}) })
} }
func TestPublishAllDraftNotes(t *testing.T) { func TestPublishAllDraftNotes(t *testing.T) {
var testDraftNotePublishRequest = DraftNotePublishRequest{PublishAll: true} var testDraftNotePublishRequest = DraftNotePublishRequest{}
t.Run("Should publish all draft notes", func(t *testing.T) { t.Run("Should publish all draft notes", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest)
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}} svc := middleware(
draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPost: &DraftNotePublishRequest{}}),
withMethodCheck(http.MethodPost),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "Draft note(s) published") 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) { t.Run("Handles error from Gitlab", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest)
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{testBase{errFromGitlab: true}}} svc := middleware(
data := getFailData(t, svc, request) draftNotePublisherService{testProjectData, fakeDraftNotePublisher{testBase: testBase{errFromGitlab: true}}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPost: &DraftNotePublishRequest{}}),
withMethodCheck(http.MethodPost),
)
data, _ := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not publish draft note(s)") checkErrorFromGitlab(t, data, "Could not publish draft note(s)")
}) })
} }

View File

@@ -3,8 +3,6 @@ package app
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -12,36 +10,15 @@ import (
"github.com/xanzy/go-gitlab" "github.com/xanzy/go-gitlab"
) )
/* The data coming from the client when creating a draft note is the same, /* 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 as when they are creating a normal comment, but the Gitlab
endpoints + resources we handle are different */ 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 { type DraftNoteResponse struct {
SuccessResponse SuccessResponse
DraftNote *gitlab.DraftNote `json:"draft_note"` 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. */ /* 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 { type DraftNoteWithPosition struct {
PositionData PositionData PositionData PositionData
@@ -64,7 +41,7 @@ type draftNoteService struct {
} }
/* draftNoteHandler creates, edits, and deletes draft notes */ /* draftNoteHandler creates, edits, and deletes draft notes */
func (a draftNoteService) handler(w http.ResponseWriter, r *http.Request) { func (a draftNoteService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
@@ -75,14 +52,16 @@ func (a draftNoteService) handler(w http.ResponseWriter, r *http.Request) {
a.updateDraftNote(w, r) a.updateDraftNote(w, r)
case http.MethodDelete: case http.MethodDelete:
a.deleteDraftNote(w, r) 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)
} }
} }
type ListDraftNotesResponse struct {
SuccessResponse
DraftNotes []*gitlab.DraftNote `json:"draft_notes"`
}
/* listDraftNotes lists all draft notes for the currently authenticated user */ /* listDraftNotes lists all draft notes for the currently authenticated user */
func (a draftNoteService) listDraftNotes(w http.ResponseWriter, _ *http.Request) { func (a draftNoteService) listDraftNotes(w http.ResponseWriter, r *http.Request) {
opt := gitlab.ListDraftNotesOptions{} opt := gitlab.ListDraftNotesOptions{}
draftNotes, res, err := a.client.ListDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt) draftNotes, res, err := a.client.ListDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt)
@@ -93,17 +72,14 @@ func (a draftNoteService) listDraftNotes(w http.ResponseWriter, _ *http.Request)
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not get draft notes", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not get draft notes", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := ListDraftNotesResponse{ response := ListDraftNotesResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Draft notes fetched successfully"},
Message: "Draft notes fetched successfully", DraftNotes: draftNotes,
Status: http.StatusOK,
},
DraftNotes: draftNotes,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)
@@ -112,34 +88,27 @@ func (a draftNoteService) listDraftNotes(w http.ResponseWriter, _ *http.Request)
} }
} }
type PostDraftNoteRequest struct {
Comment string `json:"comment" validate:"required"`
DiscussionId string `json:"discussion_id,omitempty" validate:"required"`
PositionData // TODO: How to add validations to data from external package???
}
/* postDraftNote creates a draft note */ /* postDraftNote creates a draft note */
func (a draftNoteService) postDraftNote(w http.ResponseWriter, r *http.Request) { func (a draftNoteService) postDraftNote(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) payload := r.Context().Value(payload("payload")).(*PostDraftNoteRequest)
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{ opt := gitlab.CreateDraftNoteOptions{
Note: &postDraftNoteRequest.Comment, Note: &payload.Comment,
} }
// Draft notes can be posted in "response" to existing discussions // Draft notes can be posted in "response" to existing discussions
if postDraftNoteRequest.DiscussionId != "" { if payload.DiscussionId != "" {
opt.InReplyToDiscussionID = gitlab.Ptr(postDraftNoteRequest.DiscussionId) opt.InReplyToDiscussionID = gitlab.Ptr(payload.DiscussionId)
} }
if postDraftNoteRequest.FileName != "" { if payload.FileName != "" {
draftNoteWithPosition := DraftNoteWithPosition{postDraftNoteRequest.PositionData} draftNoteWithPosition := DraftNoteWithPosition{payload.PositionData}
opt.Position = buildCommentPosition(draftNoteWithPosition) opt.Position = buildCommentPosition(draftNoteWithPosition)
} }
@@ -151,17 +120,14 @@ func (a draftNoteService) postDraftNote(w http.ResponseWriter, r *http.Request)
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not create draft note", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not create draft note", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := DraftNoteResponse{ response := DraftNoteResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Draft note created successfully"},
Message: "Draft note created successfully", DraftNote: draftNote,
Status: http.StatusOK,
},
DraftNote: draftNote,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)
@@ -187,15 +153,12 @@ func (a draftNoteService) deleteDraftNote(w http.ResponseWriter, r *http.Request
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: fmt.Sprintf("/mr/draft_notes/%d", id)}, "Could not delete draft note", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not delete draft note", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := SuccessResponse{ response := SuccessResponse{Message: "Draft note deleted"}
Message: "Draft note deleted",
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)
if err != nil { if err != nil {
@@ -203,6 +166,11 @@ func (a draftNoteService) deleteDraftNote(w http.ResponseWriter, r *http.Request
} }
} }
type UpdateDraftNoteRequest struct {
Note string `json:"note" validate:"required"`
Position gitlab.PositionOptions
}
/* updateDraftNote edits the text of a draft comment */ /* updateDraftNote edits the text of a draft comment */
func (a draftNoteService) updateDraftNote(w http.ResponseWriter, r *http.Request) { func (a draftNoteService) updateDraftNote(w http.ResponseWriter, r *http.Request) {
suffix := strings.TrimPrefix(r.URL.Path, "/mr/draft_notes/") suffix := strings.TrimPrefix(r.URL.Path, "/mr/draft_notes/")
@@ -212,29 +180,16 @@ func (a draftNoteService) updateDraftNote(w http.ResponseWriter, r *http.Request
return return
} }
body, err := io.ReadAll(r.Body) payload := r.Context().Value(payload("payload")).(*UpdateDraftNoteRequest)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close() if payload.Note == "" {
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) handleError(w, errors.New("Draft note text missing"), "Must provide draft note text", http.StatusBadRequest)
return return
} }
opt := gitlab.UpdateDraftNoteOptions{ opt := gitlab.UpdateDraftNoteOptions{
Note: &updateDraftNoteRequest.Note, Note: &payload.Note,
Position: &updateDraftNoteRequest.Position, Position: &payload.Position,
} }
draftNote, res, err := a.client.UpdateDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, id, &opt) draftNote, res, err := a.client.UpdateDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, id, &opt)
@@ -245,17 +200,14 @@ func (a draftNoteService) updateDraftNote(w http.ResponseWriter, r *http.Request
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: fmt.Sprintf("/mr/draft_notes/%d", id)}, "Could not update draft note", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not update draft note", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := DraftNoteResponse{ response := DraftNoteResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Draft note updated"},
Message: "Draft note updated", DraftNote: draftNote,
Status: http.StatusOK,
},
DraftNote: draftNote,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)

View File

@@ -42,74 +42,99 @@ func (f fakeDraftNoteManager) UpdateDraftNote(pid interface{}, mergeRequest int,
func TestListDraftNotes(t *testing.T) { func TestListDraftNotes(t *testing.T) {
t.Run("Lists all draft notes", func(t *testing.T) { t.Run("Lists all draft notes", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil) request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{}} svc := middleware(
draftNoteService{testProjectData, fakeDraftNoteManager{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{
http.MethodPost: &PostDraftNoteRequest{},
http.MethodPatch: &UpdateDraftNoteRequest{},
}),
withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "Draft notes fetched successfully") assert(t, data.Message, "Draft notes fetched successfully")
}) })
t.Run("Handles error from Gitlab client", func(t *testing.T) { t.Run("Handles error from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil) request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{errFromGitlab: true}}} svc := middleware(
data := getFailData(t, svc, request) draftNoteService{testProjectData, fakeDraftNoteManager{testBase: testBase{errFromGitlab: true}}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{
http.MethodPost: &PostDraftNoteRequest{},
http.MethodPatch: &UpdateDraftNoteRequest{},
}),
withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete),
)
data, _ := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not get draft notes") checkErrorFromGitlab(t, data, "Could not get draft notes")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil) request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} svc := middleware(
data := getFailData(t, svc, request) draftNoteService{testProjectData, fakeDraftNoteManager{testBase: testBase{status: http.StatusSeeOther}}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{
http.MethodPost: &PostDraftNoteRequest{},
http.MethodPatch: &UpdateDraftNoteRequest{},
}),
withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete),
)
data, _ := getFailData(t, svc, request)
checkNon200(t, data, "Could not get draft notes", "/mr/draft_notes/") checkNon200(t, data, "Could not get draft notes", "/mr/draft_notes/")
}) })
} }
func TestPostDraftNote(t *testing.T) { func TestPostDraftNote(t *testing.T) {
var testPostDraftNoteRequestData = PostDraftNoteRequest{Comment: "Some comment"} var testPostDraftNoteRequestData = PostDraftNoteRequest{
Comment: "Some comment",
DiscussionId: "abc123",
}
t.Run("Posts new draft note", func(t *testing.T) { t.Run("Posts new draft note", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData) request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{}} svc := middleware(
draftNoteService{testProjectData, fakeDraftNoteManager{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{
http.MethodPost: &PostDraftNoteRequest{},
http.MethodPatch: &UpdateDraftNoteRequest{},
}),
withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "Draft note created successfully") 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) { func TestDeleteDraftNote(t *testing.T) {
t.Run("Deletes new draft note", func(t *testing.T) { t.Run("Deletes new draft note", func(t *testing.T) {
request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil) request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{}} svc := middleware(
draftNoteService{testProjectData, fakeDraftNoteManager{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{
http.MethodPost: &PostDraftNoteRequest{},
http.MethodPatch: &UpdateDraftNoteRequest{},
}),
withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "Draft note deleted") 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) { t.Run("Handles bad ID", func(t *testing.T) {
request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/blah", nil) request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/blah", nil)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} svc := middleware(
data := getFailData(t, svc, request) draftNoteService{testProjectData, fakeDraftNoteManager{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{
http.MethodPost: &PostDraftNoteRequest{},
http.MethodPatch: &UpdateDraftNoteRequest{},
}),
withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete),
)
data, status := getFailData(t, svc, request)
assert(t, data.Message, "Could not parse draft note ID") assert(t, data.Message, "Could not parse draft note ID")
assert(t, data.Status, http.StatusBadRequest) assert(t, status, http.StatusBadRequest)
}) })
} }
@@ -117,37 +142,49 @@ func TestEditDraftNote(t *testing.T) {
var testUpdateDraftNoteRequest = UpdateDraftNoteRequest{Note: "Some new note"} var testUpdateDraftNoteRequest = UpdateDraftNoteRequest{Note: "Some new note"}
t.Run("Edits new draft note", func(t *testing.T) { t.Run("Edits new draft note", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", testUpdateDraftNoteRequest) request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", testUpdateDraftNoteRequest)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{}} svc := middleware(
draftNoteService{testProjectData, fakeDraftNoteManager{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{
http.MethodPost: &PostDraftNoteRequest{},
http.MethodPatch: &UpdateDraftNoteRequest{},
}),
withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "Draft note updated") 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) { t.Run("Handles bad ID", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/blah", testUpdateDraftNoteRequest) request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/blah", testUpdateDraftNoteRequest)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} svc := middleware(
data := getFailData(t, svc, request) draftNoteService{testProjectData, fakeDraftNoteManager{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{
http.MethodPost: &PostDraftNoteRequest{},
http.MethodPatch: &UpdateDraftNoteRequest{},
}),
withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete),
)
data, status := getFailData(t, svc, request)
assert(t, data.Message, "Could not parse draft note ID") assert(t, data.Message, "Could not parse draft note ID")
assert(t, data.Status, http.StatusBadRequest) assert(t, status, http.StatusBadRequest)
}) })
t.Run("Handles empty note", func(t *testing.T) { t.Run("Handles empty note", func(t *testing.T) {
requestData := testUpdateDraftNoteRequest requestData := testUpdateDraftNoteRequest
requestData.Note = "" requestData.Note = ""
request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", requestData) request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", requestData)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} svc := middleware(
data := getFailData(t, svc, request) draftNoteService{testProjectData, fakeDraftNoteManager{}},
assert(t, data.Message, "Must provide draft note text") withMr(testProjectData, fakeMergeRequestLister{}),
assert(t, data.Status, http.StatusBadRequest) withPayloadValidation(methodToPayload{
http.MethodPost: &PostDraftNoteRequest{},
http.MethodPatch: &UpdateDraftNoteRequest{},
}),
withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete),
)
data, status := getFailData(t, svc, request)
assert(t, data.Message, "Invalid payload")
assert(t, data.Details, "Note is required")
assert(t, status, http.StatusBadRequest)
}) })
} }

View File

@@ -48,16 +48,13 @@ type emojiService struct {
client EmojiManager client EmojiManager
} }
func (a emojiService) handler(w http.ResponseWriter, r *http.Request) { func (a emojiService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
switch r.Method { switch r.Method {
case http.MethodPost: case http.MethodPost:
a.postEmojiOnNote(w, r) a.postEmojiOnNote(w, r)
case http.MethodDelete: case http.MethodDelete:
a.deleteEmojiFromNote(w, r) 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)
} }
} }
@@ -87,15 +84,12 @@ func (a emojiService) deleteEmojiFromNote(w http.ResponseWriter, r *http.Request
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/pipeline"}, "Could not delete awardable", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not delete awardable", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := SuccessResponse{ response := SuccessResponse{Message: "Emoji deleted"}
Message: "Emoji deleted",
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)
if err != nil { if err != nil {
@@ -131,17 +125,14 @@ func (a emojiService) postEmojiOnNote(w http.ResponseWriter, r *http.Request) {
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/awardable/note"}, "Could not post emoji", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not post emoji", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := CreateEmojiResponse{ response := CreateEmojiResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Merge requests retrieved"},
Message: "Merge requests retrieved", Emoji: awardEmoji,
Status: http.StatusOK,
},
Emoji: awardEmoji,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)

View File

@@ -59,7 +59,7 @@ func NewGitData(remote string, g GitManager) (GitData, error) {
https://git@gitlab.com/namespace/subnamespace/dummy-test-repo.git https://git@gitlab.com/namespace/subnamespace/dummy-test-repo.git
git@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)?$`) re := regexp.MustCompile(`^(?:git@[^\/:]*|https?:\/\/[^\/]+|ssh:\/\/[^\/:]+)(?::\d+)?[\/:](.*)\/([^\/]+?)(?:\.git)?\/?$`)
matches := re.FindStringSubmatch(url) matches := re.FindStringSubmatch(url)
if len(matches) != 3 { if len(matches) != 3 {
return GitData{}, fmt.Errorf("Invalid Git URL format: %s", url) return GitData{}, fmt.Errorf("Invalid Git URL format: %s", url)

View File

@@ -101,6 +101,13 @@ func TestExtractGitInfo_Success(t *testing.T) {
projectName: "project-name", projectName: "project-name",
namespace: "namespace-1", namespace: "namespace-1",
}, },
{
desc: "Project configured in HTTP and under a single folder without .git extension (with embedded credentials)",
remote: "http://username:password@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", desc: "Project configured in HTTPS and under a single folder",
remote: "https://custom-gitlab.com/namespace-1/project-name.git", remote: "https://custom-gitlab.com/namespace-1/project-name.git",
@@ -108,6 +115,13 @@ func TestExtractGitInfo_Success(t *testing.T) {
projectName: "project-name", projectName: "project-name",
namespace: "namespace-1", namespace: "namespace-1",
}, },
{
desc: "Project configured in HTTPS and under a single folder (with embedded credentials)",
remote: "https://username:password@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", desc: "Project configured in HTTPS and under a nested folder",
remote: "https://custom-gitlab.com/namespace-1/namespace-2/project-name.git", remote: "https://custom-gitlab.com/namespace-1/namespace-2/project-name.git",
@@ -115,6 +129,13 @@ func TestExtractGitInfo_Success(t *testing.T) {
projectName: "project-name", projectName: "project-name",
namespace: "namespace-1/namespace-2", namespace: "namespace-1/namespace-2",
}, },
{
desc: "Project configured in HTTPS and under a nested folder (with embedded credentials)",
remote: "https://username:password@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", desc: "Project configured in HTTPS and under two nested folders",
remote: "https://custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git", remote: "https://custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git",
@@ -122,6 +143,13 @@ func TestExtractGitInfo_Success(t *testing.T) {
projectName: "project-name", projectName: "project-name",
namespace: "namespace-1/namespace-2/namespace-3", namespace: "namespace-1/namespace-2/namespace-3",
}, },
{
desc: "Project configured in HTTPS and under two nested folders (with embedded credentials)",
remote: "https://username:password@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 { for _, tC := range testCases {
t.Run(tC.desc, func(t *testing.T) { t.Run(tC.desc, func(t *testing.T) {

View File

@@ -22,14 +22,7 @@ type infoService struct {
} }
/* infoHandler fetches infomation about the current git project. The data returned here is used in many other API calls */ /* 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) { func (a infoService) ServeHTTP(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{}) mr, res, err := a.client.GetMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.GetMergeRequestsOptions{})
if err != nil { if err != nil {
handleError(w, err, "Could not get project info", http.StatusInternalServerError) handleError(w, err, "Could not get project info", http.StatusInternalServerError)
@@ -37,17 +30,14 @@ func (a infoService) handler(w http.ResponseWriter, r *http.Request) {
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/info"}, "Could not get project info", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not get project info", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := InfoResponse{ response := InfoResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Merge requests retrieved"},
Message: "Merge requests retrieved", Info: mr,
Status: http.StatusOK,
},
Info: mr,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)

View File

@@ -23,27 +23,29 @@ func (f fakeMergeRequestGetter) GetMergeRequest(pid interface{}, mergeRequest in
func TestInfoHandler(t *testing.T) { func TestInfoHandler(t *testing.T) {
t.Run("Returns normal information", func(t *testing.T) { t.Run("Returns normal information", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/info", nil) request := makeRequest(t, http.MethodGet, "/mr/info", nil)
svc := infoService{testProjectData, fakeMergeRequestGetter{}} svc := middleware(
infoService{testProjectData, fakeMergeRequestGetter{}},
withMethodCheck(http.MethodGet),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Message, "Merge requests retrieved") 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) { t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/info", nil) request := makeRequest(t, http.MethodGet, "/mr/info", nil)
svc := infoService{testProjectData, fakeMergeRequestGetter{testBase{errFromGitlab: true}}} svc := middleware(
data := getFailData(t, svc, request) infoService{testProjectData, fakeMergeRequestGetter{testBase{errFromGitlab: true}}},
withMethodCheck(http.MethodGet),
)
data, _ := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not get project info") checkErrorFromGitlab(t, data, "Could not get project info")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/info", nil) request := makeRequest(t, http.MethodGet, "/mr/info", nil)
svc := infoService{testProjectData, fakeMergeRequestGetter{testBase{status: http.StatusSeeOther}}} svc := middleware(
data := getFailData(t, svc, request) infoService{testProjectData, fakeMergeRequestGetter{testBase{status: http.StatusSeeOther}}},
withMethodCheck(http.MethodGet),
)
data, _ := getFailData(t, svc, request)
checkNon200(t, data, "Could not get project info", "/mr/info") checkNon200(t, data, "Could not get project info", "/mr/info")
}) })
} }

View File

@@ -10,7 +10,7 @@ import (
) )
type JobTraceRequest struct { type JobTraceRequest struct {
JobId int `json:"job_id"` JobId int `json:"job_id" validate:"required"`
} }
type JobTraceResponse struct { type JobTraceResponse struct {
@@ -28,30 +28,11 @@ type traceFileService struct {
} }
/* jobHandler returns a string that shows the output of a specific job run in a Gitlab pipeline */ /* 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) { func (a traceFileService) ServeHTTP(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) payload := r.Context().Value(payload("payload")).(*JobTraceRequest)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close() reader, res, err := a.client.GetTraceFile(a.projectInfo.ProjectId, payload.JobId)
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 { if err != nil {
handleError(w, err, "Could not get trace file for job", http.StatusInternalServerError) handleError(w, err, "Could not get trace file for job", http.StatusInternalServerError)
@@ -59,7 +40,7 @@ func (a traceFileService) handler(w http.ResponseWriter, r *http.Request) {
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/job"}, "Could not get trace file for job", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not get trace file for job", res.StatusCode)
return return
} }
@@ -71,11 +52,8 @@ func (a traceFileService) handler(w http.ResponseWriter, r *http.Request) {
} }
response := JobTraceResponse{ response := JobTraceResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Log file read"},
Status: http.StatusOK, File: string(file),
Message: "Log file read",
},
File: string(file),
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)

View File

@@ -14,9 +14,9 @@ type fakeTraceFileGetter struct {
testBase testBase
} }
func getTraceFileData(t *testing.T, svc ServiceWithHandler, request *http.Request) JobTraceResponse { func getTraceFileData(t *testing.T, svc http.Handler, request *http.Request) JobTraceResponse {
res := httptest.NewRecorder() res := httptest.NewRecorder()
svc.handler(res, request) svc.ServeHTTP(res, request)
var data JobTraceResponse var data JobTraceResponse
err := json.Unmarshal(res.Body.Bytes(), &data) err := json.Unmarshal(res.Body.Bytes(), &data)
@@ -35,37 +35,37 @@ func (f fakeTraceFileGetter) GetTraceFile(pid interface{}, jobID int, options ..
return re, resp, err return re, resp, err
} }
// var jobId = 0
func TestJobHandler(t *testing.T) { func TestJobHandler(t *testing.T) {
t.Run("Should read a job trace file", func(t *testing.T) { t.Run("Should read a job trace file", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{JobId: 3})
client := fakeTraceFileGetter{} svc := middleware(
svc := traceFileService{testProjectData, client} traceFileService{testProjectData, fakeTraceFileGetter{}},
withPayloadValidation(methodToPayload{http.MethodGet: &JobTraceRequest{}}),
withMethodCheck(http.MethodGet),
)
data := getTraceFileData(t, svc, request) data := getTraceFileData(t, svc, request)
assert(t, data.Message, "Log file read") assert(t, data.Message, "Log file read")
assert(t, data.Status, http.StatusOK)
assert(t, data.File, "Some data") 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) { t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{JobId: 2})
client := fakeTraceFileGetter{testBase{errFromGitlab: true}} svc := middleware(
svc := traceFileService{testProjectData, client} traceFileService{testProjectData, fakeTraceFileGetter{testBase{errFromGitlab: true}}},
data := getFailData(t, svc, request) withPayloadValidation(methodToPayload{http.MethodGet: &JobTraceRequest{}}),
withMethodCheck(http.MethodGet),
)
data, _ := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not get trace file for job") checkErrorFromGitlab(t, data, "Could not get trace file for job")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{JobId: 1})
client := fakeTraceFileGetter{testBase{status: http.StatusSeeOther}} svc := middleware(
svc := traceFileService{testProjectData, client} traceFileService{testProjectData, fakeTraceFileGetter{testBase{status: http.StatusSeeOther}}},
data := getFailData(t, svc, request) withPayloadValidation(methodToPayload{http.MethodGet: &JobTraceRequest{}}),
withMethodCheck(http.MethodGet),
)
data, _ := getFailData(t, svc, request)
checkNon200(t, data, "Could not get trace file for job", "/job") checkNon200(t, data, "Could not get trace file for job", "/job")
}) })
} }

View File

@@ -2,7 +2,6 @@ package app
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
@@ -39,20 +38,16 @@ type labelService struct {
} }
/* labelsHandler adds or removes labels from a merge request, and returns all labels for the current project */ /* 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) { func (a labelService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
a.getLabels(w, r) a.getLabels(w, r)
case http.MethodPut: case http.MethodPut:
a.updateLabels(w, r) 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) { func (a labelService) getLabels(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
labels, res, err := a.client.ListLabels(a.projectInfo.ProjectId, &gitlab.ListLabelsOptions{}) labels, res, err := a.client.ListLabels(a.projectInfo.ProjectId, &gitlab.ListLabelsOptions{})
@@ -63,7 +58,7 @@ func (a labelService) getLabels(w http.ResponseWriter, _ *http.Request) {
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/label"}, "Could not modify merge request labels", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not modify merge request labels", res.StatusCode)
return return
} }
@@ -78,11 +73,8 @@ func (a labelService) getLabels(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := LabelsRequestResponse{ response := LabelsRequestResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Labels updated"},
Message: "Labels updated", Labels: convertedLabels,
Status: http.StatusOK,
},
Labels: convertedLabels,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)
@@ -120,17 +112,14 @@ func (a labelService) updateLabels(w http.ResponseWriter, r *http.Request) {
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/label"}, "Could not modify merge request labels", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not modify merge request labels", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := LabelUpdateResponse{ response := LabelUpdateResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Labels updated"},
Message: "Labels updated", Labels: mr.Labels,
Status: http.StatusOK,
},
Labels: mr.Labels,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)

View File

@@ -1,7 +1,6 @@
package app package app
import ( import (
"io"
"net/http" "net/http"
"sort" "sort"
"sync" "sync"
@@ -21,7 +20,7 @@ func Contains[T comparable](elems []T, v T) bool {
} }
type DiscussionsRequest struct { type DiscussionsRequest struct {
Blacklist []string `json:"blacklist"` Blacklist []string `json:"blacklist" validate:"required"`
} }
type DiscussionsResponse struct { type DiscussionsResponse struct {
@@ -61,27 +60,9 @@ type discussionsListerService struct {
listDiscussionsHandler lists all discusions for a given merge request, both those linked and unlinked to particular points in the code. 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 The responses are sorted by date created, and blacklisted users are not included
*/ */
func (a discussionsListerService) handler(w http.ResponseWriter, r *http.Request) { func (a discussionsListerService) ServeHTTP(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) request := r.Context().Value(payload(payload("payload"))).(*DiscussionsRequest)
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{ mergeRequestDiscussionOptions := gitlab.ListMergeRequestDiscussionsOptions{
Page: 1, Page: 1,
@@ -96,7 +77,7 @@ func (a discussionsListerService) handler(w http.ResponseWriter, r *http.Request
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/discussions/list"}, "Could not list discussions", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not list discussions", res.StatusCode)
return return
} }
@@ -106,7 +87,7 @@ func (a discussionsListerService) handler(w http.ResponseWriter, r *http.Request
var linkedDiscussions []*gitlab.Discussion var linkedDiscussions []*gitlab.Discussion
for _, discussion := range discussions { for _, discussion := range discussions {
if discussion.Notes == nil || len(discussion.Notes) == 0 || Contains(requestBody.Blacklist, discussion.Notes[0].Author.Username) { if discussion.Notes == nil || len(discussion.Notes) == 0 || Contains(request.Blacklist, discussion.Notes[0].Author.Username) {
continue continue
} }
for _, note := range discussion.Notes { for _, note := range discussion.Notes {
@@ -142,10 +123,7 @@ func (a discussionsListerService) handler(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := DiscussionsResponse{ response := DiscussionsResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Discussions retrieved"},
Message: "Discussions retrieved",
Status: http.StatusOK,
},
Discussions: linkedDiscussions, Discussions: linkedDiscussions,
UnlinkedDiscussions: unlinkedDiscussions, UnlinkedDiscussions: unlinkedDiscussions,
Emojis: emojis, Emojis: emojis,

View File

@@ -53,9 +53,9 @@ func (f fakeDiscussionsLister) ListMergeRequestAwardEmojiOnNote(pid interface{},
return []*gitlab.AwardEmoji{}, resp, err return []*gitlab.AwardEmoji{}, resp, err
} }
func getDiscussionsList(t *testing.T, svc ServiceWithHandler, request *http.Request) DiscussionsResponse { func getDiscussionsList(t *testing.T, svc http.Handler, request *http.Request) DiscussionsResponse {
res := httptest.NewRecorder() res := httptest.NewRecorder()
svc.handler(res, request) svc.ServeHTTP(res, request)
var data DiscussionsResponse var data DiscussionsResponse
err := json.Unmarshal(res.Body.Bytes(), &data) err := json.Unmarshal(res.Body.Bytes(), &data)
@@ -67,46 +67,63 @@ func getDiscussionsList(t *testing.T, svc ServiceWithHandler, request *http.Requ
func TestListDiscussions(t *testing.T) { func TestListDiscussions(t *testing.T) {
t.Run("Returns sorted discussions", func(t *testing.T) { t.Run("Returns sorted discussions", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{}})
svc := discussionsListerService{testProjectData, fakeDiscussionsLister{}} svc := middleware(
discussionsListerService{testProjectData, fakeDiscussionsLister{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPost: &DiscussionsRequest{}}),
withMethodCheck(http.MethodPost),
)
data := getDiscussionsList(t, svc, request) data := getDiscussionsList(t, svc, request)
assert(t, data.Message, "Discussions retrieved") 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[0].Notes[0].Author.Username, "hcramer2") /* Sorting applied */
assert(t, data.Discussions[1].Notes[0].Author.Username, "hcramer") assert(t, data.Discussions[1].Notes[0].Author.Username, "hcramer")
}) })
t.Run("Uses blacklist to filter unwanted authors", func(t *testing.T) { t.Run("Uses blacklist to filter unwanted authors", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{"hcramer"}}) request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{"hcramer"}})
svc := discussionsListerService{testProjectData, fakeDiscussionsLister{}} svc := middleware(
discussionsListerService{testProjectData, fakeDiscussionsLister{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPost: &DiscussionsRequest{}}),
withMethodCheck(http.MethodPost),
)
data := getDiscussionsList(t, svc, request) data := getDiscussionsList(t, svc, request)
assert(t, data.SuccessResponse.Message, "Discussions retrieved") assert(t, data.SuccessResponse.Message, "Discussions retrieved")
assert(t, data.SuccessResponse.Status, http.StatusOK)
assert(t, len(data.Discussions), 1) assert(t, len(data.Discussions), 1)
assert(t, data.Discussions[0].Notes[0].Author.Username, "hcramer2") 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) { t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{}})
svc := discussionsListerService{testProjectData, fakeDiscussionsLister{testBase: testBase{errFromGitlab: true}}} svc := middleware(
data := getFailData(t, svc, request) discussionsListerService{testProjectData, fakeDiscussionsLister{testBase: testBase{errFromGitlab: true}}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPost: &DiscussionsRequest{}}),
withMethodCheck(http.MethodPost),
)
data, _ := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not list discussions") checkErrorFromGitlab(t, data, "Could not list discussions")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{}})
svc := discussionsListerService{testProjectData, fakeDiscussionsLister{testBase: testBase{status: http.StatusSeeOther}}} svc := middleware(
data := getFailData(t, svc, request) discussionsListerService{testProjectData, fakeDiscussionsLister{testBase: testBase{status: http.StatusSeeOther}}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPost: &DiscussionsRequest{}}),
withMethodCheck(http.MethodPost),
)
data, _ := getFailData(t, svc, request)
checkNon200(t, data, "Could not list discussions", "/mr/discussions/list") checkNon200(t, data, "Could not list discussions", "/mr/discussions/list")
}) })
t.Run("Handles error from emoji service", func(t *testing.T) { t.Run("Handles error from emoji service", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{}})
svc := discussionsListerService{testProjectData, fakeDiscussionsLister{badEmojiResponse: true}} svc := middleware(
data := getFailData(t, svc, request) discussionsListerService{testProjectData, fakeDiscussionsLister{badEmojiResponse: true, testBase: testBase{}}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPost: &DiscussionsRequest{}}),
withMethodCheck(http.MethodPost),
)
data, _ := getFailData(t, svc, request)
assert(t, data.Message, "Could not fetch emojis") assert(t, data.Message, "Could not fetch emojis")
assert(t, data.Details, "Some error from emoji service") assert(t, data.Details, "Some error from emoji service")
}) })

96
cmd/app/logging.go Normal file
View File

@@ -0,0 +1,96 @@
package app
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"net/http/httputil"
"os"
)
// LoggingServer is a wrapper around an http.Handler to log incoming requests and outgoing responses.
type LoggingServer struct {
handler http.Handler
}
type LoggingResponseWriter struct {
statusCode int
body *bytes.Buffer
http.ResponseWriter
}
func (l *LoggingResponseWriter) WriteHeader(statusCode int) {
l.statusCode = statusCode
}
func (l *LoggingResponseWriter) Write(b []byte) (int, error) {
l.body.Write(b)
return l.ResponseWriter.Write(b)
}
// Logs the request, calls the original handler on the ServeMux, then logs the response
func (l LoggingServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if pluginOptions.Debug.Request {
logRequest("REQUEST TO GO SERVER", r)
}
lrw := &LoggingResponseWriter{ResponseWriter: w, body: &bytes.Buffer{}}
l.handler.ServeHTTP(lrw, r)
resp := &http.Response{
Status: http.StatusText(lrw.statusCode),
StatusCode: lrw.statusCode,
Body: io.NopCloser(bytes.NewBuffer(lrw.body.Bytes())), // Use the captured body
ContentLength: int64(lrw.body.Len()),
Header: lrw.Header(),
Request: r,
}
if pluginOptions.Debug.Response {
logResponse("RESPONSE FROM GO SERVER", resp)
}
}
func logRequest(prefix string, r *http.Request) {
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(fmt.Sprintf("\n-- %s --\n%s\n", prefix, res))) //nolint:all
}
func logResponse(prefix string, r *http.Response) {
file := openLogFile()
defer file.Close()
res, err := httputil.DumpResponse(r, true)
if err != nil {
log.Fatalf("Error dumping response: %v", err)
os.Exit(1)
}
_, err = file.Write([]byte(fmt.Sprintf("\n-- %s --\n%s\n", prefix, res))) //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
}

View File

@@ -22,13 +22,7 @@ type projectMemberService struct {
} }
/* projectMembersHandler returns all members of the current Gitlab project */ /* projectMembersHandler returns all members of the current Gitlab project */
func (a projectMemberService) handler(w http.ResponseWriter, r *http.Request) { func (a projectMemberService) ServeHTTP(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{ projectMemberOptions := gitlab.ListProjectMembersOptions{
ListOptions: gitlab.ListOptions{ ListOptions: gitlab.ListOptions{
@@ -44,18 +38,15 @@ func (a projectMemberService) handler(w http.ResponseWriter, r *http.Request) {
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/project/members"}, "Could not retrieve project members", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not retrieve project members", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := ProjectMembersResponse{ response := ProjectMembersResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Project members retrieved"},
Status: http.StatusOK, ProjectMembers: projectMembers,
Message: "Project members retrieved",
},
ProjectMembers: projectMembers,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)

View File

@@ -22,27 +22,29 @@ func (f fakeMemberLister) ListAllProjectMembers(pid interface{}, opt *gitlab.Lis
func TestMembersHandler(t *testing.T) { func TestMembersHandler(t *testing.T) {
t.Run("Returns project members", func(t *testing.T) { t.Run("Returns project members", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/project/members", nil) request := makeRequest(t, http.MethodGet, "/project/members", nil)
svc := projectMemberService{testProjectData, fakeMemberLister{}} svc := middleware(
projectMemberService{testProjectData, fakeMemberLister{}},
withMethodCheck(http.MethodGet),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "Project members retrieved") 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) { t.Run("Handles error from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/project/members", nil) request := makeRequest(t, http.MethodGet, "/project/members", nil)
svc := projectMemberService{testProjectData, fakeMemberLister{testBase{errFromGitlab: true}}} svc := middleware(
data := getFailData(t, svc, request) projectMemberService{testProjectData, fakeMemberLister{testBase{errFromGitlab: true}}},
withMethodCheck(http.MethodGet),
)
data, _ := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not retrieve project members") checkErrorFromGitlab(t, data, "Could not retrieve project members")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/project/members", nil) request := makeRequest(t, http.MethodGet, "/project/members", nil)
svc := projectMemberService{testProjectData, fakeMemberLister{testBase{status: http.StatusSeeOther}}} svc := middleware(
data := getFailData(t, svc, request) projectMemberService{testProjectData, fakeMemberLister{testBase{status: http.StatusSeeOther}}},
withMethodCheck(http.MethodGet),
)
data, _ := getFailData(t, svc, request)
checkNon200(t, data, "Could not retrieve project members", "/project/members") checkNon200(t, data, "Could not retrieve project members", "/project/members")
}) })
} }

View File

@@ -2,16 +2,15 @@ package app
import ( import (
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"github.com/xanzy/go-gitlab" "github.com/xanzy/go-gitlab"
) )
type AcceptMergeRequestRequest struct { type AcceptMergeRequestRequest struct {
Squash bool `json:"squash"`
SquashMessage string `json:"squash_message"`
DeleteBranch bool `json:"delete_branch"` DeleteBranch bool `json:"delete_branch"`
SquashMessage string `json:"squash_message"`
Squash bool `json:"squash"`
} }
type MergeRequestAccepter interface { type MergeRequestAccepter interface {
@@ -24,34 +23,16 @@ type mergeRequestAccepterService struct {
} }
/* acceptAndMergeHandler merges a given merge request into the target branch */ /* acceptAndMergeHandler merges a given merge request into the target branch */
func (a mergeRequestAccepterService) handler(w http.ResponseWriter, r *http.Request) { func (a mergeRequestAccepterService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") payload := r.Context().Value(payload("payload")).(*AcceptMergeRequestRequest)
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{ opts := gitlab.AcceptMergeRequestOptions{
Squash: &acceptAndMergeRequest.Squash, Squash: &payload.Squash,
ShouldRemoveSourceBranch: &acceptAndMergeRequest.DeleteBranch, ShouldRemoveSourceBranch: &payload.DeleteBranch,
} }
if acceptAndMergeRequest.SquashMessage != "" { if payload.SquashMessage != "" {
opts.SquashCommitMessage = &acceptAndMergeRequest.SquashMessage opts.SquashCommitMessage = &payload.SquashMessage
} }
_, res, err := a.client.AcceptMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opts) _, res, err := a.client.AcceptMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opts)
@@ -62,14 +43,11 @@ func (a mergeRequestAccepterService) handler(w http.ResponseWriter, r *http.Requ
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/merge"}, "Could not merge MR", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not merge MR", res.StatusCode)
return return
} }
response := SuccessResponse{ response := SuccessResponse{Message: "MR merged successfully"}
Status: http.StatusOK,
Message: "MR merged successfully",
}
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)

View File

@@ -24,27 +24,41 @@ func TestAcceptAndMergeHandler(t *testing.T) {
var testAcceptMergeRequestPayload = AcceptMergeRequestRequest{Squash: false, SquashMessage: "Squash me!", DeleteBranch: false} var testAcceptMergeRequestPayload = AcceptMergeRequestRequest{Squash: false, SquashMessage: "Squash me!", DeleteBranch: false}
t.Run("Accepts and merges a merge request", func(t *testing.T) { t.Run("Accepts and merges a merge request", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload) request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload)
svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{}} svc := middleware(
mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{
http.MethodPost: &AcceptMergeRequestRequest{},
}),
withMethodCheck(http.MethodPost),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Message, "MR merged successfully") 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) { t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload) request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload)
svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{testBase{errFromGitlab: true}}} svc := middleware(
data := getFailData(t, svc, request) mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{testBase{errFromGitlab: true}}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{
http.MethodPost: &AcceptMergeRequestRequest{},
}),
withMethodCheck(http.MethodPost),
)
data, _ := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not merge MR") checkErrorFromGitlab(t, data, "Could not merge MR")
}) })
t.Run("Handles non-200s from Gitlab", func(t *testing.T) { t.Run("Handles non-200s from Gitlab", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload) request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload)
svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{testBase{status: http.StatusSeeOther}}} svc := middleware(
data := getFailData(t, svc, request) mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{testBase{status: http.StatusSeeOther}}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{
http.MethodPost: &AcceptMergeRequestRequest{},
}),
withMethodCheck(http.MethodPost),
)
data, _ := getFailData(t, svc, request)
checkNon200(t, data, "Could not merge MR", "/mr/merge") checkNon200(t, data, "Could not merge MR", "/mr/merge")
}) })
} }

View File

@@ -3,7 +3,6 @@ package app
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"io"
"net/http" "net/http"
"github.com/xanzy/go-gitlab" "github.com/xanzy/go-gitlab"
@@ -23,37 +22,20 @@ type mergeRequestListerService struct {
client MergeRequestLister client MergeRequestLister
} }
func (a mergeRequestListerService) handler(w http.ResponseWriter, r *http.Request) { // Lists all merge requests in Gitlab according to the provided filters
w.Header().Set("Content-Type", "application/json") func (a mergeRequestListerService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) payload := r.Context().Value(payload("payload")).(*gitlab.ListProjectMergeRequestsOptions)
handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed)
return if payload.State == nil {
payload.State = gitlab.Ptr("opened")
} }
body, err := io.ReadAll(r.Body) if payload.Scope == nil {
if err != nil { payload.Scope = gitlab.Ptr("all")
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
} }
defer r.Body.Close() mergeRequests, res, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, payload)
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 { if err != nil {
handleError(w, err, "Failed to list merge requests", http.StatusInternalServerError) handleError(w, err, "Failed to list merge requests", http.StatusInternalServerError)
@@ -61,7 +43,7 @@ func (a mergeRequestListerService) handler(w http.ResponseWriter, r *http.Reques
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/merge_requests"}, "Failed to list merge requests", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Failed to list merge requests", res.StatusCode)
return return
} }
@@ -72,11 +54,8 @@ func (a mergeRequestListerService) handler(w http.ResponseWriter, r *http.Reques
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := ListMergeRequestResponse{ response := ListMergeRequestResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Merge requests fetched successfully"},
Message: "Merge requests fetched successfully", MergeRequests: mergeRequests,
Status: http.StatusOK,
},
MergeRequests: mergeRequests,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)

View File

@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"sync" "sync"
@@ -21,42 +20,15 @@ type mergeRequestListerByUsernameService struct {
} }
type MergeRequestByUsernameRequest struct { type MergeRequestByUsernameRequest struct {
UserId int `json:"user_id"` UserId int `json:"user_id" validate:"required"`
Username string `json:"username"` Username string `json:"username" validate:"required"`
State string `json:"state,omitempty"` State string `json:"state,omitempty"`
} }
func (a mergeRequestListerByUsernameService) handler(w http.ResponseWriter, r *http.Request) { // Returns a list of merge requests where the given username/id is either an assignee, reviewer, or author
w.Header().Set("Content-Type", "application/json") func (a mergeRequestListerByUsernameService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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) request := r.Context().Value(payload("payload")).(*MergeRequestByUsernameRequest)
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 == "" { if request.State == "" {
request.State = "opened" request.State = "opened"
@@ -133,14 +105,11 @@ func (a mergeRequestListerByUsernameService) handler(w http.ResponseWriter, r *h
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := ListMergeRequestResponse{ response := ListMergeRequestResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: fmt.Sprintf("Merge requests fetched for %s", request.Username)},
Message: fmt.Sprintf("Merge requests fetched for %s", request.Username), MergeRequests: mergeRequests,
Status: http.StatusOK,
},
MergeRequests: mergeRequests,
} }
err = json.NewEncoder(w).Encode(response) err := json.NewEncoder(w).Encode(response)
if err != nil { if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError) handleError(w, err, "Could not encode response", http.StatusInternalServerError)
} }

View File

@@ -30,58 +30,81 @@ func TestListMergeRequestByUsername(t *testing.T) {
var testListMrsByUsernamePayload = MergeRequestByUsernameRequest{Username: "hcramer", UserId: 1234, State: "opened"} var testListMrsByUsernamePayload = MergeRequestByUsernameRequest{Username: "hcramer", UserId: 1234, State: "opened"}
t.Run("Gets merge requests by username", func(t *testing.T) { t.Run("Gets merge requests by username", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload) request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload)
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}} svc := middleware(
mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}},
withPayloadValidation(methodToPayload{http.MethodPost: &MergeRequestByUsernameRequest{}}),
withMethodCheck(http.MethodPost),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Message, "Merge requests fetched for hcramer") 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) { t.Run("Should handle no merge requests", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload) request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload)
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{emptyResponse: true}} svc := middleware(
data := getFailData(t, svc, request) mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{emptyResponse: true}},
withPayloadValidation(methodToPayload{http.MethodPost: &MergeRequestByUsernameRequest{}}),
withMethodCheck(http.MethodPost),
)
data, status := getFailData(t, svc, request)
assert(t, data.Message, "No MRs found") assert(t, data.Message, "No MRs found")
assert(t, data.Details, "hcramer did not have any MRs") assert(t, data.Details, "hcramer did not have any MRs")
assert(t, data.Status, http.StatusNotFound) assert(t, status, http.StatusNotFound)
}) })
t.Run("Should require username", func(t *testing.T) { t.Run("Should require username", func(t *testing.T) {
missingUsernamePayload := testListMrsByUsernamePayload missingUsernamePayload := testListMrsByUsernamePayload
missingUsernamePayload.Username = "" missingUsernamePayload.Username = ""
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", missingUsernamePayload) request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", missingUsernamePayload)
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}} svc := middleware(
data := getFailData(t, svc, request) mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}},
assert(t, data.Message, "username is required") withPayloadValidation(methodToPayload{http.MethodPost: &MergeRequestByUsernameRequest{}}),
assert(t, data.Details, "username is a required payload field") withMethodCheck(http.MethodPost),
assert(t, data.Status, http.StatusBadRequest) )
data, status := getFailData(t, svc, request)
assert(t, data.Message, "Invalid payload")
assert(t, data.Details, "Username is required")
assert(t, status, http.StatusBadRequest)
}) })
t.Run("Should require User ID for assignee call", func(t *testing.T) { t.Run("Should require User ID for assignee call", func(t *testing.T) {
missingUsernamePayload := testListMrsByUsernamePayload missingUsernamePayload := testListMrsByUsernamePayload
missingUsernamePayload.UserId = 0 missingUsernamePayload.UserId = 0
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", missingUsernamePayload) request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", missingUsernamePayload)
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}} svc := middleware(
data := getFailData(t, svc, request) mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}},
assert(t, data.Message, "user_id is required") withPayloadValidation(methodToPayload{http.MethodPost: &MergeRequestByUsernameRequest{}}),
assert(t, data.Details, "user_id is a required payload field") withMethodCheck(http.MethodPost),
assert(t, data.Status, http.StatusBadRequest) )
data, status := getFailData(t, svc, request)
assert(t, data.Message, "Invalid payload")
assert(t, data.Details, "UserId is required")
assert(t, status, http.StatusBadRequest)
}) })
t.Run("Should handle error from Gitlab", func(t *testing.T) { t.Run("Should handle error from Gitlab", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload) request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload)
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{testBase: testBase{errFromGitlab: true}}} svc := middleware(
data := getFailData(t, svc, request) mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{testBase: testBase{errFromGitlab: true}}},
withPayloadValidation(methodToPayload{http.MethodPost: &MergeRequestByUsernameRequest{}}),
withMethodCheck(http.MethodPost),
)
data, status := getFailData(t, svc, request)
assert(t, data.Message, "An error occurred") assert(t, data.Message, "An error occurred")
assert(t, data.Details, strings.Repeat("Some error from Gitlab; ", 3)) assert(t, data.Details, strings.Repeat("Some error from Gitlab; ", 3))
assert(t, data.Status, http.StatusInternalServerError) assert(t, status, http.StatusInternalServerError)
}) })
t.Run("Handles non-200 from Gitlab", func(t *testing.T) { t.Run("Handles non-200 from Gitlab", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload) request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload)
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{testBase: testBase{status: http.StatusSeeOther}}} svc := middleware(
data := getFailData(t, svc, request) mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{testBase: testBase{status: http.StatusSeeOther}}},
withPayloadValidation(methodToPayload{http.MethodPost: &MergeRequestByUsernameRequest{}}),
withMethodCheck(http.MethodPost),
)
data, status := getFailData(t, svc, request)
assert(t, data.Message, "An error occurred") 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.Details, strings.Repeat("An error occurred on the /merge_requests_by_username endpoint; ", 3))
assert(t, data.Status, http.StatusInternalServerError) assert(t, status, http.StatusInternalServerError)
}) })
} }

View File

@@ -10,6 +10,7 @@ import (
type fakeMergeRequestLister struct { type fakeMergeRequestLister struct {
testBase testBase
emptyResponse bool emptyResponse bool
multipleMrs bool
} }
func (f fakeMergeRequestLister) ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { func (f fakeMergeRequestLister) ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) {
@@ -22,6 +23,10 @@ func (f fakeMergeRequestLister) ListProjectMergeRequests(pid interface{}, opt *g
return []*gitlab.MergeRequest{}, resp, err return []*gitlab.MergeRequest{}, resp, err
} }
if f.multipleMrs {
return []*gitlab.MergeRequest{{IID: 10}, {IID: 11}}, resp, err
}
return []*gitlab.MergeRequest{{IID: 10}}, resp, err return []*gitlab.MergeRequest{{IID: 10}}, resp, err
} }
@@ -29,30 +34,45 @@ func TestMergeRequestHandler(t *testing.T) {
var testListMergeRequestsRequest = gitlab.ListProjectMergeRequestsOptions{} var testListMergeRequestsRequest = gitlab.ListProjectMergeRequestsOptions{}
t.Run("Should fetch merge requests", func(t *testing.T) { t.Run("Should fetch merge requests", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest)
svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{}} svc := middleware(
mergeRequestListerService{testProjectData, fakeMergeRequestLister{}},
withPayloadValidation(methodToPayload{http.MethodPost: &gitlab.ListProjectMergeRequestsOptions{}}),
withMethodCheck(http.MethodPost),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "Merge requests fetched successfully") assert(t, data.Message, "Merge requests fetched successfully")
}) })
t.Run("Handles error from Gitlab client", func(t *testing.T) { t.Run("Handles error from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest)
svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{testBase: testBase{errFromGitlab: true}}} svc := middleware(
data := getFailData(t, svc, request) mergeRequestListerService{testProjectData, fakeMergeRequestLister{testBase: testBase{errFromGitlab: true}}},
withPayloadValidation(methodToPayload{http.MethodPost: &gitlab.ListProjectMergeRequestsOptions{}}),
withMethodCheck(http.MethodPost),
)
data, status := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Failed to list merge requests") checkErrorFromGitlab(t, data, "Failed to list merge requests")
assert(t, data.Status, http.StatusInternalServerError) assert(t, status, http.StatusInternalServerError)
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest)
svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{testBase: testBase{status: http.StatusSeeOther}}} svc := middleware(
data := getFailData(t, svc, request) mergeRequestListerService{testProjectData, fakeMergeRequestLister{testBase: testBase{status: http.StatusSeeOther}}},
withPayloadValidation(methodToPayload{http.MethodPost: &gitlab.ListProjectMergeRequestsOptions{}}),
withMethodCheck(http.MethodPost),
)
data, status := getFailData(t, svc, request)
checkNon200(t, data, "Failed to list merge requests", "/merge_requests") checkNon200(t, data, "Failed to list merge requests", "/merge_requests")
assert(t, data.Status, http.StatusSeeOther) assert(t, status, http.StatusSeeOther)
}) })
t.Run("Should handle not having any merge requests with 404", func(t *testing.T) { t.Run("Should handle not having any merge requests with 404", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest)
svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{emptyResponse: true}} svc := middleware(
data := getFailData(t, svc, request) mergeRequestListerService{testProjectData, fakeMergeRequestLister{emptyResponse: true}},
withPayloadValidation(methodToPayload{http.MethodPost: &gitlab.ListProjectMergeRequestsOptions{}}),
withMethodCheck(http.MethodPost),
)
data, status := getFailData(t, svc, request)
assert(t, data.Message, "No merge requests found") assert(t, data.Message, "No merge requests found")
assert(t, data.Status, http.StatusNotFound) assert(t, status, http.StatusNotFound)
}) })
} }

173
cmd/app/middleware.go Normal file
View File

@@ -0,0 +1,173 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/go-playground/validator/v10"
"github.com/xanzy/go-gitlab"
)
type mw func(http.Handler) http.Handler
type payload string
// Wraps a series of middleware around the base handler. Functions are called from bottom to top.
// The middlewares should call the serveHTTP method on their http.Handler argument to pass along the request.
func middleware(h http.Handler, middlewares ...mw) http.HandlerFunc {
for _, middleware := range middlewares {
h = middleware(h)
}
return h.ServeHTTP
}
var validate = validator.New()
type methodToPayload map[string]any
type validatorMiddleware struct {
validate *validator.Validate
methodToPayload methodToPayload
}
// Validates the fields in a payload and then attaches the validated payload to the request context so that
// subsequent handlers can use it.
func (p validatorMiddleware) handle(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if p.methodToPayload[r.Method] == nil { // If no payload to validate for this method type...
next.ServeHTTP(w, r)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
pl := p.methodToPayload[r.Method]
err = json.Unmarshal(body, &pl)
if err != nil {
handleError(w, err, "Could not parse JSON request body", http.StatusBadRequest)
return
}
err = p.validate.Struct(pl)
if err != nil {
switch err := err.(type) {
case validator.ValidationErrors:
handleError(w, formatValidationErrors(err), "Invalid payload", http.StatusBadRequest)
return
case *validator.InvalidValidationError:
handleError(w, err, "Invalid validation error", http.StatusInternalServerError)
return
}
}
// Pass the parsed data so we don't have to re-parse it in the handler
ctx := context.WithValue(r.Context(), payload(payload("payload")), pl)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
func withPayloadValidation(mtp methodToPayload) mw {
return validatorMiddleware{validate: validate, methodToPayload: mtp}.handle
}
type withMrMiddleware struct {
data data
client MergeRequestLister
}
// Gets the current merge request ID and attaches it to the projectInfo
func (m withMrMiddleware) handle(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// If the merge request is already attached, skip the middleware logic
if m.data.projectInfo.MergeId == 0 {
options := gitlab.ListProjectMergeRequestsOptions{
Scope: gitlab.Ptr("all"),
SourceBranch: &m.data.gitInfo.BranchName,
TargetBranch: pluginOptions.ChosenTargetBranch,
}
mergeRequests, _, err := m.client.ListProjectMergeRequests(m.data.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("Branch '%s' does not have any merge requests", m.data.gitInfo.BranchName)
handleError(w, err, "No MRs Found", http.StatusNotFound)
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
m.data.projectInfo.MergeId = mergeIdInt
}
// Call the next handler if middleware succeeds
next.ServeHTTP(w, r)
})
}
// Att
func withMr(data data, client MergeRequestLister) mw {
return withMrMiddleware{data, client}.handle
}
type methodMiddleware struct {
methods []string
}
func (m methodMiddleware) handle(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method := r.Method
for _, acceptableMethod := range m.methods {
if method == acceptableMethod {
next.ServeHTTP(w, r)
return
}
}
w.Header().Set("Access-Control-Allow-Methods", http.MethodPut)
handleError(w, InvalidRequestError{fmt.Sprintf("Expected: %s", strings.Join(m.methods, "; "))}, "Invalid request type", http.StatusMethodNotAllowed)
})
}
func withMethodCheck(methods ...string) mw {
return methodMiddleware{methods: methods}.handle
}
// Helper function to format validation errors into more readable strings
func formatValidationErrors(errors validator.ValidationErrors) error {
var s strings.Builder
for i, e := range errors {
if i > 0 {
s.WriteString("; ")
}
switch e.Tag() {
case "required":
s.WriteString(fmt.Sprintf("%s is required", e.Field()))
default:
s.WriteString(fmt.Sprintf("The field '%s' failed on validation on the '%s' tag", e.Field(), e.Tag()))
}
}
return fmt.Errorf(s.String())
}

114
cmd/app/middleware_test.go Normal file
View File

@@ -0,0 +1,114 @@
package app
import (
"encoding/json"
"net/http"
"testing"
"github.com/harrisoncramer/gitlab.nvim/cmd/app/git"
)
type FakePayload struct {
Foo string `json:"foo" validate:"required"`
}
type fakeHandler struct{}
func (f fakeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
data := SuccessResponse{Message: "Some message"}
j, _ := json.Marshal(data)
w.Write(j) // nolint
}
func TestMethodMiddleware(t *testing.T) {
t.Run("Fails a bad method", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/foo", nil)
mw := withMethodCheck(http.MethodPost)
handler := middleware(fakeHandler{}, mw)
data, status := getFailData(t, handler, request)
assert(t, data.Message, "Invalid request type")
assert(t, data.Details, "Expected: POST")
assert(t, status, http.StatusMethodNotAllowed)
})
t.Run("Fails bad method with multiple", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/foo", nil)
mw := withMethodCheck(http.MethodPost, http.MethodPatch)
handler := middleware(fakeHandler{}, mw)
data, status := getFailData(t, handler, request)
assert(t, data.Message, "Invalid request type")
assert(t, data.Details, "Expected: POST; PATCH")
assert(t, status, http.StatusMethodNotAllowed)
})
t.Run("Allows ok method through", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/foo", nil)
mw := withMethodCheck(http.MethodGet)
handler := middleware(fakeHandler{}, mw)
data := getSuccessData(t, handler, request)
assert(t, data.Message, "Some message")
})
}
func TestWithMrMiddleware(t *testing.T) {
t.Run("Loads an MR ID into the projectInfo", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/foo", nil)
d := data{
projectInfo: &ProjectInfo{},
gitInfo: &git.GitData{BranchName: "foo"},
}
mw := withMr(d, fakeMergeRequestLister{})
handler := middleware(fakeHandler{}, mw)
getSuccessData(t, handler, request)
if d.projectInfo.MergeId != 10 {
t.FailNow()
}
})
t.Run("Handles when there are no MRs", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/foo", nil)
d := data{
projectInfo: &ProjectInfo{},
gitInfo: &git.GitData{BranchName: "foo"},
}
mw := withMr(d, fakeMergeRequestLister{emptyResponse: true})
handler := middleware(fakeHandler{}, mw)
data, status := getFailData(t, handler, request)
assert(t, status, http.StatusNotFound)
assert(t, data.Message, "No MRs Found")
assert(t, data.Details, "Branch 'foo' does not have any merge requests")
})
t.Run("Handles when there are too many MRs", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/foo", nil)
d := data{
projectInfo: &ProjectInfo{},
gitInfo: &git.GitData{BranchName: "foo"},
}
mw := withMr(d, fakeMergeRequestLister{multipleMrs: true})
handler := middleware(fakeHandler{}, mw)
data, status := getFailData(t, handler, request)
assert(t, status, http.StatusBadRequest)
assert(t, data.Message, "Multiple MRs found")
assert(t, data.Details, "Please call gitlab.choose_merge_request()")
})
}
func TestValidatorMiddleware(t *testing.T) {
t.Run("Should error with missing field", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/foo", FakePayload{}) // No Foo field
data, status := getFailData(t, middleware(
fakeHandler{},
withPayloadValidation(methodToPayload{http.MethodPost: &FakePayload{}}),
), request)
assert(t, data.Message, "Invalid payload")
assert(t, data.Details, "Foo is required")
assert(t, status, http.StatusBadRequest)
})
t.Run("Should allow valid payload through", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/foo", FakePayload{Foo: "Some payload"})
data := getSuccessData(t, middleware(
fakeHandler{},
withPayloadValidation(methodToPayload{http.MethodPost: &FakePayload{}}),
), request)
assert(t, data.Message, "Some message")
})
}

View File

@@ -43,16 +43,12 @@ type pipelineService struct {
pipelineHandler fetches information about the current pipeline, and retriggers a pipeline run. For more detailed information 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 about a given job in a pipeline, see the jobHandler function
*/ */
func (a pipelineService) handler(w http.ResponseWriter, r *http.Request) { func (a pipelineService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
a.GetPipelineAndJobs(w, r) a.GetPipelineAndJobs(w, r)
case http.MethodPost: case http.MethodPost:
a.RetriggerPipeline(w, r) 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)
} }
} }
@@ -100,7 +96,7 @@ func (a pipelineService) GetPipelineAndJobs(w http.ResponseWriter, r *http.Reque
} }
if pipeline == nil { if pipeline == nil {
handleError(w, GenericError{endpoint: "/pipeline"}, fmt.Sprintf("No pipeline found for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError) handleError(w, GenericError{r.URL.Path}, fmt.Sprintf("No pipeline found for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError)
return return
} }
@@ -112,16 +108,13 @@ func (a pipelineService) GetPipelineAndJobs(w http.ResponseWriter, r *http.Reque
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/pipeline"}, "Could not get pipeline jobs", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not get pipeline jobs", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := GetPipelineAndJobsResponse{ response := GetPipelineAndJobsResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Pipeline retrieved"},
Status: http.StatusOK,
Message: "Pipeline retrieved",
},
Pipeline: PipelineWithJobs{ Pipeline: PipelineWithJobs{
LatestPipeline: pipeline, LatestPipeline: pipeline,
Jobs: jobs, Jobs: jobs,
@@ -153,17 +146,14 @@ func (a pipelineService) RetriggerPipeline(w http.ResponseWriter, r *http.Reques
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/pipeline"}, "Could not retrigger pipeline", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not retrigger pipeline", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := RetriggerPipelineResponse{ response := RetriggerPipelineResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Pipeline retriggered"},
Message: "Pipeline retriggered", LatestPipeline: pipeline,
Status: http.StatusOK,
},
LatestPipeline: pipeline,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)

View File

@@ -38,27 +38,29 @@ func (f fakePipelineManager) RetryPipelineBuild(pid interface{}, pipeline int, o
func TestPipelineGetter(t *testing.T) { func TestPipelineGetter(t *testing.T) {
t.Run("Gets all pipeline jobs", func(t *testing.T) { t.Run("Gets all pipeline jobs", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/pipeline", nil) request := makeRequest(t, http.MethodGet, "/pipeline", nil)
svc := pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}} svc := middleware(
pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}},
withMethodCheck(http.MethodGet),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Message, "Pipeline retrieved") 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) { t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/pipeline", nil) request := makeRequest(t, http.MethodGet, "/pipeline", nil)
svc := pipelineService{testProjectData, fakePipelineManager{testBase{errFromGitlab: true}}, FakeGitManager{}} svc := middleware(
data := getFailData(t, svc, request) pipelineService{testProjectData, fakePipelineManager{testBase{errFromGitlab: true}}, FakeGitManager{}},
withMethodCheck(http.MethodGet),
)
data, _ := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Failed to get latest pipeline for some-branch branch") checkErrorFromGitlab(t, data, "Failed to get latest pipeline for some-branch branch")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/pipeline", nil) request := makeRequest(t, http.MethodGet, "/pipeline", nil)
svc := pipelineService{testProjectData, fakePipelineManager{testBase: testBase{status: http.StatusSeeOther}}, FakeGitManager{}} svc := middleware(
data := getFailData(t, svc, request) pipelineService{testProjectData, fakePipelineManager{testBase{status: http.StatusSeeOther}}, FakeGitManager{}},
withMethodCheck(http.MethodGet),
)
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 assert(t, data.Message, "Failed to get latest pipeline for some-branch branch") // Expected, we treat this as an error
}) })
} }
@@ -66,21 +68,29 @@ func TestPipelineGetter(t *testing.T) {
func TestPipelineTrigger(t *testing.T) { func TestPipelineTrigger(t *testing.T) {
t.Run("Retriggers pipeline", func(t *testing.T) { t.Run("Retriggers pipeline", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil) request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil)
svc := pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}} svc := middleware(
pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}},
withMethodCheck(http.MethodPost),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Message, "Pipeline retriggered") assert(t, data.Message, "Pipeline retriggered")
assert(t, data.Status, http.StatusOK)
}) })
t.Run("Handles errors from Gitlab client", func(t *testing.T) { t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil) request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil)
svc := pipelineService{testProjectData, fakePipelineManager{testBase{errFromGitlab: true}}, FakeGitManager{}} svc := middleware(
data := getFailData(t, svc, request) pipelineService{testProjectData, fakePipelineManager{testBase{errFromGitlab: true}}, FakeGitManager{}},
withMethodCheck(http.MethodPost),
)
data, _ := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not retrigger pipeline") checkErrorFromGitlab(t, data, "Could not retrigger pipeline")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil) request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil)
svc := pipelineService{testProjectData, fakePipelineManager{testBase: testBase{status: http.StatusSeeOther}}, FakeGitManager{}} svc := middleware(
data := getFailData(t, svc, request) pipelineService{testProjectData, fakePipelineManager{testBase{status: http.StatusSeeOther}}, FakeGitManager{}},
checkNon200(t, data, "Could not retrigger pipeline", "/pipeline") withMethodCheck(http.MethodPost),
)
data, _ := getFailData(t, svc, request)
checkNon200(t, data, "Could not retrigger pipeline", "/pipeline/trigger/3")
}) })
} }

View File

@@ -2,7 +2,6 @@ package app
import ( import (
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"time" "time"
@@ -10,8 +9,8 @@ import (
) )
type ReplyRequest struct { type ReplyRequest struct {
DiscussionId string `json:"discussion_id"` DiscussionId string `json:"discussion_id" validate:"required"`
Reply string `json:"reply"` Reply string `json:"reply" validate:"required"`
IsDraft bool `json:"is_draft"` IsDraft bool `json:"is_draft"`
} }
@@ -30,28 +29,8 @@ type replyService struct {
} }
/* replyHandler sends a reply to a note or comment */ /* replyHandler sends a reply to a note or comment */
func (a replyService) handler(w http.ResponseWriter, r *http.Request) { func (a replyService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") replyRequest := r.Context().Value(payload("payload")).(*ReplyRequest)
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() now := time.Now()
options := gitlab.AddMergeRequestDiscussionNoteOptions{ options := gitlab.AddMergeRequestDiscussionNoteOptions{
@@ -67,17 +46,14 @@ func (a replyService) handler(w http.ResponseWriter, r *http.Request) {
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/reply"}, "Could not leave reply", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not leave reply", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := ReplyResponse{ response := ReplyResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Replied to comment"},
Message: "Replied to comment", Note: note,
Status: http.StatusOK,
},
Note: note,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)

View File

@@ -24,22 +24,36 @@ func TestReplyHandler(t *testing.T) {
var testReplyRequest = ReplyRequest{DiscussionId: "abc123", Reply: "Some Reply", IsDraft: false} var testReplyRequest = ReplyRequest{DiscussionId: "abc123", Reply: "Some Reply", IsDraft: false}
t.Run("Sends a reply", func(t *testing.T) { t.Run("Sends a reply", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest) request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest)
svc := replyService{testProjectData, fakeReplyManager{}} svc := middleware(
replyService{testProjectData, fakeReplyManager{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPost: &ReplyRequest{}}),
withMethodCheck(http.MethodPost),
)
data := getSuccessData(t, svc, request) data := getSuccessData(t, svc, request)
assert(t, data.Message, "Replied to comment") assert(t, data.Message, "Replied to comment")
assert(t, data.Status, http.StatusOK)
}) })
t.Run("Handles errors from Gitlab client", func(t *testing.T) { t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest) request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest)
svc := replyService{testProjectData, fakeReplyManager{testBase{errFromGitlab: true}}} svc := middleware(
data := getFailData(t, svc, request) replyService{testProjectData, fakeReplyManager{testBase{errFromGitlab: true}}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPost: &ReplyRequest{}}),
withMethodCheck(http.MethodPost),
)
data, _ := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not leave reply") checkErrorFromGitlab(t, data, "Could not leave reply")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest) request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest)
svc := replyService{testProjectData, fakeReplyManager{testBase{status: http.StatusSeeOther}}} svc := middleware(
data := getFailData(t, svc, request) replyService{testProjectData, fakeReplyManager{testBase{status: http.StatusSeeOther}}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPost: &ReplyRequest{}}),
withMethodCheck(http.MethodPost),
)
data, _ := getFailData(t, svc, request)
checkNon200(t, data, "Could not leave reply", "/mr/reply") checkNon200(t, data, "Could not leave reply", "/mr/reply")
}) })
} }

View File

@@ -3,17 +3,11 @@ package app
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"github.com/xanzy/go-gitlab" "github.com/xanzy/go-gitlab"
) )
type DiscussionResolveRequest struct {
DiscussionID string `json:"discussion_id"`
Resolved bool `json:"resolved"`
}
type DiscussionResolver interface { type DiscussionResolver interface {
ResolveMergeRequestDiscussion(pid interface{}, mergeRequest int, discussion string, opt *gitlab.ResolveMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) ResolveMergeRequestDiscussion(pid interface{}, mergeRequest int, discussion string, opt *gitlab.ResolveMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error)
} }
@@ -23,40 +17,24 @@ type discussionsResolutionService struct {
client DiscussionResolver client DiscussionResolver
} }
type DiscussionResolveRequest struct {
DiscussionID string `json:"discussion_id" validate:"required"`
Resolved bool `json:"resolved"`
}
/* discussionsResolveHandler sets a discussion to be "resolved" or not resolved, depending on the payload */ /* discussionsResolveHandler sets a discussion to be "resolved" or not resolved, depending on the payload */
func (a discussionsResolutionService) handler(w http.ResponseWriter, r *http.Request) { func (a discussionsResolutionService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") payload := r.Context().Value(payload("payload")).(*DiscussionResolveRequest)
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( _, res, err := a.client.ResolveMergeRequestDiscussion(
a.projectInfo.ProjectId, a.projectInfo.ProjectId,
a.projectInfo.MergeId, a.projectInfo.MergeId,
resolveDiscussionRequest.DiscussionID, payload.DiscussionID,
&gitlab.ResolveMergeRequestDiscussionOptions{Resolved: &resolveDiscussionRequest.Resolved}, &gitlab.ResolveMergeRequestDiscussionOptions{Resolved: &payload.Resolved},
) )
friendlyName := "unresolve" friendlyName := "unresolve"
if resolveDiscussionRequest.Resolved { if payload.Resolved {
friendlyName = "resolve" friendlyName = "resolve"
} }
@@ -66,15 +44,12 @@ func (a discussionsResolutionService) handler(w http.ResponseWriter, r *http.Req
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/discussions/resolve"}, fmt.Sprintf("Could not %s discussion", friendlyName), res.StatusCode) handleError(w, GenericError{r.URL.Path}, fmt.Sprintf("Could not %s discussion", friendlyName), res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := SuccessResponse{ response := SuccessResponse{Message: fmt.Sprintf("Discussion %sd", friendlyName)}
Message: fmt.Sprintf("Discussion %sd", friendlyName),
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)
if err != nil { if err != nil {

View File

@@ -0,0 +1,84 @@
package app
import (
"net/http"
"testing"
"github.com/xanzy/go-gitlab"
)
type fakeDiscussionResolver struct {
testBase
}
func (f fakeDiscussionResolver) ResolveMergeRequestDiscussion(pid interface{}, mergeRequest int, discussion string, opt *gitlab.ResolveMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
return &gitlab.Discussion{}, resp, err
}
func TestResolveDiscussion(t *testing.T) {
var testResolveMergeRequestPayload = DiscussionResolveRequest{
DiscussionID: "abc123",
Resolved: true,
}
t.Run("Resolves a discussion", func(t *testing.T) {
svc := middleware(
discussionsResolutionService{testProjectData, fakeDiscussionResolver{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPut: &DiscussionResolveRequest{}}),
withMethodCheck(http.MethodPut),
)
request := makeRequest(t, http.MethodPut, "/mr/discussions/resolve", testResolveMergeRequestPayload)
data := getSuccessData(t, svc, request)
assert(t, data.Message, "Discussion resolved")
})
t.Run("Unresolves a discussion", func(t *testing.T) {
payload := testResolveMergeRequestPayload
payload.Resolved = false
svc := middleware(
discussionsResolutionService{testProjectData, fakeDiscussionResolver{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPut: &DiscussionResolveRequest{}}),
withMethodCheck(http.MethodPut),
)
request := makeRequest(t, http.MethodPut, "/mr/discussions/resolve", payload)
data := getSuccessData(t, svc, request)
assert(t, data.Message, "Discussion unresolved")
})
t.Run("Requires a discussion ID", func(t *testing.T) {
payload := testResolveMergeRequestPayload
payload.DiscussionID = ""
svc := middleware(
discussionsResolutionService{testProjectData, fakeDiscussionResolver{}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPut: &DiscussionResolveRequest{}}),
withMethodCheck(http.MethodPut),
)
request := makeRequest(t, http.MethodPut, "/mr/discussions/resolve", payload)
data, status := getFailData(t, svc, request)
assert(t, data.Message, "Invalid payload")
assert(t, data.Details, "DiscussionID is required")
assert(t, status, http.StatusBadRequest)
})
t.Run("Handles error from Gitlab", func(t *testing.T) {
svc := middleware(
discussionsResolutionService{testProjectData, fakeDiscussionResolver{testBase: testBase{errFromGitlab: true}}},
withMr(testProjectData, fakeMergeRequestLister{}),
withPayloadValidation(methodToPayload{http.MethodPut: &DiscussionResolveRequest{}}),
withMethodCheck(http.MethodPut),
)
request := makeRequest(t, http.MethodPut, "/mr/discussions/resolve", testResolveMergeRequestPayload)
data, status := getFailData(t, svc, request)
assert(t, data.Message, "Could not resolve discussion")
assert(t, data.Details, "Some error from Gitlab")
assert(t, status, http.StatusInternalServerError)
})
}

View File

@@ -7,12 +7,10 @@ import (
type ErrorResponse struct { type ErrorResponse struct {
Message string `json:"message"` Message string `json:"message"`
Details string `json:"details"` Details string `json:"details"`
Status int `json:"status"`
} }
type SuccessResponse struct { type SuccessResponse struct {
Message string `json:"message"` Message string `json:"message"`
Status int `json:"status"`
} }
type GenericError struct { type GenericError struct {
@@ -23,8 +21,8 @@ func (e GenericError) Error() string {
return fmt.Sprintf("An error occurred on the %s endpoint", e.endpoint) return fmt.Sprintf("An error occurred on the %s endpoint", e.endpoint)
} }
type InvalidRequestError struct{} type InvalidRequestError struct{ msg string }
func (e InvalidRequestError) Error() string { func (e InvalidRequestError) Error() string {
return "Invalid request type" return e.msg
} }

View File

@@ -2,14 +2,13 @@ package app
import ( import (
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"github.com/xanzy/go-gitlab" "github.com/xanzy/go-gitlab"
) )
type ReviewerUpdateRequest struct { type ReviewerUpdateRequest struct {
Ids []int `json:"ids"` Ids []int `json:"ids" validate:"required"`
} }
type ReviewerUpdateResponse struct { type ReviewerUpdateResponse struct {
@@ -32,31 +31,11 @@ type reviewerService struct {
} }
/* reviewersHandler adds or removes reviewers from an MR */ /* reviewersHandler adds or removes reviewers from an MR */
func (a reviewerService) handler(w http.ResponseWriter, r *http.Request) { func (a reviewerService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") payload := r.Context().Value(payload("payload")).(*ReviewerUpdateRequest)
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{ mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{
ReviewerIDs: &reviewerUpdateRequest.Ids, ReviewerIDs: &payload.Ids,
}) })
if err != nil { if err != nil {
@@ -65,17 +44,14 @@ func (a reviewerService) handler(w http.ResponseWriter, r *http.Request) {
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/reviewer"}, "Could not modify merge request reviewers", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not modify merge request reviewers", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := ReviewerUpdateResponse{ response := ReviewerUpdateResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Reviewers updated"},
Message: "Reviewers updated", Reviewers: mr.Reviewers,
Status: http.StatusOK,
},
Reviewers: mr.Reviewers,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)

View File

@@ -25,13 +25,7 @@ type revisionsService struct {
revisionsHandler gets revision information about the current MR. This data is not used directly but is revisionsHandler gets revision information about the current MR. This data is not used directly but is
a precursor API call for other functionality a precursor API call for other functionality
*/ */
func (a revisionsService) handler(w http.ResponseWriter, r *http.Request) { func (a revisionsService) ServeHTTP(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{}) versionInfo, res, err := a.client.GetMergeRequestDiffVersions(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.GetMergeRequestDiffVersionsOptions{})
if err != nil { if err != nil {
@@ -40,17 +34,14 @@ func (a revisionsService) handler(w http.ResponseWriter, r *http.Request) {
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/revisions"}, "Could not get diff version info", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not get diff version info", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := RevisionsResponse{ response := RevisionsResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Revisions fetched successfully"},
Message: "Revisions fetched successfully", Revisions: versionInfo,
Status: http.StatusOK,
},
Revisions: versionInfo,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)

View File

@@ -17,13 +17,7 @@ type mergeRequestRevokerService struct {
} }
/* revokeHandler revokes approval for the current merge request */ /* revokeHandler revokes approval for the current merge request */
func (a mergeRequestRevokerService) handler(w http.ResponseWriter, r *http.Request) { func (a mergeRequestRevokerService) ServeHTTP(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) res, err := a.client.UnapproveMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, nil, nil)
@@ -33,15 +27,12 @@ func (a mergeRequestRevokerService) handler(w http.ResponseWriter, r *http.Reque
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/revoke"}, "Could not revoke approval", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not revoke approval", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := SuccessResponse{ response := SuccessResponse{Message: "Success! Revoked MR approval"}
Message: "Success! Revoked MR approval",
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)
if err != nil { if err != nil {

View File

@@ -76,7 +76,7 @@ type data struct {
type optFunc func(a *data) error type optFunc func(a *data) error
func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s ShutdownHandler, optFuncs ...optFunc) *http.ServeMux { func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s ShutdownHandler, optFuncs ...optFunc) http.Handler {
m := http.NewServeMux() m := http.NewServeMux()
d := data{ d := data{
@@ -92,37 +92,149 @@ func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s ShutdownHand
} }
} }
m.HandleFunc("/mr/approve", withMr(mergeRequestApproverService{d, gitlabClient}, d, gitlabClient)) m.HandleFunc("/mr/approve", middleware(
m.HandleFunc("/mr/comment", withMr(commentService{d, gitlabClient}, d, gitlabClient)) mergeRequestApproverService{d, gitlabClient}, // These functions are called from bottom to top...
m.HandleFunc("/mr/merge", withMr(mergeRequestAccepterService{d, gitlabClient}, d, gitlabClient)) withMr(d, gitlabClient),
m.HandleFunc("/mr/discussions/list", withMr(discussionsListerService{d, gitlabClient}, d, gitlabClient)) withMethodCheck(http.MethodPost),
m.HandleFunc("/mr/discussions/resolve", withMr(discussionsResolutionService{d, gitlabClient}, d, gitlabClient)) ))
m.HandleFunc("/mr/info", withMr(infoService{d, gitlabClient}, d, gitlabClient)) m.HandleFunc("/mr/comment", middleware(
m.HandleFunc("/mr/assignee", withMr(assigneesService{d, gitlabClient}, d, gitlabClient)) commentService{d, gitlabClient},
m.HandleFunc("/mr/summary", withMr(summaryService{d, gitlabClient}, d, gitlabClient)) withMr(d, gitlabClient),
m.HandleFunc("/mr/reviewer", withMr(reviewerService{d, gitlabClient}, d, gitlabClient)) withPayloadValidation(methodToPayload{
m.HandleFunc("/mr/revisions", withMr(revisionsService{d, gitlabClient}, d, gitlabClient)) http.MethodPost: &PostCommentRequest{},
m.HandleFunc("/mr/reply", withMr(replyService{d, gitlabClient}, d, gitlabClient)) http.MethodDelete: &DeleteCommentRequest{},
m.HandleFunc("/mr/label", withMr(labelService{d, gitlabClient}, d, gitlabClient)) http.MethodPatch: &EditCommentRequest{},
m.HandleFunc("/mr/revoke", withMr(mergeRequestRevokerService{d, gitlabClient}, d, gitlabClient)) }),
m.HandleFunc("/mr/awardable/note/", withMr(emojiService{d, gitlabClient}, d, gitlabClient)) withMethodCheck(http.MethodPost, http.MethodDelete, http.MethodPatch),
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("/mr/merge", middleware(
mergeRequestAccepterService{d, gitlabClient},
withMr(d, gitlabClient),
withPayloadValidation(methodToPayload{http.MethodPost: &AcceptMergeRequestRequest{}}),
withMethodCheck(http.MethodPost),
))
m.HandleFunc("/mr/discussions/list", middleware(
discussionsListerService{d, gitlabClient},
withMr(d, gitlabClient),
withPayloadValidation(methodToPayload{http.MethodPost: &DiscussionsRequest{}}),
withMethodCheck(http.MethodPost),
))
m.HandleFunc("/mr/discussions/resolve", middleware(
discussionsResolutionService{d, gitlabClient},
withMr(d, gitlabClient),
withPayloadValidation(methodToPayload{http.MethodPut: &DiscussionResolveRequest{}}),
withMethodCheck(http.MethodPut),
))
m.HandleFunc("/mr/info", middleware(
infoService{d, gitlabClient},
withMr(d, gitlabClient),
withMethodCheck(http.MethodGet),
))
m.HandleFunc("/mr/assignee", middleware(
assigneesService{d, gitlabClient},
withMr(d, gitlabClient),
withPayloadValidation(methodToPayload{http.MethodPut: &AssigneeUpdateRequest{}}),
withMethodCheck(http.MethodPut),
))
m.HandleFunc("/mr/summary", middleware(
summaryService{d, gitlabClient},
withMr(d, gitlabClient),
withPayloadValidation(methodToPayload{http.MethodPut: &SummaryUpdateRequest{}}),
withMethodCheck(http.MethodPut),
))
m.HandleFunc("/mr/reviewer", middleware(
reviewerService{d, gitlabClient},
withMr(d, gitlabClient),
withPayloadValidation(methodToPayload{http.MethodPut: &ReviewerUpdateRequest{}}),
withMethodCheck(http.MethodPut),
))
m.HandleFunc("/mr/revisions", middleware(
revisionsService{d, gitlabClient},
withMr(d, gitlabClient),
withMethodCheck(http.MethodGet),
))
m.HandleFunc("/mr/reply", middleware(
replyService{d, gitlabClient},
withMr(d, gitlabClient),
withPayloadValidation(methodToPayload{http.MethodPost: &ReplyRequest{}}),
withMethodCheck(http.MethodPost),
))
m.HandleFunc("/mr/label", middleware(
labelService{d, gitlabClient},
withMr(d, gitlabClient),
))
m.HandleFunc("/mr/revoke", middleware(
mergeRequestRevokerService{d, gitlabClient},
withMethodCheck(http.MethodPost),
withMr(d, gitlabClient),
))
m.HandleFunc("/mr/awardable/note/", middleware(
emojiService{d, gitlabClient},
withMethodCheck(http.MethodPost, http.MethodDelete),
withMr(d, gitlabClient),
))
m.HandleFunc("/mr/draft_notes/", middleware(
draftNoteService{d, gitlabClient},
withMr(d, gitlabClient),
withPayloadValidation(methodToPayload{
http.MethodPost: &PostDraftNoteRequest{},
http.MethodPatch: &UpdateDraftNoteRequest{},
}),
withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete),
))
m.HandleFunc("/mr/draft_notes/publish", middleware(
draftNotePublisherService{d, gitlabClient},
withMr(d, gitlabClient),
withPayloadValidation(methodToPayload{http.MethodPost: &DraftNotePublishRequest{}}),
withMethodCheck(http.MethodPost),
))
m.HandleFunc("/pipeline", pipelineService{d, gitlabClient, git.Git{}}.handler) m.HandleFunc("/pipeline", middleware(
m.HandleFunc("/pipeline/trigger/", pipelineService{d, gitlabClient, git.Git{}}.handler) pipelineService{d, gitlabClient, git.Git{}},
m.HandleFunc("/users/me", meService{d, gitlabClient}.handler) withMethodCheck(http.MethodGet),
m.HandleFunc("/attachment", attachmentService{data: d, client: gitlabClient, fileReader: attachmentReader{}}.handler) ))
m.HandleFunc("/create_mr", mergeRequestCreatorService{d, gitlabClient}.handler) m.HandleFunc("/pipeline/trigger/", middleware(
m.HandleFunc("/job", traceFileService{d, gitlabClient}.handler) pipelineService{d, gitlabClient, git.Git{}},
m.HandleFunc("/project/members", projectMemberService{d, gitlabClient}.handler) withMethodCheck(http.MethodPost),
m.HandleFunc("/merge_requests", mergeRequestListerService{d, gitlabClient}.handler) ))
m.HandleFunc("/merge_requests_by_username", mergeRequestListerByUsernameService{d, gitlabClient}.handler) m.HandleFunc("/users/me", middleware(
meService{d, gitlabClient},
withMethodCheck(http.MethodGet),
))
m.HandleFunc("/attachment", middleware(
attachmentService{data: d, client: gitlabClient, fileReader: attachmentReader{}},
withPayloadValidation(methodToPayload{http.MethodPost: &AttachmentRequest{}}),
withMethodCheck(http.MethodPost),
))
m.HandleFunc("/create_mr", middleware(
mergeRequestCreatorService{d, gitlabClient},
withPayloadValidation(methodToPayload{http.MethodPost: &CreateMrRequest{}}),
withMethodCheck(http.MethodPost),
))
m.HandleFunc("/job", middleware(
traceFileService{d, gitlabClient},
withPayloadValidation(methodToPayload{http.MethodGet: &JobTraceRequest{}}),
withMethodCheck(http.MethodGet),
))
m.HandleFunc("/project/members", middleware(
projectMemberService{d, gitlabClient},
withMethodCheck(http.MethodGet),
))
m.HandleFunc("/merge_requests", middleware(
mergeRequestListerService{d, gitlabClient},
withPayloadValidation(methodToPayload{http.MethodPost: &gitlab.ListProjectMergeRequestsOptions{}}), // TODO: How to validate external object
withMethodCheck(http.MethodPost),
))
m.HandleFunc("/merge_requests_by_username", middleware(
mergeRequestListerByUsernameService{d, gitlabClient},
withPayloadValidation(methodToPayload{http.MethodPost: &MergeRequestByUsernameRequest{}}),
withMethodCheck(http.MethodPost),
))
m.HandleFunc("/shutdown", s.shutdownHandler) m.HandleFunc("/shutdown", s.shutdownHandler)
m.Handle("/ping", http.HandlerFunc(pingHandler)) m.Handle("/ping", http.HandlerFunc(pingHandler))
return m return LoggingServer{handler: m}
} }
/* Used to check whether the server has started yet */ /* Used to check whether the server has started yet */
@@ -155,45 +267,3 @@ func createListener() (l net.Listener) {
return l 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)
}
}

View File

@@ -69,10 +69,7 @@ func (s shutdown) shutdownHandler(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := SuccessResponse{ response := SuccessResponse{Message: text}
Message: text,
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)
if err != nil { if err != nil {

View File

@@ -2,15 +2,14 @@ package app
import ( import (
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"github.com/xanzy/go-gitlab" "github.com/xanzy/go-gitlab"
) )
type SummaryUpdateRequest struct { type SummaryUpdateRequest struct {
Title string `json:"title" validate:"required"`
Description string `json:"description"` Description string `json:"description"`
Title string `json:"title"`
} }
type SummaryUpdateResponse struct { type SummaryUpdateResponse struct {
@@ -23,33 +22,13 @@ type summaryService struct {
client MergeRequestUpdater client MergeRequestUpdater
} }
func (a summaryService) handler(w http.ResponseWriter, r *http.Request) { func (a summaryService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPut { payload := r.Context().Value(payload("payload")).(*SummaryUpdateRequest)
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{ mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{
Description: &SummaryUpdateRequest.Description, Description: &payload.Description,
Title: &SummaryUpdateRequest.Title, Title: &payload.Title,
}) })
if err != nil { if err != nil {
@@ -58,18 +37,15 @@ func (a summaryService) handler(w http.ResponseWriter, r *http.Request) {
} }
if res.StatusCode >= 300 { if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/summary"}, "Could not edit merge request summary", res.StatusCode) handleError(w, GenericError{r.URL.Path}, "Could not edit merge request summary", res.StatusCode)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response := SummaryUpdateResponse{ response := SummaryUpdateResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "Summary updated"},
Message: "Summary updated", MergeRequest: mr,
Status: http.StatusOK,
},
MergeRequest: mr,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)

View File

@@ -8,7 +8,6 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/harrisoncramer/gitlab.nvim/cmd/app/git" "github.com/harrisoncramer/gitlab.nvim/cmd/app/git"
@@ -63,9 +62,9 @@ var testProjectData = data{
}, },
} }
func getSuccessData(t *testing.T, svc ServiceWithHandler, request *http.Request) SuccessResponse { func getSuccessData(t *testing.T, svc http.Handler, request *http.Request) SuccessResponse {
res := httptest.NewRecorder() res := httptest.NewRecorder()
svc.handler(res, request) svc.ServeHTTP(res, request)
var data SuccessResponse var data SuccessResponse
err := json.Unmarshal(res.Body.Bytes(), &data) err := json.Unmarshal(res.Body.Bytes(), &data)
@@ -75,16 +74,16 @@ func getSuccessData(t *testing.T, svc ServiceWithHandler, request *http.Request)
return data return data
} }
func getFailData(t *testing.T, svc ServiceWithHandler, request *http.Request) ErrorResponse { func getFailData(t *testing.T, svc http.Handler, request *http.Request) (errResponse ErrorResponse, status int) {
res := httptest.NewRecorder() res := httptest.NewRecorder()
svc.handler(res, request) svc.ServeHTTP(res, request)
var data ErrorResponse var data ErrorResponse
err := json.Unmarshal(res.Body.Bytes(), &data) err := json.Unmarshal(res.Body.Bytes(), &data)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
return data return data, res.Result().StatusCode
} }
type testBase struct { type testBase struct {
@@ -105,22 +104,12 @@ func (f *testBase) handleGitlabError() (*gitlab.Response, error) {
func checkErrorFromGitlab(t *testing.T, data ErrorResponse, msg string) { func checkErrorFromGitlab(t *testing.T, data ErrorResponse, msg string) {
t.Helper() t.Helper()
assert(t, data.Status, http.StatusInternalServerError)
assert(t, data.Message, msg) assert(t, data.Message, msg)
assert(t, data.Details, errorFromGitlab.Error()) 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) { func checkNon200(t *testing.T, data ErrorResponse, msg, endpoint string) {
t.Helper() t.Helper()
assert(t, data.Status, http.StatusSeeOther)
assert(t, data.Message, msg) assert(t, data.Message, msg)
assert(t, data.Details, fmt.Sprintf("An error occurred on the %s endpoint", endpoint)) assert(t, data.Details, fmt.Sprintf("An error occurred on the %s endpoint", endpoint))
} }

View File

@@ -21,13 +21,7 @@ type meService struct {
client MeGetter client MeGetter
} }
func (a meService) handler(w http.ResponseWriter, r *http.Request) { func (a meService) ServeHTTP(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() user, res, err := a.client.CurrentUser()
@@ -42,11 +36,8 @@ func (a meService) handler(w http.ResponseWriter, r *http.Request) {
} }
response := UserResponse{ response := UserResponse{
SuccessResponse: SuccessResponse{ SuccessResponse: SuccessResponse{Message: "User fetched successfully"},
Message: "User fetched successfully", User: user,
Status: http.StatusOK,
},
User: user,
} }
err = json.NewEncoder(w).Encode(response) err = json.NewEncoder(w).Encode(response)

View File

@@ -152,7 +152,12 @@ you call this function with no values the defaults will be used:
port = nil, -- The port of the Go server, which runs in the background, if omitted or `nil` the port will be chosen automatically port = nil, -- The port of the Go server, which runs in the background, if omitted or `nil` the port will be chosen automatically
log_path = vim.fn.stdpath("cache") .. "/gitlab.nvim.log", -- Log path for the Go server log_path = vim.fn.stdpath("cache") .. "/gitlab.nvim.log", -- Log path for the Go server
config_path = nil, -- Custom path for `.gitlab.nvim` file, please read the "Connecting to Gitlab" section config_path = nil, -- Custom path for `.gitlab.nvim` file, please read the "Connecting to Gitlab" section
debug = { go_request = false, go_response = false }, -- Which values to log debug = {
request = false, -- Requests to/from Go server
response = false,
gitlab_request = false, -- Requests to/from Gitlab
gitlab_response = false,
},
attachment_dir = nil, -- The local directory for files (see the "summary" section) attachment_dir = nil, -- The local directory for files (see the "summary" section)
reviewer_settings = { reviewer_settings = {
jump_with_no_diagnostics = false, -- Jump to last position in discussion tree if true, otherwise stay in reviewer and show warning. jump_with_no_diagnostics = false, -- Jump to last position in discussion tree if true, otherwise stay in reviewer and show warning.

10
go.mod
View File

@@ -3,16 +3,24 @@ module github.com/harrisoncramer/gitlab.nvim
go 1.19 go 1.19
require ( require (
github.com/go-playground/validator/v10 v10.22.1
github.com/hashicorp/go-retryablehttp v0.7.7 github.com/hashicorp/go-retryablehttp v0.7.7
github.com/xanzy/go-gitlab v0.108.0 github.com/xanzy/go-gitlab v0.108.0
) )
require ( require (
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang/protobuf v1.5.3 // indirect github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
golang.org/x/net v0.8.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
golang.org/x/crypto v0.19.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/oauth2 v0.6.0 // indirect golang.org/x/oauth2 v0.6.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.3.0 // indirect golang.org/x/time v0.3.0 // indirect
google.golang.org/appengine v1.6.7 // indirect google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.29.1 // indirect google.golang.org/protobuf v1.29.1 // indirect

22
go.sum
View File

@@ -1,5 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
@@ -14,22 +23,29 @@ github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/S
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/xanzy/go-gitlab v0.108.0 h1:IEvEUWFR5G1seslRhJ8gC//INiIUqYXuSUoBd7/gFKE= github.com/xanzy/go-gitlab v0.108.0 h1:IEvEUWFR5G1seslRhJ8gC//INiIUqYXuSUoBd7/gFKE=
github.com/xanzy/go-gitlab v0.108.0/go.mod h1:wKNKh3GkYDMOsGmnfuX+ITCmDuSDWFO0G+C4AygL9RY= github.com/xanzy/go-gitlab v0.108.0/go.mod h1:wKNKh3GkYDMOsGmnfuX+ITCmDuSDWFO0G+C4AygL9RY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw=
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -3,6 +3,7 @@
--- to this module the data required to make the API calls --- to this module the data required to make the API calls
local Popup = require("nui.popup") local Popup = require("nui.popup")
local Layout = require("nui.layout") local Layout = require("nui.layout")
local diffview_lib = require("diffview.lib")
local state = require("gitlab.state") local state = require("gitlab.state")
local job = require("gitlab.job") local job = require("gitlab.job")
local u = require("gitlab.utils") local u = require("gitlab.utils")
@@ -153,17 +154,45 @@ end
---@class LayoutOpts ---@class LayoutOpts
---@field ranged boolean ---@field ranged boolean
---@field discussion_id string|nil
---@field unlinked boolean ---@field unlinked boolean
---@field discussion_id string|nil
---This function sets up the layout and popups needed to create a comment, note and ---This function sets up the layout and popups needed to create a comment, note and
---multi-line comment. It also sets up the basic keybindings for switching between ---multi-line comment. It also sets up the basic keybindings for switching between
---window panes, and for the non-primary sections. ---window panes, and for the non-primary sections.
---@param opts LayoutOpts|nil ---@param opts LayoutOpts
---@return NuiLayout ---@return NuiLayout|nil
M.create_comment_layout = function(opts) M.create_comment_layout = function(opts)
if opts == nil then if opts.unlinked ~= true then
opts = {} -- Check that diffview is initialized
if reviewer.tabnr == nil then
u.notify("Reviewer must be initialized first", vim.log.levels.ERROR)
return
end
-- Check that Diffview is the current view
local view = diffview_lib.get_current_view()
if view == nil then
u.notify("Comments should be left in the reviewer pane", vim.log.levels.ERROR)
return
end
-- Check that we are in the diffview tab
local tabnr = vim.api.nvim_get_current_tabpage()
if tabnr ~= reviewer.tabnr then
u.notify("Line location can only be determined within reviewer window", vim.log.levels.ERROR)
return
end
-- Check that we are hovering over the code
local filetype = vim.bo[0].filetype
if filetype == "DiffviewFiles" or filetype == "gitlab" then
u.notify(
"Comments can only be left on the code. To leave unlinked comments, use gitlab.create_note() instead",
vim.log.levels.ERROR
)
return
end
end end
local title = opts.discussion_id and "Reply" or "Comment" local title = opts.discussion_id and "Reply" or "Comment"
@@ -229,7 +258,8 @@ M.create_comment = function()
if err ~= nil then if err ~= nil then
return return
end end
local is_modified = vim.api.nvim_buf_get_option(0, "modified")
local is_modified = vim.bo[0].modified
if state.settings.reviewer_settings.diffview.imply_local and (is_modified or not has_clean_tree) then if state.settings.reviewer_settings.diffview.imply_local and (is_modified or not has_clean_tree) then
u.notify( u.notify(
"Cannot leave comments on changed files. \n Please stash all local changes or push them to the feature branch.", "Cannot leave comments on changed files. \n Please stash all local changes or push them to the feature branch.",
@@ -243,7 +273,9 @@ M.create_comment = function()
end end
local layout = M.create_comment_layout({ ranged = false, unlinked = false }) local layout = M.create_comment_layout({ ranged = false, unlinked = false })
layout:mount() if layout ~= nil then
layout:mount()
end
end end
--- This function will open a multi-line comment popup in order to create a multi-line comment --- This function will open a multi-line comment popup in order to create a multi-line comment
@@ -257,14 +289,18 @@ M.create_multiline_comment = function()
end end
local layout = M.create_comment_layout({ ranged = true, unlinked = false }) local layout = M.create_comment_layout({ ranged = true, unlinked = false })
layout:mount() if layout ~= nil then
layout:mount()
end
end end
--- This function will open a a popup to create a "note" (e.g. unlinked comment) --- This function will open a a popup to create a "note" (e.g. unlinked comment)
--- on the changed/updated line in the current MR --- on the changed/updated line in the current MR
M.create_note = function() M.create_note = function()
local layout = M.create_comment_layout({ ranged = false, unlinked = true }) local layout = M.create_comment_layout({ ranged = false, unlinked = true })
layout:mount() if layout ~= nil then
layout:mount()
end
end end
---Given the current visually selected area of text, builds text to fill in the ---Given the current visually selected area of text, builds text to fill in the
@@ -319,7 +355,9 @@ M.create_comment_suggestion = function()
local suggestion_lines, range_length = build_suggestion() local suggestion_lines, range_length = build_suggestion()
local layout = M.create_comment_layout({ ranged = range_length > 0, unlinked = false }) local layout = M.create_comment_layout({ ranged = range_length > 0, unlinked = false })
layout:mount() if layout ~= nil then
layout:mount()
end
vim.schedule(function() vim.schedule(function()
if suggestion_lines then if suggestion_lines then
vim.api.nvim_buf_set_lines(M.comment_popup.bufnr, 0, -1, false, suggestion_lines) vim.api.nvim_buf_set_lines(M.comment_popup.bufnr, 0, -1, false, suggestion_lines)

View File

@@ -84,7 +84,7 @@ end
---Publishes all draft notes and comments. Re-renders all discussion views. ---Publishes all draft notes and comments. Re-renders all discussion views.
M.confirm_publish_all_drafts = function() M.confirm_publish_all_drafts = function()
local body = { publish_all = true } local body = {}
job.run_job("/mr/draft_notes/publish", "POST", body, function(data) job.run_job("/mr/draft_notes/publish", "POST", body, function(data)
u.notify(data.message, vim.log.levels.INFO) u.notify(data.message, vim.log.levels.INFO)
state.DRAFT_NOTES = {} state.DRAFT_NOTES = {}
@@ -109,7 +109,7 @@ M.confirm_publish_draft = function(tree)
---@type integer ---@type integer
local note_id = note_node.is_root and root_node.id or note_node.id local note_id = note_node.is_root and root_node.id or note_node.id
local body = { note = note_id, publish_all = false } local body = { note = note_id }
job.run_job("/mr/draft_notes/publish", "POST", body, function(data) job.run_job("/mr/draft_notes/publish", "POST", body, function(data)
u.notify(data.message, vim.log.levels.INFO) u.notify(data.message, vim.log.levels.INFO)

View File

@@ -226,6 +226,8 @@
---@class DebugSettings: table ---@class DebugSettings: table
---@field go_request? boolean -- Log the requests to Gitlab sent by the Go server ---@field go_request? boolean -- Log the requests to Gitlab sent by the Go server
---@field go_response? boolean -- Log the responses received from Gitlab to the Go server ---@field go_response? boolean -- Log the responses received from Gitlab to the Go server
---@field request? boolean -- Log the requests to the Go server
---@field response? boolean -- Log the responses from the Go server
---@class PopupSettings: table ---@class PopupSettings: table
---@field width? string -- The width of the popup, by default "40%" ---@field width? string -- The width of the popup, by default "40%"

View File

@@ -26,6 +26,8 @@ M.run_job = function(endpoint, method, body, callback)
return return
end end
local data_ok, data = pcall(vim.json.decode, output) local data_ok, data = pcall(vim.json.decode, output)
-- Failing to unmarshal JSON
if not data_ok then if not data_ok then
local msg = string.format("Failed to parse JSON from %s endpoint", endpoint) local msg = string.format("Failed to parse JSON from %s endpoint", endpoint)
if type(output) == "string" then if type(output) == "string" then
@@ -34,17 +36,22 @@ M.run_job = function(endpoint, method, body, callback)
u.notify(string.format(msg, endpoint, output), vim.log.levels.WARN) u.notify(string.format(msg, endpoint, output), vim.log.levels.WARN)
return return
end end
-- If JSON provided, handle success or error cases
if data ~= nil then if data ~= nil then
local status = (tonumber(data.status) >= 200 and tonumber(data.status) < 300) and "success" or "error" if data.details == nil then
if status == "success" and callback ~= nil then if callback then
callback(data) callback(data)
elseif status == "success" then return
end
local message = string.format("%s", data.message) local message = string.format("%s", data.message)
u.notify(message, vim.log.levels.INFO) u.notify(message, vim.log.levels.INFO)
else return
local message = string.format("%s: %s", data.message, data.details)
u.notify(message, vim.log.levels.ERROR)
end end
-- Handle error case
local message = string.format("%s: %s", data.message, data.details)
u.notify(message, vim.log.levels.ERROR)
end end
end, 0) end, 0)
end, end,

View File

@@ -67,11 +67,11 @@ M.open = function()
end end
if state.INFO.state == "closed" then if state.INFO.state == "closed" then
u.notify(string.format("This MR was closed on %s", u.format_date(state.INFO.closed_at)), vim.log.levels.WARN) u.notify(string.format("This MR was closed %s", u.time_since(state.INFO.closed_at)), vim.log.levels.WARN)
end end
if state.INFO.state == "merged" then if state.INFO.state == "merged" then
u.notify(string.format("This MR was merged on %s", u.format_date(state.INFO.merged_at)), vim.log.levels.WARN) u.notify(string.format("This MR was merged %s", u.time_since(state.INFO.merged_at)), vim.log.levels.WARN)
end end
if state.settings.discussion_diagnostic ~= nil or state.settings.discussion_sign ~= nil then if state.settings.discussion_diagnostic ~= nil or state.settings.discussion_sign ~= nil then
@@ -151,25 +151,7 @@ end
---other modules such as the comment module to create line codes or set diagnostics ---other modules such as the comment module to create line codes or set diagnostics
---@return DiffviewInfo | nil ---@return DiffviewInfo | nil
M.get_reviewer_data = function() M.get_reviewer_data = function()
if M.tabnr == nil then
u.notify("Diffview reviewer must be initialized first", vim.log.levels.ERROR)
return
end
-- Check if we are in the diffview tab
local tabnr = vim.api.nvim_get_current_tabpage()
if tabnr ~= M.tabnr then
u.notify("Line location can only be determined within reviewer window", vim.log.levels.ERROR)
return
end
-- Check if we are in the diffview buffer
local view = diffview_lib.get_current_view() local view = diffview_lib.get_current_view()
if view == nil then
u.notify("Could not find Diffview view", vim.log.levels.ERROR)
return
end
local layout = view.cur_layout local layout = view.cur_layout
local old_win = u.get_window_id_by_buffer_id(layout.a.file.bufnr) local old_win = u.get_window_id_by_buffer_id(layout.a.file.bufnr)
local new_win = u.get_window_id_by_buffer_id(layout.b.file.bufnr) local new_win = u.get_window_id_by_buffer_id(layout.b.file.bufnr)
@@ -321,7 +303,7 @@ local set_keymaps = function(bufnr, keymaps)
if keymaps.reviewer.create_comment ~= false then if keymaps.reviewer.create_comment ~= false then
-- Set keymap for repeated operator keybinding -- Set keymap for repeated operator keybinding
vim.keymap.set("o", keymaps.reviewer.create_comment, function() vim.keymap.set("o", keymaps.reviewer.create_comment, function()
vim.api.nvim_cmd({ cmd = "normal", bang = true, args = { tostring(vim.v.count1) .. "j" } }, {}) vim.api.nvim_cmd({ cmd = "normal", bang = true, args = { tostring(vim.v.count1) .. "$" } }, {})
end, { end, {
buffer = bufnr, buffer = bufnr,
desc = "Create comment for [count] lines", desc = "Create comment for [count] lines",
@@ -351,7 +333,7 @@ local set_keymaps = function(bufnr, keymaps)
if keymaps.reviewer.create_suggestion ~= false then if keymaps.reviewer.create_suggestion ~= false then
-- Set keymap for repeated operator keybinding -- Set keymap for repeated operator keybinding
vim.keymap.set("o", keymaps.reviewer.create_suggestion, function() vim.keymap.set("o", keymaps.reviewer.create_suggestion, function()
vim.api.nvim_cmd({ cmd = "normal", bang = true, args = { tostring(vim.v.count1) .. "j" } }, {}) vim.api.nvim_cmd({ cmd = "normal", bang = true, args = { tostring(vim.v.count1) .. "$" } }, {})
end, { end, {
buffer = bufnr, buffer = bufnr,
desc = "Create suggestion for [count] lines", desc = "Create suggestion for [count] lines",

View File

@@ -47,8 +47,10 @@ M.settings = {
file_separator = u.path_separator, file_separator = u.path_separator,
port = nil, -- choose random port port = nil, -- choose random port
debug = { debug = {
go_request = false, request = false,
go_response = false, response = false,
gitlab_request = false,
gitlab_response = false,
}, },
log_path = (vim.fn.stdpath("cache") .. "/gitlab.nvim.log"), log_path = (vim.fn.stdpath("cache") .. "/gitlab.nvim.log"),
config_path = nil, config_path = nil,