Fix MR Selection, Go Code Refactor (#358)

refactor: Refactors the Go codebase into a more modular and idiomatic approach
fix: require selection of specific MR when there are multiple targets for a given source branch
feat: Allows for the passing of Gitlab's filter options when choosing an MR, improves MR selection
feat: API to choose an MR from a list based on the provided username's involvement as an assignee/reviewer/author

This is a #MINOR release
This commit is contained in:
Harrison (Harry) Cramer
2024-09-08 16:45:09 -04:00
committed by GitHub
parent 6500ef1f2c
commit ea2b2b2f5c
81 changed files with 2559 additions and 3035 deletions

50
cmd/app/approve.go Normal file
View File

@@ -0,0 +1,50 @@
package app
import (
"encoding/json"
"net/http"
"github.com/xanzy/go-gitlab"
)
type MergeRequestApprover interface {
ApproveMergeRequest(pid interface{}, mr int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error)
}
type mergeRequestApproverService struct {
data
client MergeRequestApprover
}
/* approveHandler approves a merge request. */
func (a mergeRequestApproverService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPost {
w.Header().Set("Access-Control-Allow-Methods", http.MethodPost)
handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed)
return
}
_, res, err := a.client.ApproveMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, nil, nil)
if err != nil {
handleError(w, err, "Could not approve merge request", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/approve"}, "Could not approve merge request", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := SuccessResponse{
Message: "Approved MR",
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

55
cmd/app/approve_test.go Normal file
View File

@@ -0,0 +1,55 @@
package app
import (
"net/http"
"testing"
"github.com/xanzy/go-gitlab"
)
type fakeApproverClient struct {
testBase
}
func (f fakeApproverClient) ApproveMergeRequest(pid interface{}, mr int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
return &gitlab.MergeRequestApprovals{}, resp, nil
}
func TestApproveHandler(t *testing.T) {
t.Run("Approves merge request", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/approve", nil)
client := fakeApproverClient{}
svc := mergeRequestApproverService{testProjectData, client}
data := getSuccessData(t, svc, request)
assert(t, data.Message, "Approved MR")
assert(t, data.Status, http.StatusOK)
})
t.Run("Disallows non-POST method", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/approve", nil)
client := fakeApproverClient{}
svc := mergeRequestApproverService{testProjectData, client}
data := getFailData(t, svc, request)
checkBadMethod(t, data, http.MethodPost)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/approve", nil)
client := fakeApproverClient{testBase{errFromGitlab: true}}
svc := mergeRequestApproverService{testProjectData, client}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not approve merge request")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/approve", nil)
client := fakeApproverClient{testBase{status: http.StatusSeeOther}}
svc := mergeRequestApproverService{testProjectData, client}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not approve merge request", "/mr/approve")
})
}

81
cmd/app/assignee.go Normal file
View File

@@ -0,0 +1,81 @@
package app
import (
"encoding/json"
"io"
"net/http"
"github.com/xanzy/go-gitlab"
)
type AssigneeUpdateRequest struct {
Ids []int `json:"ids"`
}
type AssigneeUpdateResponse struct {
SuccessResponse
Assignees []*gitlab.BasicUser `json:"assignees"`
}
type AssigneesRequestResponse struct {
SuccessResponse
Assignees []int `json:"assignees"`
}
type assigneesService struct {
data
client MergeRequestUpdater
}
/* assigneesHandler adds or removes assignees from a merge request. */
func (a assigneesService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPut {
w.Header().Set("Access-Control-Allow-Methods", http.MethodPut)
handleError(w, InvalidRequestError{}, "Expected PUT", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var assigneeUpdateRequest AssigneeUpdateRequest
err = json.Unmarshal(body, &assigneeUpdateRequest)
if err != nil {
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
return
}
mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{
AssigneeIDs: &assigneeUpdateRequest.Ids,
})
if err != nil {
handleError(w, err, "Could not modify merge request assignees", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/assignee"}, "Could not modify merge request assignees", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := AssigneeUpdateResponse{
SuccessResponse: SuccessResponse{
Message: "Assignees updated",
Status: http.StatusOK,
},
Assignees: mr.Assignees,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

57
cmd/app/assignee_test.go Normal file
View File

@@ -0,0 +1,57 @@
package app
import (
"net/http"
"testing"
"github.com/xanzy/go-gitlab"
)
type fakeAssigneeClient struct {
testBase
}
func (f fakeAssigneeClient) UpdateMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
return &gitlab.MergeRequest{}, resp, nil
}
func TestAssigneeHandler(t *testing.T) {
var updatePayload = AssigneeUpdateRequest{Ids: []int{1, 2}}
t.Run("Updates assignees", func(t *testing.T) {
request := makeRequest(t, http.MethodPut, "/mr/assignee", updatePayload)
client := fakeAssigneeClient{}
svc := assigneesService{testProjectData, client}
data := getSuccessData(t, svc, request)
assert(t, data.Message, "Assignees updated")
assert(t, data.Status, http.StatusOK)
})
t.Run("Disallows non-PUT method", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/assignee", nil)
client := fakeAssigneeClient{}
svc := assigneesService{testProjectData, client}
data := getFailData(t, svc, request)
checkBadMethod(t, data, http.MethodPut)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPut, "/mr/approve", updatePayload)
client := fakeAssigneeClient{testBase{errFromGitlab: true}}
svc := assigneesService{testProjectData, client}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not modify merge request assignees")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPut, "/mr/approve", updatePayload)
client := fakeAssigneeClient{testBase{status: http.StatusSeeOther}}
svc := assigneesService{testProjectData, client}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not modify merge request assignees", "/mr/assignee")
})
}

116
cmd/app/attachment.go Normal file
View File

@@ -0,0 +1,116 @@
package app
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"github.com/xanzy/go-gitlab"
)
type FileReader interface {
ReadFile(path string) (io.Reader, error)
}
type AttachmentRequest struct {
FilePath string `json:"file_path"`
FileName string `json:"file_name"`
}
type AttachmentResponse struct {
SuccessResponse
Markdown string `json:"markdown"`
Alt string `json:"alt"`
Url string `json:"url"`
}
type attachmentReader struct{}
func (ar attachmentReader) ReadFile(path string) (io.Reader, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
data, err := io.ReadAll(file)
if err != nil {
return nil, err
}
defer file.Close()
reader := bytes.NewReader(data)
return reader, nil
}
type FileUploader interface {
UploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error)
}
type attachmentService struct {
data
fileReader FileReader
client FileUploader
}
/* attachmentHandler uploads an attachment (file, image, etc) to Gitlab and returns metadata about the upload. */
func (a attachmentService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPost {
w.Header().Set("Access-Control-Allow-Methods", http.MethodPost)
handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed)
return
}
var attachmentRequest AttachmentRequest
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
err = json.Unmarshal(body, &attachmentRequest)
if err != nil {
handleError(w, err, "Could not unmarshal JSON", http.StatusBadRequest)
return
}
file, err := a.fileReader.ReadFile(attachmentRequest.FilePath)
if err != nil || file == nil {
handleError(w, err, fmt.Sprintf("Could not read %s file", attachmentRequest.FileName), http.StatusInternalServerError)
return
}
projectFile, res, err := a.client.UploadFile(a.projectInfo.ProjectId, file, attachmentRequest.FileName)
if err != nil {
handleError(w, err, fmt.Sprintf("Could not upload %s to Gitlab", attachmentRequest.FileName), http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/attachment"}, fmt.Sprintf("Could not upload %s to Gitlab", attachmentRequest.FileName), res.StatusCode)
return
}
response := AttachmentResponse{
SuccessResponse: SuccessResponse{
Status: http.StatusOK,
Message: "File uploaded successfully",
},
Markdown: projectFile.Markdown,
Alt: projectFile.Alt,
Url: projectFile.URL,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,64 @@
package app
import (
"bytes"
"io"
"net/http"
"testing"
"github.com/xanzy/go-gitlab"
)
type fakeFileUploaderClient struct {
testBase
}
func (f fakeFileUploaderClient) UploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
return &gitlab.ProjectFile{}, resp, nil
}
type fakeFileReader struct{}
func (f fakeFileReader) ReadFile(path string) (io.Reader, error) {
return &bytes.Reader{}, nil
}
func TestAttachmentHandler(t *testing.T) {
attachmentTestRequestData := AttachmentRequest{
FileName: "some_file_name",
FilePath: "some_file_path",
}
t.Run("Returns 200-status response after upload", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData)
svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{}}
data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "File uploaded successfully")
})
t.Run("Disallows non-POST method", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/attachment", nil)
svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{}}
data := getFailData(t, svc, request)
checkBadMethod(t, data, http.MethodPost)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData)
svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not upload some_file_name to Gitlab")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData)
svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not upload some_file_name to Gitlab", "/attachment")
})
}

175
cmd/app/client.go Normal file
View File

@@ -0,0 +1,175 @@
package app
import (
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/http/httputil"
"os"
"github.com/harrisoncramer/gitlab.nvim/cmd/app/git"
"github.com/hashicorp/go-retryablehttp"
"github.com/xanzy/go-gitlab"
)
type ProjectInfo struct {
ProjectId string
MergeId int
}
/* The Client struct embeds all the methods from Gitlab for the different services */
type Client struct {
*gitlab.MergeRequestsService
*gitlab.MergeRequestApprovalsService
*gitlab.DiscussionsService
*gitlab.ProjectsService
*gitlab.ProjectMembersService
*gitlab.JobsService
*gitlab.PipelinesService
*gitlab.LabelsService
*gitlab.AwardEmojiService
*gitlab.UsersService
*gitlab.DraftNotesService
}
/* NewClient parses and validates the project settings and initializes the Gitlab client. */
func NewClient() (error, *Client) {
if pluginOptions.GitlabUrl == "" {
return errors.New("GitLab instance URL cannot be empty"), nil
}
var apiCustUrl = fmt.Sprintf(pluginOptions.GitlabUrl + "/api/v4")
gitlabOptions := []gitlab.ClientOptionFunc{
gitlab.WithBaseURL(apiCustUrl),
}
if pluginOptions.Debug.Request {
gitlabOptions = append(gitlabOptions, gitlab.WithRequestLogHook(requestLogger))
}
if pluginOptions.Debug.Response {
gitlabOptions = append(gitlabOptions, gitlab.WithResponseLogHook(responseLogger))
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: pluginOptions.ConnectionSettings.Insecure,
},
}
retryClient := retryablehttp.NewClient()
retryClient.HTTPClient.Transport = tr
gitlabOptions = append(gitlabOptions, gitlab.WithHTTPClient(retryClient.HTTPClient))
client, err := gitlab.NewClient(pluginOptions.AuthToken, gitlabOptions...)
if err != nil {
return fmt.Errorf("Failed to create client: %v", err), nil
}
return nil, &Client{
MergeRequestsService: client.MergeRequests,
MergeRequestApprovalsService: client.MergeRequestApprovals,
DiscussionsService: client.Discussions,
ProjectsService: client.Projects,
ProjectMembersService: client.ProjectMembers,
JobsService: client.Jobs,
PipelinesService: client.Pipelines,
LabelsService: client.Labels,
AwardEmojiService: client.AwardEmoji,
UsersService: client.Users,
DraftNotesService: client.DraftNotes,
}
}
/* InitProjectSettings fetch the project ID using the client */
func InitProjectSettings(c *Client, gitInfo git.GitData) (error, *ProjectInfo) {
opt := gitlab.GetProjectOptions{}
project, _, err := c.GetProject(gitInfo.ProjectPath(), &opt)
if err != nil {
return fmt.Errorf(fmt.Sprintf("Error getting project at %s", gitInfo.RemoteUrl), err), nil
}
if project == nil {
return fmt.Errorf(fmt.Sprintf("Could not find project at %s", gitInfo.RemoteUrl), err), nil
}
projectId := fmt.Sprint(project.ID)
return nil, &ProjectInfo{
ProjectId: projectId,
}
}
/* handleError is a utililty handler that returns errors to the client along with their statuses and messages */
func handleError(w http.ResponseWriter, err error, message string, status int) {
w.WriteHeader(status)
response := ErrorResponse{
Message: message,
Details: err.Error(),
Status: status,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode error response", http.StatusInternalServerError)
}
}
var requestLogger retryablehttp.RequestLogHook = func(l retryablehttp.Logger, r *http.Request, i int) {
file := openLogFile()
defer file.Close()
token := r.Header.Get("Private-Token")
r.Header.Set("Private-Token", "REDACTED")
res, err := httputil.DumpRequest(r, true)
if err != nil {
log.Fatalf("Error dumping request: %v", err)
os.Exit(1)
}
r.Header.Set("Private-Token", token)
_, err = file.Write([]byte("\n-- REQUEST --\n")) //nolint:all
_, err = file.Write(res) //nolint:all
_, err = file.Write([]byte("\n")) //nolint:all
}
var responseLogger retryablehttp.ResponseLogHook = func(l retryablehttp.Logger, response *http.Response) {
file := openLogFile()
defer file.Close()
res, err := httputil.DumpResponse(response, true)
if err != nil {
log.Fatalf("Error dumping response: %v", err)
os.Exit(1)
}
_, err = file.Write([]byte("\n-- RESPONSE --\n")) //nolint:all
_, err = file.Write(res) //nolint:all
_, err = file.Write([]byte("\n")) //nolint:all
}
func openLogFile() *os.File {
file, err := os.OpenFile(pluginOptions.LogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
if os.IsNotExist(err) {
log.Printf("Log file %s does not exist", pluginOptions.LogPath)
} else if os.IsPermission(err) {
log.Printf("Permission denied for log file %s", pluginOptions.LogPath)
} else {
log.Printf("Error opening log file %s: %v", pluginOptions.LogPath, err)
}
os.Exit(1)
}
return file
}

215
cmd/app/comment.go Normal file
View File

@@ -0,0 +1,215 @@
package app
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/xanzy/go-gitlab"
)
type PostCommentRequest struct {
Comment string `json:"comment"`
PositionData
}
type DeleteCommentRequest struct {
NoteId int `json:"note_id"`
DiscussionId string `json:"discussion_id"`
}
type EditCommentRequest struct {
Comment string `json:"comment"`
NoteId int `json:"note_id"`
DiscussionId string `json:"discussion_id"`
Resolved bool `json:"resolved"`
}
type CommentResponse struct {
SuccessResponse
Comment *gitlab.Note `json:"note"`
Discussion *gitlab.Discussion `json:"discussion"`
}
/* CommentWithPosition is a comment with an (optional) position data value embedded in it. The position data will be non-nil for range-based comments. */
type CommentWithPosition struct {
PositionData PositionData
}
func (comment CommentWithPosition) GetPositionData() PositionData {
return comment.PositionData
}
type CommentManager interface {
CreateMergeRequestDiscussion(pid interface{}, mergeRequest int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error)
UpdateMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error)
DeleteMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
}
type commentService struct {
data
client CommentManager
}
/* commentHandler creates, edits, and deletes discussions (comments, multi-line comments) */
func (a commentService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodPost:
a.postComment(w, r)
case http.MethodPatch:
a.editComment(w, r)
case http.MethodDelete:
a.deleteComment(w, r)
default:
w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s, %s", http.MethodDelete, http.MethodPost, http.MethodPatch))
handleError(w, InvalidRequestError{}, "Expected DELETE, POST or PATCH", http.StatusMethodNotAllowed)
}
}
/* deleteComment deletes a note, multiline comment, or comment, which are all considered discussion notes. */
func (a commentService) deleteComment(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var deleteCommentRequest DeleteCommentRequest
err = json.Unmarshal(body, &deleteCommentRequest)
if err != nil {
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
return
}
res, err := a.client.DeleteMergeRequestDiscussionNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, deleteCommentRequest.DiscussionId, deleteCommentRequest.NoteId)
if err != nil {
handleError(w, err, "Could not delete comment", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/comment"}, "Could not delete comment", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := SuccessResponse{
Message: "Comment deleted successfully",
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}
/* postComment creates a note, multiline comment, or comment. */
func (a commentService) postComment(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var postCommentRequest PostCommentRequest
err = json.Unmarshal(body, &postCommentRequest)
if err != nil {
handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest)
return
}
opt := gitlab.CreateMergeRequestDiscussionOptions{
Body: &postCommentRequest.Comment,
}
/* If we are leaving a comment on a line, leave position. Otherwise,
we are leaving a note (unlinked comment) */
if postCommentRequest.FileName != "" {
commentWithPositionData := CommentWithPosition{postCommentRequest.PositionData}
opt.Position = buildCommentPosition(commentWithPositionData)
}
discussion, res, err := a.client.CreateMergeRequestDiscussion(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt)
if err != nil {
handleError(w, err, "Could not create discussion", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/comment"}, "Could not create discussion", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := CommentResponse{
SuccessResponse: SuccessResponse{
Message: "Comment created successfully",
Status: http.StatusOK,
},
Comment: discussion.Notes[0],
Discussion: discussion,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}
/* editComment changes the text of a comment or changes it's resolved status. */
func (a commentService) editComment(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var editCommentRequest EditCommentRequest
err = json.Unmarshal(body, &editCommentRequest)
if err != nil {
handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest)
return
}
options := gitlab.UpdateMergeRequestDiscussionNoteOptions{}
options.Body = gitlab.Ptr(editCommentRequest.Comment)
note, res, err := a.client.UpdateMergeRequestDiscussionNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, editCommentRequest.DiscussionId, editCommentRequest.NoteId, &options)
if err != nil {
handleError(w, err, "Could not update comment", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/comment"}, "Could not update comment", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := CommentResponse{
SuccessResponse: SuccessResponse{
Message: "Comment updated successfully",
Status: http.StatusOK,
},
Comment: note,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,82 @@
package app
import (
"crypto/sha1"
"fmt"
"github.com/xanzy/go-gitlab"
)
/* LinePosition represents a position in a line range. Unlike the Gitlab struct, this does not contain LineCode with a sha1 of the filename */
type LinePosition struct {
Type string `json:"type"`
OldLine int `json:"old_line"`
NewLine int `json:"new_line"`
}
/* LineRange represents the range of a note. */
type LineRange struct {
StartRange *LinePosition `json:"start"`
EndRange *LinePosition `json:"end"`
}
/* PositionData represents the position of a comment or note (relative to a file diff) */
type PositionData struct {
FileName string `json:"file_name"`
NewLine *int `json:"new_line,omitempty"`
OldLine *int `json:"old_line,omitempty"`
HeadCommitSHA string `json:"head_commit_sha"`
BaseCommitSHA string `json:"base_commit_sha"`
StartCommitSHA string `json:"start_commit_sha"`
Type string `json:"type"`
LineRange *LineRange `json:"line_range,omitempty"`
}
/* RequestWithPosition is an interface that abstracts the handling of position data for a comment or a draft comment */
type RequestWithPosition interface {
GetPositionData() PositionData
}
/* buildCommentPosition takes a comment or draft comment request and builds the position data necessary for a location-based comment */
func buildCommentPosition(commentWithPositionData RequestWithPosition) *gitlab.PositionOptions {
positionData := commentWithPositionData.GetPositionData()
opt := &gitlab.PositionOptions{
PositionType: &positionData.Type,
StartSHA: &positionData.StartCommitSHA,
HeadSHA: &positionData.HeadCommitSHA,
BaseSHA: &positionData.BaseCommitSHA,
NewPath: &positionData.FileName,
OldPath: &positionData.FileName,
NewLine: positionData.NewLine,
OldLine: positionData.OldLine,
}
if positionData.LineRange != nil {
shaFormat := "%x_%d_%d"
startFilenameSha := fmt.Sprintf(
shaFormat,
sha1.Sum([]byte(positionData.FileName)),
positionData.LineRange.StartRange.OldLine,
positionData.LineRange.StartRange.NewLine,
)
endFilenameSha := fmt.Sprintf(
shaFormat,
sha1.Sum([]byte(positionData.FileName)),
positionData.LineRange.EndRange.OldLine,
positionData.LineRange.EndRange.NewLine,
)
opt.LineRange = &gitlab.LineRangeOptions{
Start: &gitlab.LinePositionOptions{
Type: &positionData.LineRange.StartRange.Type,
LineCode: &startFilenameSha,
},
End: &gitlab.LinePositionOptions{
Type: &positionData.LineRange.EndRange.Type,
LineCode: &endFilenameSha,
},
}
}
return opt
}

124
cmd/app/comment_test.go Normal file
View File

@@ -0,0 +1,124 @@
package app
import (
"net/http"
"testing"
"github.com/xanzy/go-gitlab"
)
type fakeCommentClient struct {
testBase
}
func (f fakeCommentClient) CreateMergeRequestDiscussion(pid interface{}, mergeRequest int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
return &gitlab.Discussion{Notes: []*gitlab.Note{{}}}, resp, err
}
func (f fakeCommentClient) UpdateMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
return &gitlab.Note{}, resp, err
}
func (f fakeCommentClient) DeleteMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, err
}
return resp, err
}
func TestPostComment(t *testing.T) {
var testCommentCreationData = PostCommentRequest{Comment: "Some comment"}
t.Run("Creates a new note (unlinked comment)", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData)
svc := commentService{testProjectData, fakeCommentClient{}}
data := getSuccessData(t, svc, request)
assert(t, data.Message, "Comment created successfully")
assert(t, data.Status, http.StatusOK)
})
t.Run("Creates a new comment", func(t *testing.T) {
testCommentCreationData := PostCommentRequest{ // Re-create comment creation data to avoid mutating this variable in other tests
Comment: "Some comment",
PositionData: PositionData{
FileName: "file.txt",
},
}
request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData)
svc := commentService{testProjectData, fakeCommentClient{}}
data := getSuccessData(t, svc, request)
assert(t, data.Message, "Comment created successfully")
assert(t, data.Status, http.StatusOK)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData)
svc := commentService{testProjectData, fakeCommentClient{testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not create discussion")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData)
svc := commentService{testProjectData, fakeCommentClient{testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not create discussion", "/mr/comment")
})
}
func TestDeleteComment(t *testing.T) {
var testCommentDeletionData = DeleteCommentRequest{NoteId: 3, DiscussionId: "abc123"}
t.Run("Deletes a comment", func(t *testing.T) {
request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData)
svc := commentService{testProjectData, fakeCommentClient{}}
data := getSuccessData(t, svc, request)
assert(t, data.Message, "Comment deleted successfully")
assert(t, data.Status, http.StatusOK)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData)
svc := commentService{testProjectData, fakeCommentClient{testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not delete comment")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData)
svc := commentService{testProjectData, fakeCommentClient{testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not delete comment", "/mr/comment")
})
}
func TestEditComment(t *testing.T) {
var testEditCommentData = EditCommentRequest{Comment: "Some comment", NoteId: 3, DiscussionId: "abc123"}
t.Run("Edits a comment", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData)
svc := commentService{testProjectData, fakeCommentClient{}}
data := getSuccessData(t, svc, request)
assert(t, data.Message, "Comment updated successfully")
assert(t, data.Status, http.StatusOK)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData)
svc := commentService{testProjectData, fakeCommentClient{testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not update comment")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData)
svc := commentService{testProjectData, fakeCommentClient{testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not update comment", "/mr/comment")
})
}

23
cmd/app/config.go Normal file
View File

@@ -0,0 +1,23 @@
package app
type PluginOptions struct {
GitlabUrl string `json:"gitlab_url"`
Port int `json:"port"`
AuthToken string `json:"auth_token"`
LogPath string `json:"log_path"`
Debug struct {
Request bool `json:"go_request"`
Response bool `json:"go_response"`
} `json:"debug"`
ChosenTargetBranch *string `json:"chosen_target_branch,omitempty"`
ConnectionSettings struct {
Insecure bool `json:"insecure"`
Remote string `json:"remote"`
} `json:"connection_settings"`
}
var pluginOptions PluginOptions
func SetPluginOptions(p PluginOptions) {
pluginOptions = p
}

99
cmd/app/create_mr.go Normal file
View File

@@ -0,0 +1,99 @@
package app
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/xanzy/go-gitlab"
)
type CreateMrRequest struct {
Title string `json:"title"`
Description string `json:"description"`
TargetBranch string `json:"target_branch"`
DeleteBranch bool `json:"delete_branch"`
Squash bool `json:"squash"`
TargetProjectID int `json:"forked_project_id,omitempty"`
}
type MergeRequestCreator interface {
CreateMergeRequest(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
}
type mergeRequestCreatorService struct {
data
client MergeRequestCreator
}
/* createMr creates a merge request */
func (a mergeRequestCreatorService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Methods", http.MethodGet)
if r.Method != http.MethodPost {
handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
var createMrRequest CreateMrRequest
err = json.Unmarshal(body, &createMrRequest)
if err != nil {
handleError(w, err, "Could not unmarshal request body", http.StatusBadRequest)
return
}
if createMrRequest.Title == "" {
handleError(w, errors.New("Title cannot be empty"), "Could not create MR", http.StatusBadRequest)
return
}
if createMrRequest.TargetBranch == "" {
handleError(w, errors.New("Target branch cannot be empty"), "Could not create MR", http.StatusBadRequest)
return
}
opts := gitlab.CreateMergeRequestOptions{
Title: &createMrRequest.Title,
Description: &createMrRequest.Description,
TargetBranch: &createMrRequest.TargetBranch,
SourceBranch: &a.gitInfo.BranchName,
RemoveSourceBranch: &createMrRequest.DeleteBranch,
Squash: &createMrRequest.Squash,
}
if createMrRequest.TargetProjectID != 0 {
opts.TargetProjectID = gitlab.Ptr(createMrRequest.TargetProjectID)
}
_, res, err := a.client.CreateMergeRequest(a.projectInfo.ProjectId, &opts)
if err != nil {
handleError(w, err, "Could not create MR", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/create_mr"}, "Could not create MR", res.StatusCode)
return
}
response := SuccessResponse{
Status: http.StatusOK,
Message: fmt.Sprintf("MR '%s' created", createMrRequest.Title),
}
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

80
cmd/app/create_mr_test.go Normal file
View File

@@ -0,0 +1,80 @@
package app
import (
"net/http"
"testing"
"github.com/xanzy/go-gitlab"
)
type fakeMergeCreatorClient struct {
testBase
}
func (f fakeMergeCreatorClient) CreateMergeRequest(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
return &gitlab.MergeRequest{}, resp, nil
}
func TestCreateMr(t *testing.T) {
var testCreateMrRequestData = CreateMrRequest{
Title: "Some title",
Description: "Some description",
TargetBranch: "main",
DeleteBranch: false,
Squash: false,
}
t.Run("Creates an MR", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData)
svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}}
data := getSuccessData(t, svc, request)
assert(t, data.Message, "MR 'Some title' created")
assert(t, data.Status, http.StatusOK)
})
t.Run("Disallows non-POST methods", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/create_mr", testCreateMrRequestData)
svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}}
data := getFailData(t, svc, request)
checkBadMethod(t, data, http.MethodPost)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData)
svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not create MR")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData)
svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not create MR", "/create_mr")
})
t.Run("Handles missing titles", func(t *testing.T) {
reqData := testCreateMrRequestData
reqData.Title = ""
request := makeRequest(t, http.MethodPost, "/create_mr", reqData)
svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}}
data := getFailData(t, svc, request)
assert(t, data.Status, http.StatusBadRequest)
assert(t, data.Message, "Could not create MR")
assert(t, data.Details, "Title cannot be empty")
})
t.Run("Handles missing target branch", func(t *testing.T) {
reqData := testCreateMrRequestData
reqData.TargetBranch = ""
request := makeRequest(t, http.MethodPost, "/create_mr", reqData)
svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}}
data := getFailData(t, svc, request)
assert(t, data.Status, http.StatusBadRequest)
assert(t, data.Message, "Could not create MR")
assert(t, data.Details, "Target branch cannot be empty")
})
}

View File

@@ -0,0 +1,76 @@
package app
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/xanzy/go-gitlab"
)
type DraftNotePublisher interface {
PublishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
PublishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
}
type draftNotePublisherService struct {
data
client DraftNotePublisher
}
func (a draftNotePublisherService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPost {
w.Header().Set("Access-Control-Allow-Methods", http.MethodPost)
handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var draftNotePublishRequest DraftNotePublishRequest
err = json.Unmarshal(body, &draftNotePublishRequest)
if err != nil {
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
return
}
var res *gitlab.Response
if draftNotePublishRequest.PublishAll {
res, err = a.client.PublishAllDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId)
} else {
if draftNotePublishRequest.Note == 0 {
handleError(w, errors.New("No ID provided"), "Must provide Note ID", http.StatusBadRequest)
return
}
res, err = a.client.PublishDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, draftNotePublishRequest.Note)
}
if err != nil {
handleError(w, err, "Could not publish draft note(s)", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/draft_notes/publish"}, "Could not publish dfaft note", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := SuccessResponse{
Message: "Draft note(s) published",
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,74 @@
package app
import (
"net/http"
"testing"
"github.com/xanzy/go-gitlab"
)
type fakeDraftNotePublisher struct {
testBase
}
func (f fakeDraftNotePublisher) PublishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
return f.handleGitlabError()
}
func (f fakeDraftNotePublisher) PublishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
return f.handleGitlabError()
}
func TestPublishDraftNote(t *testing.T) {
var testDraftNotePublishRequest = DraftNotePublishRequest{Note: 3, PublishAll: false}
t.Run("Publishes draft note", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest)
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}}
data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "Draft note(s) published")
})
t.Run("Disallows non-POST method", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/draft_notes/publish", testDraftNotePublishRequest)
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}}
data := getFailData(t, svc, request)
checkBadMethod(t, data, http.MethodPost)
})
t.Run("Handles bad ID", func(t *testing.T) {
badData := testDraftNotePublishRequest
badData.Note = 0
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", badData)
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}}
data := getFailData(t, svc, request)
assert(t, data.Status, http.StatusBadRequest)
assert(t, data.Message, "Must provide Note ID")
})
t.Run("Handles error from Gitlab", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest)
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not publish draft note(s)")
})
}
func TestPublishAllDraftNotes(t *testing.T) {
var testDraftNotePublishRequest = DraftNotePublishRequest{PublishAll: true}
t.Run("Should publish all draft notes", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest)
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}}
data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "Draft note(s) published")
})
t.Run("Disallows non-POST method", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/draft_notes/publish", testDraftNotePublishRequest)
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}}
data := getFailData(t, svc, request)
checkBadMethod(t, data, http.MethodPost)
})
t.Run("Handles error from Gitlab", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest)
svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not publish draft note(s)")
})
}

265
cmd/app/draft_notes.go Normal file
View File

@@ -0,0 +1,265 @@
package app
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/xanzy/go-gitlab"
)
/* The data coming from the client when creating a draft note is the same,
as when they are creating a normal comment, but the Gitlab
endpoints + resources we handle are different */
type PostDraftNoteRequest struct {
Comment string `json:"comment"`
DiscussionId string `json:"discussion_id,omitempty"`
PositionData
}
type UpdateDraftNoteRequest struct {
Note string `json:"note"`
Position gitlab.PositionOptions
}
type DraftNotePublishRequest struct {
Note int `json:"note,omitempty"`
PublishAll bool `json:"publish_all"`
}
type DraftNoteResponse struct {
SuccessResponse
DraftNote *gitlab.DraftNote `json:"draft_note"`
}
type ListDraftNotesResponse struct {
SuccessResponse
DraftNotes []*gitlab.DraftNote `json:"draft_notes"`
}
/* DraftNoteWithPosition is a draft comment with an (optional) position data value embedded in it. The position data will be non-nil for range-based draft comments. */
type DraftNoteWithPosition struct {
PositionData PositionData
}
func (draftNote DraftNoteWithPosition) GetPositionData() PositionData {
return draftNote.PositionData
}
type DraftNoteManager interface {
ListDraftNotes(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error)
CreateDraftNote(pid interface{}, mergeRequest int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error)
DeleteDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
UpdateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error)
}
type draftNoteService struct {
data
client DraftNoteManager
}
/* draftNoteHandler creates, edits, and deletes draft notes */
func (a draftNoteService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
a.listDraftNotes(w, r)
case http.MethodPost:
a.postDraftNote(w, r)
case http.MethodPatch:
a.updateDraftNote(w, r)
case http.MethodDelete:
a.deleteDraftNote(w, r)
default:
w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s, %s, %s", http.MethodDelete, http.MethodPost, http.MethodPatch, http.MethodGet))
handleError(w, InvalidRequestError{}, "Expected DELETE, GET, POST or PATCH", http.StatusMethodNotAllowed)
}
}
/* listDraftNotes lists all draft notes for the currently authenticated user */
func (a draftNoteService) listDraftNotes(w http.ResponseWriter, _ *http.Request) {
opt := gitlab.ListDraftNotesOptions{}
draftNotes, res, err := a.client.ListDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt)
if err != nil {
handleError(w, err, "Could not get draft notes", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not get draft notes", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := ListDraftNotesResponse{
SuccessResponse: SuccessResponse{
Message: "Draft notes fetched successfully",
Status: http.StatusOK,
},
DraftNotes: draftNotes,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}
/* postDraftNote creates a draft note */
func (a draftNoteService) postDraftNote(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var postDraftNoteRequest PostDraftNoteRequest
err = json.Unmarshal(body, &postDraftNoteRequest)
if err != nil {
handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest)
return
}
opt := gitlab.CreateDraftNoteOptions{
Note: &postDraftNoteRequest.Comment,
}
// Draft notes can be posted in "response" to existing discussions
if postDraftNoteRequest.DiscussionId != "" {
opt.InReplyToDiscussionID = gitlab.Ptr(postDraftNoteRequest.DiscussionId)
}
if postDraftNoteRequest.FileName != "" {
draftNoteWithPosition := DraftNoteWithPosition{postDraftNoteRequest.PositionData}
opt.Position = buildCommentPosition(draftNoteWithPosition)
}
draftNote, res, err := a.client.CreateDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt)
if err != nil {
handleError(w, err, "Could not create draft note", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not create draft note", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := DraftNoteResponse{
SuccessResponse: SuccessResponse{
Message: "Draft note created successfully",
Status: http.StatusOK,
},
DraftNote: draftNote,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}
/* deleteDraftNote deletes a draft note */
func (a draftNoteService) deleteDraftNote(w http.ResponseWriter, r *http.Request) {
suffix := strings.TrimPrefix(r.URL.Path, "/mr/draft_notes/")
id, err := strconv.Atoi(suffix)
if err != nil {
handleError(w, err, "Could not parse draft note ID", http.StatusBadRequest)
return
}
res, err := a.client.DeleteDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, id)
if err != nil {
handleError(w, err, "Could not delete draft note", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: fmt.Sprintf("/mr/draft_notes/%d", id)}, "Could not delete draft note", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := SuccessResponse{
Message: "Draft note deleted",
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}
/* updateDraftNote edits the text of a draft comment */
func (a draftNoteService) updateDraftNote(w http.ResponseWriter, r *http.Request) {
suffix := strings.TrimPrefix(r.URL.Path, "/mr/draft_notes/")
id, err := strconv.Atoi(suffix)
if err != nil {
handleError(w, err, "Could not parse draft note ID", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var updateDraftNoteRequest UpdateDraftNoteRequest
err = json.Unmarshal(body, &updateDraftNoteRequest)
if err != nil {
handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest)
return
}
if updateDraftNoteRequest.Note == "" {
handleError(w, errors.New("Draft note text missing"), "Must provide draft note text", http.StatusBadRequest)
return
}
opt := gitlab.UpdateDraftNoteOptions{
Note: &updateDraftNoteRequest.Note,
Position: &updateDraftNoteRequest.Position,
}
draftNote, res, err := a.client.UpdateDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, id, &opt)
if err != nil {
handleError(w, err, "Could not update draft note", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: fmt.Sprintf("/mr/draft_notes/%d", id)}, "Could not update draft note", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := DraftNoteResponse{
SuccessResponse: SuccessResponse{
Message: "Draft note updated",
Status: http.StatusOK,
},
DraftNote: draftNote,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

153
cmd/app/draft_notes_test.go Normal file
View File

@@ -0,0 +1,153 @@
package app
import (
"net/http"
"testing"
"github.com/xanzy/go-gitlab"
)
type fakeDraftNoteManager struct {
testBase
}
func (f fakeDraftNoteManager) ListDraftNotes(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
return []*gitlab.DraftNote{}, resp, err
}
func (f fakeDraftNoteManager) CreateDraftNote(pid interface{}, mergeRequest int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
return &gitlab.DraftNote{}, resp, err
}
func (f fakeDraftNoteManager) DeleteDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
return f.handleGitlabError()
}
func (f fakeDraftNoteManager) UpdateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
return &gitlab.DraftNote{}, resp, err
}
func TestListDraftNotes(t *testing.T) {
t.Run("Lists all draft notes", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{}}
data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "Draft notes fetched successfully")
})
t.Run("Handles error from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not get draft notes")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not get draft notes", "/mr/draft_notes/")
})
}
func TestPostDraftNote(t *testing.T) {
var testPostDraftNoteRequestData = PostDraftNoteRequest{Comment: "Some comment"}
t.Run("Posts new draft note", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{}}
data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "Draft note created successfully")
})
t.Run("Handles error from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not create draft note")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not create draft note", "/mr/draft_notes/")
})
}
func TestDeleteDraftNote(t *testing.T) {
t.Run("Deletes new draft note", func(t *testing.T) {
request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{}}
data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "Draft note deleted")
})
t.Run("Handles error from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not delete draft note")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not delete draft note", "/mr/draft_notes/3")
})
t.Run("Handles bad ID", func(t *testing.T) {
request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/blah", nil)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
assert(t, data.Message, "Could not parse draft note ID")
assert(t, data.Status, http.StatusBadRequest)
})
}
func TestEditDraftNote(t *testing.T) {
var testUpdateDraftNoteRequest = UpdateDraftNoteRequest{Note: "Some new note"}
t.Run("Edits new draft note", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", testUpdateDraftNoteRequest)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{}}
data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "Draft note updated")
})
t.Run("Handles error from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", testUpdateDraftNoteRequest)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not update draft note")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", testUpdateDraftNoteRequest)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not update draft note", "/mr/draft_notes/3")
})
t.Run("Handles bad ID", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/blah", testUpdateDraftNoteRequest)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
assert(t, data.Message, "Could not parse draft note ID")
assert(t, data.Status, http.StatusBadRequest)
})
t.Run("Handles empty note", func(t *testing.T) {
requestData := testUpdateDraftNoteRequest
requestData.Note = ""
request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", requestData)
svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
assert(t, data.Message, "Must provide draft note text")
assert(t, data.Status, http.StatusBadRequest)
})
}

186
cmd/app/emoji.go Normal file
View File

@@ -0,0 +1,186 @@
package app
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"strconv"
"strings"
"github.com/xanzy/go-gitlab"
)
type Emoji struct {
Unicode string `json:"unicode"`
UnicodeAlternates []string `json:"unicode_alternates"`
Name string `json:"name"`
Shortname string `json:"shortname"`
Category string `json:"category"`
Aliases []string `json:"aliases"`
AliasesASCII []string `json:"aliases_ascii"`
Keywords []string `json:"keywords"`
Moji string `json:"moji"`
}
type EmojiMap map[string]Emoji
type CreateNoteEmojiPost struct {
Emoji string `json:"emoji"`
NoteId int `json:"note_id"`
}
type CreateEmojiResponse struct {
SuccessResponse
Emoji *gitlab.AwardEmoji
}
type EmojiManager interface {
DeleteMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, awardID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
CreateMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, opt *gitlab.CreateAwardEmojiOptions, options ...gitlab.RequestOptionFunc) (*gitlab.AwardEmoji, *gitlab.Response, error)
}
type emojiService struct {
data
client EmojiManager
}
func (a emojiService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodPost:
a.postEmojiOnNote(w, r)
case http.MethodDelete:
a.deleteEmojiFromNote(w, r)
default:
w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s", http.MethodDelete, http.MethodPost))
handleError(w, InvalidRequestError{}, "Expected DELETE or POST", http.StatusMethodNotAllowed)
}
}
/* deleteEmojiFromNote deletes an emoji from a note based on the emoji (awardable) ID and the note's ID */
func (a emojiService) deleteEmojiFromNote(w http.ResponseWriter, r *http.Request) {
suffix := strings.TrimPrefix(r.URL.Path, "/mr/awardable/note/")
ids := strings.Split(suffix, "/")
noteId, err := strconv.Atoi(ids[0])
if err != nil {
handleError(w, err, "Could not convert note ID to integer", http.StatusBadRequest)
return
}
awardableId, err := strconv.Atoi(ids[1])
if err != nil {
handleError(w, err, "Could not convert awardable ID to integer", http.StatusBadRequest)
return
}
res, err := a.client.DeleteMergeRequestAwardEmojiOnNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, noteId, awardableId)
if err != nil {
handleError(w, err, "Could not delete awardable", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/pipeline"}, "Could not delete awardable", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := SuccessResponse{
Message: "Emoji deleted",
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}
/* postEmojiOnNote adds an emojis to a note based on the note's ID */
func (a emojiService) postEmojiOnNote(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var emojiPost CreateNoteEmojiPost
err = json.Unmarshal(body, &emojiPost)
if err != nil {
handleError(w, err, "Could not unmarshal request body", http.StatusBadRequest)
return
}
awardEmoji, res, err := a.client.CreateMergeRequestAwardEmojiOnNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, emojiPost.NoteId, &gitlab.CreateAwardEmojiOptions{
Name: emojiPost.Emoji,
})
if err != nil {
handleError(w, err, "Could not post emoji", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/awardable/note"}, "Could not post emoji", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := CreateEmojiResponse{
SuccessResponse: SuccessResponse{
Message: "Merge requests retrieved",
Status: http.StatusOK,
},
Emoji: awardEmoji,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}
/*
attachEmojis reads the emojis from our external JSON file
and attaches them to the data so that they can be looked up later
*/
func attachEmojis(a *data, fr FileReader) error {
e, err := os.Executable()
if err != nil {
return err
}
binPath := path.Dir(e)
filePath := fmt.Sprintf("%s/config/emojis.json", binPath)
reader, err := fr.ReadFile(filePath)
if err != nil {
return fmt.Errorf("Could not find emojis at %s", filePath)
}
bytes, err := io.ReadAll(reader)
if err != nil {
return errors.New("Could not read emoji file")
}
var emojiMap EmojiMap
err = json.Unmarshal(bytes, &emojiMap)
if err != nil {
return errors.New("Could not unmarshal emojis")
}
a.emojiMap = emojiMap
return nil
}

131
cmd/app/git/git.go Normal file
View File

@@ -0,0 +1,131 @@
package git
import (
"fmt"
"os/exec"
"regexp"
"strings"
)
type GitManager interface {
RefreshProjectInfo(remote string) error
GetProjectUrlFromNativeGitCmd(remote string) (url string, err error)
GetCurrentBranchNameFromNativeGitCmd() (string, error)
GetLatestCommitOnRemote(remote string, branchName string) (string, error)
}
type GitData struct {
RemoteUrl string
Namespace string
ProjectName string
BranchName string
}
type Git struct{}
/*
projectPath returns the Gitlab project full path, which isn't necessarily the same as its name.
See https://docs.gitlab.com/ee/api/rest/index.html#namespaced-path-encoding for more information.
*/
func (g GitData) ProjectPath() string {
return g.Namespace + "/" + g.ProjectName
}
/*
Extracts information about the current repository and returns
it to the client for initialization. The current directory must be a valid
Gitlab project and the branch must be a feature branch
*/
func NewGitData(remote string, g GitManager) (GitData, error) {
err := g.RefreshProjectInfo(remote)
if err != nil {
return GitData{}, fmt.Errorf("Could not get latest information from remote: %v", err)
}
url, err := g.GetProjectUrlFromNativeGitCmd(remote)
if err != nil {
return GitData{}, fmt.Errorf("Could not get project Url: %v", err)
}
/*
This should match following formats:
namespace: namespace, projectName: dummy-test-repo:
https://gitlab.com/namespace/dummy-test-repo.git
git@gitlab.com:namespace/dummy-test-repo.git
ssh://git@gitlab.com/namespace/dummy-test-repo.git
namespace: namespace/subnamespace, projectName: dummy-test-repo:
ssh://git@gitlab.com/namespace/subnamespace/dummy-test-repo
https://git@gitlab.com/namespace/subnamespace/dummy-test-repo.git
git@git@gitlab.com:namespace/subnamespace/dummy-test-repo.git
*/
re := regexp.MustCompile(`(?:^https?:\/\/|^ssh:\/\/|^git@)(?:[^\/:]+)(?::\d+)?[\/:](.*)\/([^\/]+?)(?:\.git)?$`)
matches := re.FindStringSubmatch(url)
if len(matches) != 3 {
return GitData{}, fmt.Errorf("Invalid Git URL format: %s", url)
}
namespace := matches[1]
projectName := matches[2]
branchName, err := g.GetCurrentBranchNameFromNativeGitCmd()
if err != nil {
return GitData{}, fmt.Errorf("Failed to get current branch: %v", err)
}
return GitData{
RemoteUrl: url,
Namespace: namespace,
ProjectName: projectName,
BranchName: branchName,
},
nil
}
/* Gets the current branch name */
func (g Git) GetCurrentBranchNameFromNativeGitCmd() (res string, e error) {
gitCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
output, err := gitCmd.Output()
if err != nil {
return "", fmt.Errorf("Error running git rev-parse: %w", err)
}
branchName := strings.TrimSpace(string(output))
return branchName, nil
}
/* Gets the project SSH or HTTPS url */
func (g Git) GetProjectUrlFromNativeGitCmd(remote string) (string, error) {
cmd := exec.Command("git", "remote", "get-url", remote)
url, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("Could not get remote")
}
return strings.TrimSpace(string(url)), nil
}
/* Pulls down latest commit information from Gitlab */
func (g Git) RefreshProjectInfo(remote string) error {
cmd := exec.Command("git", "fetch", remote)
_, err := cmd.Output()
if err != nil {
return fmt.Errorf("Failed to run `git fetch %s`: %v", remote, err)
}
return nil
}
func (g Git) GetLatestCommitOnRemote(remote string, branchName string) (string, error) {
cmd := exec.Command("git", "log", "-1", "--format=%H", fmt.Sprintf("%s/%s", remote, branchName))
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("Failed to run `git log -1 --format=%%H " + fmt.Sprintf("%s/%s", remote, branchName))
}
commit := strings.TrimSpace(string(out))
return commit, nil
}

219
cmd/app/git/git_test.go Normal file
View File

@@ -0,0 +1,219 @@
package git
import (
"errors"
"testing"
)
type FakeGitManager struct {
RemoteUrl string
BranchName string
ProjectName string
Namespace string
}
func (f FakeGitManager) RefreshProjectInfo(remote string) error {
return nil
}
func (f FakeGitManager) GetCurrentBranchNameFromNativeGitCmd() (string, error) {
return f.BranchName, nil
}
func (f FakeGitManager) GetLatestCommitOnRemote(remote string, branchName string) (string, error) {
return "", nil
}
func (f FakeGitManager) GetProjectUrlFromNativeGitCmd(string) (url string, err error) {
return f.RemoteUrl, nil
}
type TestCase struct {
desc string
branch string
projectName string
namespace string
remote string
}
func TestExtractGitInfo_Success(t *testing.T) {
testCases := []TestCase{
{
desc: "Project configured in SSH under a single folder",
remote: "git@custom-gitlab.com:namespace-1/project-name.git",
branch: "feature/abc",
projectName: "project-name",
namespace: "namespace-1",
},
{
desc: "Project configured in SSH under a single folder without .git extension",
remote: "git@custom-gitlab.com:namespace-1/project-name",
branch: "feature/abc",
projectName: "project-name",
namespace: "namespace-1",
},
{
desc: "Project configured in SSH under one nested folder",
remote: "git@custom-gitlab.com:namespace-1/namespace-2/project-name.git",
branch: "feature/abc",
projectName: "project-name",
namespace: "namespace-1/namespace-2",
},
{
desc: "Project configured in SSH under two nested folders",
remote: "git@custom-gitlab.com:namespace-1/namespace-2/namespace-3/project-name.git",
branch: "feature/abc",
projectName: "project-name",
namespace: "namespace-1/namespace-2/namespace-3",
},
{
desc: "Project configured in SSH:// under a single folder",
remote: "ssh://custom-gitlab.com/namespace-1/project-name.git",
branch: "feature/abc",
projectName: "project-name",
namespace: "namespace-1",
},
{
desc: "Project configured in SSH:// under a single folder without .git extension",
remote: "ssh://custom-gitlab.com/namespace-1/project-name",
branch: "feature/abc",
projectName: "project-name",
namespace: "namespace-1",
},
{
desc: "Project configured in SSH:// under two nested folders",
remote: "ssh://custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git",
branch: "feature/abc",
projectName: "project-name",
namespace: "namespace-1/namespace-2/namespace-3",
},
{
desc: "Project configured in SSH:// and have a custom port",
remote: "ssh://custom-gitlab.com:2222/namespace-1/project-name",
branch: "feature/abc",
projectName: "project-name",
namespace: "namespace-1",
},
{
desc: "Project configured in HTTP and under a single folder without .git extension",
remote: "http://custom-gitlab.com/namespace-1/project-name",
branch: "feature/abc",
projectName: "project-name",
namespace: "namespace-1",
},
{
desc: "Project configured in HTTPS and under a single folder",
remote: "https://custom-gitlab.com/namespace-1/project-name.git",
branch: "feature/abc",
projectName: "project-name",
namespace: "namespace-1",
},
{
desc: "Project configured in HTTPS and under a nested folder",
remote: "https://custom-gitlab.com/namespace-1/namespace-2/project-name.git",
branch: "feature/abc",
projectName: "project-name",
namespace: "namespace-1/namespace-2",
},
{
desc: "Project configured in HTTPS and under two nested folders",
remote: "https://custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git",
branch: "feature/abc",
projectName: "project-name",
namespace: "namespace-1/namespace-2/namespace-3",
},
}
for _, tC := range testCases {
t.Run(tC.desc, func(t *testing.T) {
g := FakeGitManager{
Namespace: tC.namespace,
ProjectName: tC.projectName,
BranchName: tC.branch,
RemoteUrl: tC.remote,
}
data, err := NewGitData(tC.remote, g)
if err != nil {
t.Errorf("No error was expected, got %s", err)
}
if data.RemoteUrl != tC.remote {
t.Errorf("\nExpected Remote URL: %s\nActual: %s", tC.remote, data.RemoteUrl)
}
if data.BranchName != tC.branch {
t.Errorf("\nExpected Branch Name: %s\nActual: %s", tC.branch, data.BranchName)
}
if data.ProjectName != tC.projectName {
t.Errorf("\nExpected Project Name: %s\nActual: %s", tC.projectName, data.ProjectName)
}
if data.Namespace != tC.namespace {
t.Errorf("\nExpected Namespace: %s\nActual: %s", tC.namespace, data.Namespace)
}
})
}
}
type FailTestCase struct {
desc string
errMsg string
expectedErr string
}
type failingUrlManager struct {
errMsg string
FakeGitManager
}
func (f failingUrlManager) GetProjectUrlFromNativeGitCmd(string) (string, error) {
return "", errors.New(f.errMsg)
}
func TestExtractGitInfo_FailToGetProjectRemoteUrl(t *testing.T) {
tC := FailTestCase{
desc: "Error returned by function to get the project remote url",
errMsg: "Some error",
expectedErr: "Could not get project Url: Some error",
}
t.Run(tC.desc, func(t *testing.T) {
g := failingUrlManager{
errMsg: tC.errMsg,
}
_, err := NewGitData("", g)
if err == nil {
t.Errorf("Expected an error, got none")
}
if err.Error() != tC.expectedErr {
t.Errorf("\nExpected: %s\nActual: %s", tC.expectedErr, err.Error())
}
})
}
type failingBranchManager struct {
errMsg string
FakeGitManager
}
func (f failingBranchManager) GetCurrentBranchNameFromNativeGitCmd() (string, error) {
return "", errors.New(f.errMsg)
}
func TestExtractGitInfo_FailToGetCurrentBranchName(t *testing.T) {
tC := FailTestCase{
desc: "Error returned by function to get the project remote url",
errMsg: "Some error",
expectedErr: "Failed to get current branch: Some error",
}
t.Run(tC.desc, func(t *testing.T) {
g := failingBranchManager{
FakeGitManager: FakeGitManager{
RemoteUrl: "git@custom-gitlab.com:namespace-1/project-name.git",
},
errMsg: tC.errMsg,
}
_, err := NewGitData("", g)
if err == nil {
t.Errorf("Expected an error, got none")
}
if err.Error() != tC.expectedErr {
t.Errorf("\nExpected: %s\nActual: %s", tC.expectedErr, err.Error())
}
})
}

57
cmd/app/info.go Normal file
View File

@@ -0,0 +1,57 @@
package app
import (
"encoding/json"
"net/http"
"github.com/xanzy/go-gitlab"
)
type InfoResponse struct {
SuccessResponse
Info *gitlab.MergeRequest `json:"info"`
}
type MergeRequestGetter interface {
GetMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
}
type infoService struct {
data
client MergeRequestGetter
}
/* infoHandler fetches infomation about the current git project. The data returned here is used in many other API calls */
func (a infoService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodGet {
w.Header().Set("Access-Control-Allow-Methods", http.MethodGet)
handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed)
return
}
mr, res, err := a.client.GetMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.GetMergeRequestsOptions{})
if err != nil {
handleError(w, err, "Could not get project info", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/info"}, "Could not get project info", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := InfoResponse{
SuccessResponse: SuccessResponse{
Message: "Merge requests retrieved",
Status: http.StatusOK,
},
Info: mr,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

49
cmd/app/info_test.go Normal file
View File

@@ -0,0 +1,49 @@
package app
import (
"net/http"
"testing"
"github.com/xanzy/go-gitlab"
)
type fakeMergeRequestGetter struct {
testBase
}
func (f fakeMergeRequestGetter) GetMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
return &gitlab.MergeRequest{}, resp, err
}
func TestInfoHandler(t *testing.T) {
t.Run("Returns normal information", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/info", nil)
svc := infoService{testProjectData, fakeMergeRequestGetter{}}
data := getSuccessData(t, svc, request)
assert(t, data.Message, "Merge requests retrieved")
assert(t, data.Status, http.StatusOK)
})
t.Run("Disallows non-GET methods", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/info", nil)
svc := infoService{testProjectData, fakeMergeRequestGetter{}}
data := getFailData(t, svc, request)
checkBadMethod(t, data, http.MethodGet)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/info", nil)
svc := infoService{testProjectData, fakeMergeRequestGetter{testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not get project info")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/info", nil)
svc := infoService{testProjectData, fakeMergeRequestGetter{testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not get project info", "/mr/info")
})
}

85
cmd/app/job.go Normal file
View File

@@ -0,0 +1,85 @@
package app
import (
"bytes"
"encoding/json"
"io"
"net/http"
"github.com/xanzy/go-gitlab"
)
type JobTraceRequest struct {
JobId int `json:"job_id"`
}
type JobTraceResponse struct {
SuccessResponse
File string `json:"file"`
}
type TraceFileGetter interface {
GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error)
}
type traceFileService struct {
data
client TraceFileGetter
}
/* jobHandler returns a string that shows the output of a specific job run in a Gitlab pipeline */
func (a traceFileService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodGet {
w.Header().Set("Access-Control-Allow-Methods", http.MethodGet)
handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var jobTraceRequest JobTraceRequest
err = json.Unmarshal(body, &jobTraceRequest)
if err != nil {
handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest)
return
}
reader, res, err := a.client.GetTraceFile(a.projectInfo.ProjectId, jobTraceRequest.JobId)
if err != nil {
handleError(w, err, "Could not get trace file for job", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/job"}, "Could not get trace file for job", res.StatusCode)
return
}
file, err := io.ReadAll(reader)
if err != nil {
handleError(w, err, "Could not read job trace file", http.StatusBadRequest)
return
}
response := JobTraceResponse{
SuccessResponse: SuccessResponse{
Status: http.StatusOK,
Message: "Log file read",
},
File: string(file),
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

71
cmd/app/job_test.go Normal file
View File

@@ -0,0 +1,71 @@
package app
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/xanzy/go-gitlab"
)
type fakeTraceFileGetter struct {
testBase
}
func getTraceFileData(t *testing.T, svc ServiceWithHandler, request *http.Request) JobTraceResponse {
res := httptest.NewRecorder()
svc.handler(res, request)
var data JobTraceResponse
err := json.Unmarshal(res.Body.Bytes(), &data)
if err != nil {
t.Error(err)
}
return data
}
func (f fakeTraceFileGetter) GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
re := bytes.NewReader([]byte("Some data"))
return re, resp, err
}
// var jobId = 0
func TestJobHandler(t *testing.T) {
t.Run("Should read a job trace file", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{})
client := fakeTraceFileGetter{}
svc := traceFileService{testProjectData, client}
data := getTraceFileData(t, svc, request)
assert(t, data.Message, "Log file read")
assert(t, data.Status, http.StatusOK)
assert(t, data.File, "Some data")
})
t.Run("Disallows non-GET methods", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/job", JobTraceRequest{})
client := fakeTraceFileGetter{}
svc := traceFileService{testProjectData, client}
data := getFailData(t, svc, request)
checkBadMethod(t, data, http.MethodGet)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{})
client := fakeTraceFileGetter{testBase{errFromGitlab: true}}
svc := traceFileService{testProjectData, client}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not get trace file for job")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{})
client := fakeTraceFileGetter{testBase{status: http.StatusSeeOther}}
svc := traceFileService{testProjectData, client}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not get trace file for job", "/job")
})
}

140
cmd/app/label.go Normal file
View File

@@ -0,0 +1,140 @@
package app
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/xanzy/go-gitlab"
)
type LabelUpdateRequest struct {
Labels []string `json:"labels"`
}
type Label struct {
Name string
Color string
}
type LabelUpdateResponse struct {
SuccessResponse
Labels gitlab.Labels `json:"labels"`
}
type LabelsRequestResponse struct {
SuccessResponse
Labels []Label `json:"labels"`
}
type LabelManager interface {
UpdateMergeRequest(interface{}, int, *gitlab.UpdateMergeRequestOptions, ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
ListLabels(interface{}, *gitlab.ListLabelsOptions, ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error)
}
type labelService struct {
data
client LabelManager
}
/* labelsHandler adds or removes labels from a merge request, and returns all labels for the current project */
func (a labelService) handler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
a.getLabels(w, r)
case http.MethodPut:
a.updateLabels(w, r)
default:
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s", http.MethodPut, http.MethodGet))
handleError(w, InvalidRequestError{}, "Expected GET or PUT", http.StatusMethodNotAllowed)
}
}
func (a labelService) getLabels(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
labels, res, err := a.client.ListLabels(a.projectInfo.ProjectId, &gitlab.ListLabelsOptions{})
if err != nil {
handleError(w, err, "Could not modify merge request labels", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/label"}, "Could not modify merge request labels", res.StatusCode)
return
}
/* Hacky, but convert them to the correct response */
convertedLabels := make([]Label, len(labels))
for i, labelPtr := range labels {
convertedLabels[i] = Label{
Name: labelPtr.Name,
Color: labelPtr.Color,
}
}
w.WriteHeader(http.StatusOK)
response := LabelsRequestResponse{
SuccessResponse: SuccessResponse{
Message: "Labels updated",
Status: http.StatusOK,
},
Labels: convertedLabels,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}
func (a labelService) updateLabels(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var labelUpdateRequest LabelUpdateRequest
err = json.Unmarshal(body, &labelUpdateRequest)
if err != nil {
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
return
}
var labels = gitlab.LabelOptions(labelUpdateRequest.Labels)
mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{
Labels: &labels,
})
if err != nil {
handleError(w, err, "Could not modify merge request labels", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/label"}, "Could not modify merge request labels", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := LabelUpdateResponse{
SuccessResponse: SuccessResponse{
Message: "Labels updated",
Status: http.StatusOK,
},
Labels: mr.Labels,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

215
cmd/app/list_discussions.go Normal file
View File

@@ -0,0 +1,215 @@
package app
import (
"io"
"net/http"
"sort"
"sync"
"encoding/json"
"github.com/xanzy/go-gitlab"
)
func Contains[T comparable](elems []T, v T) bool {
for _, s := range elems {
if v == s {
return true
}
}
return false
}
type DiscussionsRequest struct {
Blacklist []string `json:"blacklist"`
}
type DiscussionsResponse struct {
SuccessResponse
Discussions []*gitlab.Discussion `json:"discussions"`
UnlinkedDiscussions []*gitlab.Discussion `json:"unlinked_discussions"`
Emojis map[int][]*gitlab.AwardEmoji `json:"emojis"`
}
type SortableDiscussions []*gitlab.Discussion
func (n SortableDiscussions) Len() int {
return len(n)
}
func (d SortableDiscussions) Less(i int, j int) bool {
iTime := d[i].Notes[len(d[i].Notes)-1].CreatedAt
jTime := d[j].Notes[len(d[j].Notes)-1].CreatedAt
return iTime.After(*jTime)
}
func (n SortableDiscussions) Swap(i, j int) {
n[i], n[j] = n[j], n[i]
}
type DiscussionsLister interface {
ListMergeRequestDiscussions(pid interface{}, mergeRequest int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error)
ListMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error)
}
type discussionsListerService struct {
data
client DiscussionsLister
}
/*
listDiscussionsHandler lists all discusions for a given merge request, both those linked and unlinked to particular points in the code.
The responses are sorted by date created, and blacklisted users are not included
*/
func (a discussionsListerService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPost {
w.Header().Set("Access-Control-Allow-Methods", http.MethodPost)
handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
}
defer r.Body.Close()
var requestBody DiscussionsRequest
err = json.Unmarshal(body, &requestBody)
if err != nil {
handleError(w, err, "Could not unmarshal request body", http.StatusBadRequest)
}
mergeRequestDiscussionOptions := gitlab.ListMergeRequestDiscussionsOptions{
Page: 1,
PerPage: 250,
}
discussions, res, err := a.client.ListMergeRequestDiscussions(a.projectInfo.ProjectId, a.projectInfo.MergeId, &mergeRequestDiscussionOptions)
if err != nil {
handleError(w, err, "Could not list discussions", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/discussions/list"}, "Could not list discussions", res.StatusCode)
return
}
/* Filter out any discussions started by a blacklisted user
and system discussions, then return them sorted by created date */
var unlinkedDiscussions []*gitlab.Discussion
var linkedDiscussions []*gitlab.Discussion
for _, discussion := range discussions {
if discussion.Notes == nil || len(discussion.Notes) == 0 || Contains(requestBody.Blacklist, discussion.Notes[0].Author.Username) {
continue
}
for _, note := range discussion.Notes {
if note.Type == gitlab.NoteTypeValue("DiffNote") {
linkedDiscussions = append(linkedDiscussions, discussion)
break
} else if !note.System && note.Position == nil {
unlinkedDiscussions = append(unlinkedDiscussions, discussion)
break
}
}
}
/* Collect IDs in order to fetch emojis */
var noteIds []int
for _, discussion := range discussions {
for _, note := range discussion.Notes {
noteIds = append(noteIds, note.ID)
}
}
emojis, err := a.fetchEmojisForNotesAndComments(noteIds)
if err != nil {
handleError(w, err, "Could not fetch emojis", http.StatusInternalServerError)
return
}
sortedLinkedDiscussions := SortableDiscussions(linkedDiscussions)
sortedUnlinkedDiscussions := SortableDiscussions(unlinkedDiscussions)
sort.Sort(sortedLinkedDiscussions)
sort.Sort(sortedUnlinkedDiscussions)
w.WriteHeader(http.StatusOK)
response := DiscussionsResponse{
SuccessResponse: SuccessResponse{
Message: "Discussions retrieved",
Status: http.StatusOK,
},
Discussions: linkedDiscussions,
UnlinkedDiscussions: unlinkedDiscussions,
Emojis: emojis,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}
/*
Fetches emojis for a set of notes and comments in parallel and returns a map of note IDs to their emojis.
Gitlab's API does not allow for fetching notes for an entire discussion thread so we have to do it per-note.
*/
func (a discussionsListerService) fetchEmojisForNotesAndComments(noteIDs []int) (map[int][]*gitlab.AwardEmoji, error) {
var wg sync.WaitGroup
emojis := make(map[int][]*gitlab.AwardEmoji)
mu := &sync.Mutex{}
errs := make(chan error, len(noteIDs))
emojiChan := make(chan struct {
noteID int
emojis []*gitlab.AwardEmoji
}, len(noteIDs))
for _, noteID := range noteIDs {
wg.Add(1)
go func(noteID int) {
defer wg.Done()
emojis, _, err := a.client.ListMergeRequestAwardEmojiOnNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, noteID, &gitlab.ListAwardEmojiOptions{})
if err != nil {
errs <- err
return
}
emojiChan <- struct {
noteID int
emojis []*gitlab.AwardEmoji
}{noteID, emojis}
}(noteID)
}
/* Close the channels when all goroutines finish */
go func() {
wg.Wait()
close(errs)
close(emojiChan)
}()
/* Collect emojis */
for e := range emojiChan {
mu.Lock()
emojis[e.noteID] = e.emojis
mu.Unlock()
}
/* Check if any errors occurred */
if len(errs) > 0 {
for err := range errs {
if err != nil {
return nil, err
}
}
}
return emojis, nil
}

View File

@@ -0,0 +1,113 @@
package app
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/xanzy/go-gitlab"
)
type fakeDiscussionsLister struct {
testBase
badEmojiResponse bool
}
func (f fakeDiscussionsLister) ListMergeRequestDiscussions(pid interface{}, mergeRequest int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
now := time.Now()
newer := now.Add(time.Second * 100)
type Author struct {
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Name string `json:"name"`
State string `json:"state"`
AvatarURL string `json:"avatar_url"`
WebURL string `json:"web_url"`
}
testListDiscussionsResponse := []*gitlab.Discussion{
{Notes: []*gitlab.Note{{CreatedAt: &now, Type: "DiffNote", Author: Author{Username: "hcramer"}}}},
{Notes: []*gitlab.Note{{CreatedAt: &newer, Type: "DiffNote", Author: Author{Username: "hcramer2"}}}},
}
return testListDiscussionsResponse, resp, err
}
func (f fakeDiscussionsLister) ListMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
if f.badEmojiResponse {
return nil, nil, errors.New("Some error from emoji service")
}
return []*gitlab.AwardEmoji{}, resp, err
}
func getDiscussionsList(t *testing.T, svc ServiceWithHandler, request *http.Request) DiscussionsResponse {
res := httptest.NewRecorder()
svc.handler(res, request)
var data DiscussionsResponse
err := json.Unmarshal(res.Body.Bytes(), &data)
if err != nil {
t.Error(err)
}
return data
}
func TestListDiscussions(t *testing.T) {
t.Run("Returns sorted discussions", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{})
svc := discussionsListerService{testProjectData, fakeDiscussionsLister{}}
data := getDiscussionsList(t, svc, request)
assert(t, data.Message, "Discussions retrieved")
assert(t, data.SuccessResponse.Status, http.StatusOK)
assert(t, data.Discussions[0].Notes[0].Author.Username, "hcramer2") /* Sorting applied */
assert(t, data.Discussions[1].Notes[0].Author.Username, "hcramer")
})
t.Run("Uses blacklist to filter unwanted authors", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{"hcramer"}})
svc := discussionsListerService{testProjectData, fakeDiscussionsLister{}}
data := getDiscussionsList(t, svc, request)
assert(t, data.SuccessResponse.Message, "Discussions retrieved")
assert(t, data.SuccessResponse.Status, http.StatusOK)
assert(t, len(data.Discussions), 1)
assert(t, data.Discussions[0].Notes[0].Author.Username, "hcramer2")
})
t.Run("Disallows non-GET methods", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/mr/discussions/list", DiscussionsRequest{})
svc := discussionsListerService{testProjectData, fakeDiscussionsLister{}}
data := getFailData(t, svc, request)
checkBadMethod(t, data, http.MethodPost)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{})
svc := discussionsListerService{testProjectData, fakeDiscussionsLister{testBase: testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not list discussions")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{})
svc := discussionsListerService{testProjectData, fakeDiscussionsLister{testBase: testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not list discussions", "/mr/discussions/list")
})
t.Run("Handles error from emoji service", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{})
svc := discussionsListerService{testProjectData, fakeDiscussionsLister{badEmojiResponse: true}}
data := getFailData(t, svc, request)
assert(t, data.Message, "Could not fetch emojis")
assert(t, data.Details, "Some error from emoji service")
})
}

65
cmd/app/members.go Normal file
View File

@@ -0,0 +1,65 @@
package app
import (
"encoding/json"
"net/http"
"github.com/xanzy/go-gitlab"
)
type ProjectMembersResponse struct {
SuccessResponse
ProjectMembers []*gitlab.ProjectMember
}
type ProjectMemberLister interface {
ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error)
}
type projectMemberService struct {
data
client ProjectMemberLister
}
/* projectMembersHandler returns all members of the current Gitlab project */
func (a projectMemberService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodGet {
w.Header().Set("Access-Control-Allow-Methods", http.MethodGet)
handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed)
return
}
projectMemberOptions := gitlab.ListProjectMembersOptions{
ListOptions: gitlab.ListOptions{
PerPage: 100,
},
}
projectMembers, res, err := a.client.ListAllProjectMembers(a.projectInfo.ProjectId, &projectMemberOptions)
if err != nil {
handleError(w, err, "Could not retrieve project members", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/project/members"}, "Could not retrieve project members", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := ProjectMembersResponse{
SuccessResponse: SuccessResponse{
Status: http.StatusOK,
Message: "Project members retrieved",
},
ProjectMembers: projectMembers,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

48
cmd/app/members_test.go Normal file
View File

@@ -0,0 +1,48 @@
package app
import (
"net/http"
"testing"
"github.com/xanzy/go-gitlab"
)
type fakeMemberLister struct {
testBase
}
func (f fakeMemberLister) ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
return []*gitlab.ProjectMember{}, resp, err
}
func TestMembersHandler(t *testing.T) {
t.Run("Returns project members", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/project/members", nil)
svc := projectMemberService{testProjectData, fakeMemberLister{}}
data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "Project members retrieved")
})
t.Run("Disallows non-GET methods", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/project/members", nil)
svc := projectMemberService{testProjectData, fakeMemberLister{}}
data := getFailData(t, svc, request)
checkBadMethod(t, data, http.MethodGet)
})
t.Run("Handles error from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/project/members", nil)
svc := projectMemberService{testProjectData, fakeMemberLister{testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not retrieve project members")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/project/members", nil)
svc := projectMemberService{testProjectData, fakeMemberLister{testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not retrieve project members", "/project/members")
})
}

80
cmd/app/merge_mr.go Normal file
View File

@@ -0,0 +1,80 @@
package app
import (
"encoding/json"
"io"
"net/http"
"github.com/xanzy/go-gitlab"
)
type AcceptMergeRequestRequest struct {
Squash bool `json:"squash"`
SquashMessage string `json:"squash_message"`
DeleteBranch bool `json:"delete_branch"`
}
type MergeRequestAccepter interface {
AcceptMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
}
type mergeRequestAccepterService struct {
data
client MergeRequestAccepter
}
/* acceptAndMergeHandler merges a given merge request into the target branch */
func (a mergeRequestAccepterService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Methods", http.MethodGet)
if r.Method != http.MethodPost {
handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
var acceptAndMergeRequest AcceptMergeRequestRequest
err = json.Unmarshal(body, &acceptAndMergeRequest)
if err != nil {
handleError(w, err, "Could not unmarshal request body", http.StatusBadRequest)
return
}
opts := gitlab.AcceptMergeRequestOptions{
Squash: &acceptAndMergeRequest.Squash,
ShouldRemoveSourceBranch: &acceptAndMergeRequest.DeleteBranch,
}
if acceptAndMergeRequest.SquashMessage != "" {
opts.SquashCommitMessage = &acceptAndMergeRequest.SquashMessage
}
_, res, err := a.client.AcceptMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opts)
if err != nil {
handleError(w, err, "Could not merge MR", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/merge"}, "Could not merge MR", res.StatusCode)
return
}
response := SuccessResponse{
Status: http.StatusOK,
Message: "MR merged successfully",
}
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

50
cmd/app/merge_mr_test.go Normal file
View File

@@ -0,0 +1,50 @@
package app
import (
"net/http"
"testing"
"github.com/xanzy/go-gitlab"
)
type fakeMergeRequestAccepter struct {
testBase
}
func (f fakeMergeRequestAccepter) AcceptMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
return &gitlab.MergeRequest{}, resp, err
}
func TestAcceptAndMergeHandler(t *testing.T) {
var testAcceptMergeRequestPayload = AcceptMergeRequestRequest{Squash: false, SquashMessage: "Squash me!", DeleteBranch: false}
t.Run("Accepts and merges a merge request", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload)
svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{}}
data := getSuccessData(t, svc, request)
assert(t, data.Message, "MR merged successfully")
assert(t, data.Status, http.StatusOK)
})
t.Run("Disallows non-POST methods", func(t *testing.T) {
request := makeRequest(t, http.MethodPut, "/mr/merge", testAcceptMergeRequestPayload)
svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{}}
data := getFailData(t, svc, request)
checkBadMethod(t, data, http.MethodPost)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload)
svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not merge MR")
})
t.Run("Handles non-200s from Gitlab", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload)
svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not merge MR", "/mr/merge")
})
}

86
cmd/app/merge_requests.go Normal file
View File

@@ -0,0 +1,86 @@
package app
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/xanzy/go-gitlab"
)
type ListMergeRequestResponse struct {
SuccessResponse
MergeRequests []*gitlab.MergeRequest `json:"merge_requests"`
}
type MergeRequestLister interface {
ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error)
}
type mergeRequestListerService struct {
data
client MergeRequestLister
}
func (a mergeRequestListerService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPost {
w.Header().Set("Access-Control-Allow-Methods", http.MethodPost)
handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var listMergeRequestRequest gitlab.ListProjectMergeRequestsOptions
err = json.Unmarshal(body, &listMergeRequestRequest)
if err != nil {
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
return
}
if listMergeRequestRequest.State == nil {
listMergeRequestRequest.State = gitlab.Ptr("opened")
}
if listMergeRequestRequest.Scope == nil {
listMergeRequestRequest.Scope = gitlab.Ptr("all")
}
mergeRequests, res, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, &listMergeRequestRequest)
if err != nil {
handleError(w, err, "Failed to list merge requests", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/merge_requests"}, "Failed to list merge requests", res.StatusCode)
return
}
if len(mergeRequests) == 0 {
handleError(w, errors.New("No merge requests found"), "No merge requests found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
response := ListMergeRequestResponse{
SuccessResponse: SuccessResponse{
Message: "Merge requests fetched successfully",
Status: http.StatusOK,
},
MergeRequests: mergeRequests,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,162 @@
package app
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sync"
"github.com/xanzy/go-gitlab"
)
type MergeRequestListerByUsername interface {
ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error)
}
type mergeRequestListerByUsernameService struct {
data
client MergeRequestListerByUsername
}
type MergeRequestByUsernameRequest struct {
UserId int `json:"user_id"`
Username string `json:"username"`
State string `json:"state,omitempty"`
}
func (a mergeRequestListerByUsernameService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPost {
w.Header().Set("Access-Control-Allow-Methods", http.MethodPost)
handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var request MergeRequestByUsernameRequest
err = json.Unmarshal(body, &request)
if err != nil {
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
return
}
if request.Username == "" {
handleError(w, errors.New("username is a required payload field"), "username is required", http.StatusBadRequest)
return
}
if request.UserId == 0 {
handleError(w, errors.New("user_id is a required payload field"), "user_id is required", http.StatusBadRequest)
return
}
if request.State == "" {
request.State = "opened"
}
payloads := []gitlab.ListProjectMergeRequestsOptions{
{
AuthorUsername: gitlab.Ptr(request.Username),
State: gitlab.Ptr(request.State),
Scope: gitlab.Ptr("all"),
},
{
ReviewerUsername: gitlab.Ptr(request.Username),
State: gitlab.Ptr(request.State),
Scope: gitlab.Ptr("all"),
},
{
AssigneeID: gitlab.AssigneeID(request.UserId),
State: gitlab.Ptr(request.State),
Scope: gitlab.Ptr("all"),
},
}
type apiResponse struct {
mrs []*gitlab.MergeRequest
err error
}
mrChan := make(chan apiResponse, len(payloads))
wg := sync.WaitGroup{}
go func() {
wg.Wait()
close(mrChan)
}()
for _, payload := range payloads {
wg.Add(1)
go func(p gitlab.ListProjectMergeRequestsOptions) {
defer wg.Done()
mrs, err := a.getMrs(&p)
mrChan <- apiResponse{mrs, err}
}(payload)
}
var mergeRequests []*gitlab.MergeRequest
existingIds := make(map[int]bool)
var errs []error
for res := range mrChan {
if res.err != nil {
errs = append(errs, res.err)
} else {
for _, mr := range res.mrs {
if !existingIds[mr.ID] {
mergeRequests = append(mergeRequests, mr)
existingIds[mr.ID] = true
}
}
}
}
if len(errs) > 0 {
combinedErr := ""
for _, err := range errs {
combinedErr += err.Error() + "; "
}
handleError(w, errors.New(combinedErr), "An error occurred", http.StatusInternalServerError)
return
}
if len(mergeRequests) == 0 {
handleError(w, fmt.Errorf("%s did not have any MRs", request.Username), "No MRs found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
response := ListMergeRequestResponse{
SuccessResponse: SuccessResponse{
Message: fmt.Sprintf("Merge requests fetched for %s", request.Username),
Status: http.StatusOK,
},
MergeRequests: mergeRequests,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}
func (a mergeRequestListerByUsernameService) getMrs(payload *gitlab.ListProjectMergeRequestsOptions) ([]*gitlab.MergeRequest, error) {
mrs, res, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, payload)
if err != nil {
return []*gitlab.MergeRequest{}, err
}
if res.StatusCode >= 300 {
return []*gitlab.MergeRequest{}, GenericError{endpoint: "/merge_requests_by_username"}
}
defer res.Body.Close()
return mrs, err
}

View File

@@ -0,0 +1,87 @@
package app
import (
"net/http"
"strings"
"testing"
"github.com/xanzy/go-gitlab"
)
type fakeMergeRequestListerByUsername struct {
testBase
emptyResponse bool
}
func (f fakeMergeRequestListerByUsername) ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
if f.emptyResponse {
return []*gitlab.MergeRequest{}, resp, err
}
return []*gitlab.MergeRequest{{IID: 10}}, resp, err
}
func TestListMergeRequestByUsername(t *testing.T) {
var testListMrsByUsernamePayload = MergeRequestByUsernameRequest{Username: "hcramer", UserId: 1234, State: "opened"}
t.Run("Gets merge requests by username", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload)
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}}
data := getSuccessData(t, svc, request)
assert(t, data.Message, "Merge requests fetched for hcramer")
assert(t, data.Status, http.StatusOK)
})
t.Run("Should handle no merge requests", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload)
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{emptyResponse: true}}
data := getFailData(t, svc, request)
assert(t, data.Message, "No MRs found")
assert(t, data.Details, "hcramer did not have any MRs")
assert(t, data.Status, http.StatusNotFound)
})
t.Run("Should require username", func(t *testing.T) {
missingUsernamePayload := testListMrsByUsernamePayload
missingUsernamePayload.Username = ""
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", missingUsernamePayload)
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}}
data := getFailData(t, svc, request)
assert(t, data.Message, "username is required")
assert(t, data.Details, "username is a required payload field")
assert(t, data.Status, http.StatusBadRequest)
})
t.Run("Should require User ID for assignee call", func(t *testing.T) {
missingUsernamePayload := testListMrsByUsernamePayload
missingUsernamePayload.UserId = 0
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", missingUsernamePayload)
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}}
data := getFailData(t, svc, request)
assert(t, data.Message, "user_id is required")
assert(t, data.Details, "user_id is a required payload field")
assert(t, data.Status, http.StatusBadRequest)
})
t.Run("Should handle error from Gitlab", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload)
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{testBase: testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
assert(t, data.Message, "An error occurred")
assert(t, data.Details, strings.Repeat("Some error from Gitlab; ", 3))
assert(t, data.Status, http.StatusInternalServerError)
})
t.Run("Handles non-200 from Gitlab", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload)
svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{testBase: testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
assert(t, data.Message, "An error occurred")
assert(t, data.Details, strings.Repeat("An error occurred on the /merge_requests_by_username endpoint; ", 3))
assert(t, data.Status, http.StatusInternalServerError)
})
}

View File

@@ -0,0 +1,58 @@
package app
import (
"net/http"
"testing"
"github.com/xanzy/go-gitlab"
)
type fakeMergeRequestLister struct {
testBase
emptyResponse bool
}
func (f fakeMergeRequestLister) ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
if f.emptyResponse {
return []*gitlab.MergeRequest{}, resp, err
}
return []*gitlab.MergeRequest{{IID: 10}}, resp, err
}
func TestMergeRequestHandler(t *testing.T) {
var testListMergeRequestsRequest = gitlab.ListProjectMergeRequestsOptions{}
t.Run("Should fetch merge requests", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest)
svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{}}
data := getSuccessData(t, svc, request)
assert(t, data.Status, http.StatusOK)
assert(t, data.Message, "Merge requests fetched successfully")
})
t.Run("Handles error from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest)
svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{testBase: testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Failed to list merge requests")
assert(t, data.Status, http.StatusInternalServerError)
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest)
svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{testBase: testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
checkNon200(t, data, "Failed to list merge requests", "/merge_requests")
assert(t, data.Status, http.StatusSeeOther)
})
t.Run("Should handle not having any merge requests with 404", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest)
svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{emptyResponse: true}}
data := getFailData(t, svc, request)
assert(t, data.Message, "No merge requests found")
assert(t, data.Status, http.StatusNotFound)
})
}

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

@@ -0,0 +1,173 @@
package app
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/harrisoncramer/gitlab.nvim/cmd/app/git"
"github.com/xanzy/go-gitlab"
)
type RetriggerPipelineResponse struct {
SuccessResponse
LatestPipeline *gitlab.Pipeline `json:"latest_pipeline"`
}
type PipelineWithJobs struct {
Jobs []*gitlab.Job `json:"jobs"`
LatestPipeline *gitlab.PipelineInfo `json:"latest_pipeline"`
}
type GetPipelineAndJobsResponse struct {
SuccessResponse
Pipeline PipelineWithJobs `json:"latest_pipeline"`
}
type PipelineManager interface {
ListProjectPipelines(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error)
ListPipelineJobs(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error)
RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error)
}
type pipelineService struct {
data
client PipelineManager
gitService git.GitManager
}
/*
pipelineHandler fetches information about the current pipeline, and retriggers a pipeline run. For more detailed information
about a given job in a pipeline, see the jobHandler function
*/
func (a pipelineService) handler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
a.GetPipelineAndJobs(w, r)
case http.MethodPost:
a.RetriggerPipeline(w, r)
default:
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s", http.MethodGet, http.MethodPost))
handleError(w, InvalidRequestError{}, "Expected GET or POST", http.StatusMethodNotAllowed)
}
}
/* Gets the latest pipeline for a given commit, returns an error if there is no pipeline */
func (a pipelineService) GetLastPipeline(commit string) (*gitlab.PipelineInfo, error) {
l := &gitlab.ListProjectPipelinesOptions{
SHA: gitlab.Ptr(commit),
Sort: gitlab.Ptr("desc"),
}
pipes, res, err := a.client.ListProjectPipelines(a.projectInfo.ProjectId, l)
if err != nil {
return nil, err
}
if res.StatusCode >= 300 {
return nil, errors.New("Could not get pipelines")
}
if len(pipes) == 0 {
return nil, errors.New("No pipeline running or available for commit " + commit)
}
return pipes[0], nil
}
/* Gets the latest pipeline and job information for the current branch */
func (a pipelineService) GetPipelineAndJobs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
commit, err := a.gitService.GetLatestCommitOnRemote(pluginOptions.ConnectionSettings.Remote, a.gitInfo.BranchName)
if err != nil {
handleError(w, err, "Error getting commit on remote branch", http.StatusInternalServerError)
return
}
pipeline, err := a.GetLastPipeline(commit)
if err != nil {
handleError(w, err, fmt.Sprintf("Failed to get latest pipeline for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError)
return
}
if pipeline == nil {
handleError(w, GenericError{endpoint: "/pipeline"}, fmt.Sprintf("No pipeline found for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError)
return
}
jobs, res, err := a.client.ListPipelineJobs(a.projectInfo.ProjectId, pipeline.ID, &gitlab.ListJobsOptions{})
if err != nil {
handleError(w, err, "Could not get pipeline jobs", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/pipeline"}, "Could not get pipeline jobs", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := GetPipelineAndJobsResponse{
SuccessResponse: SuccessResponse{
Status: http.StatusOK,
Message: "Pipeline retrieved",
},
Pipeline: PipelineWithJobs{
LatestPipeline: pipeline,
Jobs: jobs,
},
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}
func (a pipelineService) RetriggerPipeline(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
id := strings.TrimPrefix(r.URL.Path, "/pipeline/trigger/")
idInt, err := strconv.Atoi(id)
if err != nil {
handleError(w, err, "Could not convert pipeline ID to integer", http.StatusBadRequest)
return
}
pipeline, res, err := a.client.RetryPipelineBuild(a.projectInfo.ProjectId, idInt)
if err != nil {
handleError(w, err, "Could not retrigger pipeline", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/pipeline"}, "Could not retrigger pipeline", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := RetriggerPipelineResponse{
SuccessResponse: SuccessResponse{
Message: "Pipeline retriggered",
Status: http.StatusOK,
},
LatestPipeline: pipeline,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

86
cmd/app/pipeline_test.go Normal file
View File

@@ -0,0 +1,86 @@
package app
import (
"net/http"
"testing"
"github.com/xanzy/go-gitlab"
)
type fakePipelineManager struct {
testBase
}
func (f fakePipelineManager) ListProjectPipelines(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
return []*gitlab.PipelineInfo{{ID: 1234}}, resp, err
}
func (f fakePipelineManager) ListPipelineJobs(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
return []*gitlab.Job{}, resp, err
}
func (f fakePipelineManager) RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
return &gitlab.Pipeline{}, resp, err
}
func TestPipelineGetter(t *testing.T) {
t.Run("Gets all pipeline jobs", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/pipeline", nil)
svc := pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}}
data := getSuccessData(t, svc, request)
assert(t, data.Message, "Pipeline retrieved")
assert(t, data.Status, http.StatusOK)
})
t.Run("Disallows non-GET, non-POST methods", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/pipeline", nil)
svc := pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}}
data := getFailData(t, svc, request)
checkBadMethod(t, data, http.MethodGet, http.MethodPost)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/pipeline", nil)
svc := pipelineService{testProjectData, fakePipelineManager{testBase{errFromGitlab: true}}, FakeGitManager{}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Failed to get latest pipeline for some-branch branch")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/pipeline", nil)
svc := pipelineService{testProjectData, fakePipelineManager{testBase: testBase{status: http.StatusSeeOther}}, FakeGitManager{}}
data := getFailData(t, svc, request)
assert(t, data.Message, "Failed to get latest pipeline for some-branch branch") // Expected, we treat this as an error
})
}
func TestPipelineTrigger(t *testing.T) {
t.Run("Retriggers pipeline", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil)
svc := pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}}
data := getSuccessData(t, svc, request)
assert(t, data.Message, "Pipeline retriggered")
assert(t, data.Status, http.StatusOK)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil)
svc := pipelineService{testProjectData, fakePipelineManager{testBase{errFromGitlab: true}}, FakeGitManager{}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not retrigger pipeline")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil)
svc := pipelineService{testProjectData, fakePipelineManager{testBase: testBase{status: http.StatusSeeOther}}, FakeGitManager{}}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not retrigger pipeline", "/pipeline")
})
}

87
cmd/app/reply.go Normal file
View File

@@ -0,0 +1,87 @@
package app
import (
"encoding/json"
"io"
"net/http"
"time"
"github.com/xanzy/go-gitlab"
)
type ReplyRequest struct {
DiscussionId string `json:"discussion_id"`
Reply string `json:"reply"`
IsDraft bool `json:"is_draft"`
}
type ReplyResponse struct {
SuccessResponse
Note *gitlab.Note `json:"note"`
}
type ReplyManager interface {
AddMergeRequestDiscussionNote(interface{}, int, string, *gitlab.AddMergeRequestDiscussionNoteOptions, ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error)
}
type replyService struct {
data
client ReplyManager
}
/* replyHandler sends a reply to a note or comment */
func (a replyService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPost {
w.Header().Set("Access-Control-Allow-Methods", http.MethodPost)
handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var replyRequest ReplyRequest
err = json.Unmarshal(body, &replyRequest)
if err != nil {
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
return
}
now := time.Now()
options := gitlab.AddMergeRequestDiscussionNoteOptions{
Body: gitlab.Ptr(replyRequest.Reply),
CreatedAt: &now,
}
note, res, err := a.client.AddMergeRequestDiscussionNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, replyRequest.DiscussionId, &options)
if err != nil {
handleError(w, err, "Could not leave reply", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/reply"}, "Could not leave reply", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := ReplyResponse{
SuccessResponse: SuccessResponse{
Message: "Replied to comment",
Status: http.StatusOK,
},
Note: note,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

45
cmd/app/reply_test.go Normal file
View File

@@ -0,0 +1,45 @@
package app
import (
"net/http"
"testing"
"github.com/xanzy/go-gitlab"
)
type fakeReplyManager struct {
testBase
}
func (f fakeReplyManager) AddMergeRequestDiscussionNote(interface{}, int, string, *gitlab.AddMergeRequestDiscussionNoteOptions, ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) {
resp, err := f.handleGitlabError()
if err != nil {
return nil, nil, err
}
return &gitlab.Note{}, resp, err
}
func TestReplyHandler(t *testing.T) {
var testReplyRequest = ReplyRequest{DiscussionId: "abc123", Reply: "Some Reply", IsDraft: false}
t.Run("Sends a reply", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest)
svc := replyService{testProjectData, fakeReplyManager{}}
data := getSuccessData(t, svc, request)
assert(t, data.Message, "Replied to comment")
assert(t, data.Status, http.StatusOK)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest)
svc := replyService{testProjectData, fakeReplyManager{testBase{errFromGitlab: true}}}
data := getFailData(t, svc, request)
checkErrorFromGitlab(t, data, "Could not leave reply")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest)
svc := replyService{testProjectData, fakeReplyManager{testBase{status: http.StatusSeeOther}}}
data := getFailData(t, svc, request)
checkNon200(t, data, "Could not leave reply", "/mr/reply")
})
}

View File

@@ -0,0 +1,83 @@
package app
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/xanzy/go-gitlab"
)
type DiscussionResolveRequest struct {
DiscussionID string `json:"discussion_id"`
Resolved bool `json:"resolved"`
}
type DiscussionResolver interface {
ResolveMergeRequestDiscussion(pid interface{}, mergeRequest int, discussion string, opt *gitlab.ResolveMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error)
}
type discussionsResolutionService struct {
data
client DiscussionResolver
}
/* discussionsResolveHandler sets a discussion to be "resolved" or not resolved, depending on the payload */
func (a discussionsResolutionService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPut {
w.Header().Set("Access-Control-Allow-Methods", http.MethodPut)
handleError(w, InvalidRequestError{}, "Expected PUT", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var resolveDiscussionRequest DiscussionResolveRequest
err = json.Unmarshal(body, &resolveDiscussionRequest)
if err != nil {
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
return
}
_, res, err := a.client.ResolveMergeRequestDiscussion(
a.projectInfo.ProjectId,
a.projectInfo.MergeId,
resolveDiscussionRequest.DiscussionID,
&gitlab.ResolveMergeRequestDiscussionOptions{Resolved: &resolveDiscussionRequest.Resolved},
)
friendlyName := "unresolve"
if resolveDiscussionRequest.Resolved {
friendlyName = "resolve"
}
if err != nil {
handleError(w, err, fmt.Sprintf("Could not %s discussion", friendlyName), http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/discussions/resolve"}, fmt.Sprintf("Could not %s discussion", friendlyName), res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := SuccessResponse{
Message: fmt.Sprintf("Discussion %sd", friendlyName),
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

30
cmd/app/response_types.go Normal file
View File

@@ -0,0 +1,30 @@
package app
import (
"fmt"
)
type ErrorResponse struct {
Message string `json:"message"`
Details string `json:"details"`
Status int `json:"status"`
}
type SuccessResponse struct {
Message string `json:"message"`
Status int `json:"status"`
}
type GenericError struct {
endpoint string
}
func (e GenericError) Error() string {
return fmt.Sprintf("An error occurred on the %s endpoint", e.endpoint)
}
type InvalidRequestError struct{}
func (e InvalidRequestError) Error() string {
return "Invalid request type"
}

85
cmd/app/reviewer.go Normal file
View File

@@ -0,0 +1,85 @@
package app
import (
"encoding/json"
"io"
"net/http"
"github.com/xanzy/go-gitlab"
)
type ReviewerUpdateRequest struct {
Ids []int `json:"ids"`
}
type ReviewerUpdateResponse struct {
SuccessResponse
Reviewers []*gitlab.BasicUser `json:"reviewers"`
}
type ReviewersRequestResponse struct {
SuccessResponse
Reviewers []int `json:"reviewers"`
}
type MergeRequestUpdater interface {
UpdateMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
}
type reviewerService struct {
data
client MergeRequestUpdater
}
/* reviewersHandler adds or removes reviewers from an MR */
func (a reviewerService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPut {
w.Header().Set("Access-Control-Allow-Methods", http.MethodPut)
handleError(w, InvalidRequestError{}, "Expected PUT", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var reviewerUpdateRequest ReviewerUpdateRequest
err = json.Unmarshal(body, &reviewerUpdateRequest)
if err != nil {
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
return
}
mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{
ReviewerIDs: &reviewerUpdateRequest.Ids,
})
if err != nil {
handleError(w, err, "Could not modify merge request reviewers", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/reviewer"}, "Could not modify merge request reviewers", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := ReviewerUpdateResponse{
SuccessResponse: SuccessResponse{
Message: "Reviewers updated",
Status: http.StatusOK,
},
Reviewers: mr.Reviewers,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

61
cmd/app/revisions.go Normal file
View File

@@ -0,0 +1,61 @@
package app
import (
"encoding/json"
"net/http"
"github.com/xanzy/go-gitlab"
)
type RevisionsResponse struct {
SuccessResponse
Revisions []*gitlab.MergeRequestDiffVersion
}
type RevisionsGetter interface {
GetMergeRequestDiffVersions(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error)
}
type revisionsService struct {
data
client RevisionsGetter
}
/*
revisionsHandler gets revision information about the current MR. This data is not used directly but is
a precursor API call for other functionality
*/
func (a revisionsService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodGet {
w.Header().Set("Access-Control-Allow-Methods", http.MethodGet)
handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed)
return
}
versionInfo, res, err := a.client.GetMergeRequestDiffVersions(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.GetMergeRequestDiffVersionsOptions{})
if err != nil {
handleError(w, err, "Could not get diff version info", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/revisions"}, "Could not get diff version info", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := RevisionsResponse{
SuccessResponse: SuccessResponse{
Message: "Revisions fetched successfully",
Status: http.StatusOK,
},
Revisions: versionInfo,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

50
cmd/app/revoke.go Normal file
View File

@@ -0,0 +1,50 @@
package app
import (
"encoding/json"
"net/http"
"github.com/xanzy/go-gitlab"
)
type MergeRequestRevoker interface {
UnapproveMergeRequest(interface{}, int, ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
}
type mergeRequestRevokerService struct {
data
client MergeRequestRevoker
}
/* revokeHandler revokes approval for the current merge request */
func (a mergeRequestRevokerService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPost {
w.Header().Set("Access-Control-Allow-Methods", http.MethodPost)
handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed)
return
}
res, err := a.client.UnapproveMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, nil, nil)
if err != nil {
handleError(w, err, "Could not revoke approval", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/revoke"}, "Could not revoke approval", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := SuccessResponse{
Message: "Success! Revoked MR approval",
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

199
cmd/app/server.go Normal file
View File

@@ -0,0 +1,199 @@
package app
import (
"errors"
"fmt"
"net"
"net/http"
"os"
"time"
"github.com/harrisoncramer/gitlab.nvim/cmd/app/git"
"github.com/xanzy/go-gitlab"
)
/*
startSever starts the server and runs concurrent goroutines
to handle potential shutdown requests and incoming HTTP requests.
*/
func StartServer(client *Client, projectInfo *ProjectInfo, GitInfo git.GitData) {
s := shutdown{
sigCh: make(chan os.Signal, 1),
}
fr := attachmentReader{}
r := CreateRouter(
client,
projectInfo,
s,
func(a *data) error { a.projectInfo = projectInfo; return nil },
func(a *data) error { a.gitInfo = &GitInfo; return nil },
func(a *data) error { err := attachEmojis(a, fr); return err },
)
l := createListener()
server := &http.Server{Handler: r}
/* Starts the Go server */
go func() {
err := server.Serve(l)
if err != nil {
if errors.Is(err, http.ErrServerClosed) {
os.Exit(0)
} else {
fmt.Fprintf(os.Stderr, "Server did not respond: %s\n", err)
os.Exit(1)
}
}
}()
port := l.Addr().(*net.TCPAddr).Port
err := checkServer(port)
if err != nil {
fmt.Fprintf(os.Stderr, "Server did not respond: %s\n", err)
os.Exit(1)
}
/* This print is detected by the Lua code */
fmt.Println("Server started on port: ", port)
/* Handles shutdown requests */
s.WatchForShutdown(server)
}
/*
CreateRouterAndApi wires up the router and attaches all handlers to their respective routes. It also
iterates over all option functions to configure API fields such as the project information and default
file reader functionality
*/
type data struct {
projectInfo *ProjectInfo
gitInfo *git.GitData
emojiMap EmojiMap
}
type optFunc func(a *data) error
func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s ShutdownHandler, optFuncs ...optFunc) *http.ServeMux {
m := http.NewServeMux()
d := data{
projectInfo: &ProjectInfo{},
gitInfo: &git.GitData{},
}
/* Mutates the API struct as necessary with configuration functions */
for _, optFunc := range optFuncs {
err := optFunc(&d)
if err != nil {
panic(err)
}
}
m.HandleFunc("/mr/approve", withMr(mergeRequestApproverService{d, gitlabClient}, d, gitlabClient))
m.HandleFunc("/mr/comment", withMr(commentService{d, gitlabClient}, d, gitlabClient))
m.HandleFunc("/mr/merge", withMr(mergeRequestAccepterService{d, gitlabClient}, d, gitlabClient))
m.HandleFunc("/mr/discussions/list", withMr(discussionsListerService{d, gitlabClient}, d, gitlabClient))
m.HandleFunc("/mr/discussions/resolve", withMr(discussionsResolutionService{d, gitlabClient}, d, gitlabClient))
m.HandleFunc("/mr/info", withMr(infoService{d, gitlabClient}, d, gitlabClient))
m.HandleFunc("/mr/assignee", withMr(assigneesService{d, gitlabClient}, d, gitlabClient))
m.HandleFunc("/mr/summary", withMr(summaryService{d, gitlabClient}, d, gitlabClient))
m.HandleFunc("/mr/reviewer", withMr(reviewerService{d, gitlabClient}, d, gitlabClient))
m.HandleFunc("/mr/revisions", withMr(revisionsService{d, gitlabClient}, d, gitlabClient))
m.HandleFunc("/mr/reply", withMr(replyService{d, gitlabClient}, d, gitlabClient))
m.HandleFunc("/mr/label", withMr(labelService{d, gitlabClient}, d, gitlabClient))
m.HandleFunc("/mr/revoke", withMr(mergeRequestRevokerService{d, gitlabClient}, d, gitlabClient))
m.HandleFunc("/mr/awardable/note/", withMr(emojiService{d, gitlabClient}, d, gitlabClient))
m.HandleFunc("/mr/draft_notes/", withMr(draftNoteService{d, gitlabClient}, d, gitlabClient))
m.HandleFunc("/mr/draft_notes/publish", withMr(draftNotePublisherService{d, gitlabClient}, d, gitlabClient))
m.HandleFunc("/pipeline", pipelineService{d, gitlabClient, git.Git{}}.handler)
m.HandleFunc("/pipeline/trigger/", pipelineService{d, gitlabClient, git.Git{}}.handler)
m.HandleFunc("/users/me", meService{d, gitlabClient}.handler)
m.HandleFunc("/attachment", attachmentService{data: d, client: gitlabClient, fileReader: attachmentReader{}}.handler)
m.HandleFunc("/create_mr", mergeRequestCreatorService{d, gitlabClient}.handler)
m.HandleFunc("/job", traceFileService{d, gitlabClient}.handler)
m.HandleFunc("/project/members", projectMemberService{d, gitlabClient}.handler)
m.HandleFunc("/merge_requests", mergeRequestListerService{d, gitlabClient}.handler)
m.HandleFunc("/merge_requests_by_username", mergeRequestListerByUsernameService{d, gitlabClient}.handler)
m.HandleFunc("/shutdown", s.shutdownHandler)
m.Handle("/ping", http.HandlerFunc(pingHandler))
return m
}
/* Used to check whether the server has started yet */
func pingHandler(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "pong")
}
/* checkServer pings the server repeatedly for 1 full second after startup in order to notify the plugin that the server is ready */
func checkServer(port int) error {
for i := 0; i < 10; i++ {
resp, err := http.Get("http://localhost:" + fmt.Sprintf("%d", port) + "/ping")
if resp.StatusCode == 200 && err == nil {
return nil
}
time.Sleep(100 * time.Microsecond)
}
return errors.New("Could not start server!")
}
/* Creates a TCP listener on the port specified by the user or a random port */
func createListener() (l net.Listener) {
addr := fmt.Sprintf("localhost:%d", pluginOptions.Port)
l, err := net.Listen("tcp", addr)
if err != nil {
fmt.Fprintf(os.Stderr, "Error starting server: %s\n", err)
os.Exit(1)
}
return l
}
type ServiceWithHandler interface {
handler(http.ResponseWriter, *http.Request)
}
/* withMr is a Middlware that gets the current merge request ID and attaches it to the projectInfo */
func withMr(svc ServiceWithHandler, c data, client MergeRequestLister) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// If the merge request is already attached, skip the middleware logic
if c.projectInfo.MergeId == 0 {
options := gitlab.ListProjectMergeRequestsOptions{
Scope: gitlab.Ptr("all"),
SourceBranch: &c.gitInfo.BranchName,
TargetBranch: pluginOptions.ChosenTargetBranch,
}
mergeRequests, _, err := client.ListProjectMergeRequests(c.projectInfo.ProjectId, &options)
if err != nil {
handleError(w, fmt.Errorf("Failed to list merge requests: %w", err), "Failed to list merge requests", http.StatusInternalServerError)
return
}
if len(mergeRequests) == 0 {
err := fmt.Errorf("No merge requests found for branch '%s'", c.gitInfo.BranchName)
handleError(w, err, "No merge requests found", http.StatusBadRequest)
return
}
if len(mergeRequests) > 1 {
err := errors.New("Please call gitlab.choose_merge_request()")
handleError(w, err, "Multiple MRs found", http.StatusBadRequest)
return
}
mergeIdInt := mergeRequests[0].IID
c.projectInfo.MergeId = mergeIdInt
}
// Call the next handler if middleware succeeds
svc.handler(w, r)
}
}

83
cmd/app/shutdown.go Normal file
View File

@@ -0,0 +1,83 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
)
type killer struct{}
func (k killer) Signal() {}
func (k killer) String() string {
return "0"
}
type ShutdownHandler interface {
WatchForShutdown(server *http.Server)
shutdownHandler(w http.ResponseWriter, r *http.Request)
}
type shutdown struct {
sigCh chan os.Signal
}
func (s shutdown) WatchForShutdown(server *http.Server) {
/* Handles shutdown requests */
<-s.sigCh
err := server.Shutdown(context.Background())
if err != nil {
fmt.Fprintf(os.Stderr, "Server could not shut down gracefully: %s\n", err)
os.Exit(1)
} else {
os.Exit(0)
}
}
type ShutdownRequest struct {
Restart bool `json:"restart"`
}
/* shutdownHandler will shutdown the HTTP server and exit the process by signaling to the shutdown channel */
func (s shutdown) shutdownHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
var shutdownRequest ShutdownRequest
err = json.Unmarshal(body, &shutdownRequest)
if err != nil {
handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest)
return
}
var text = "Shut down server"
if shutdownRequest.Restart {
text = "Restarted server"
}
w.WriteHeader(http.StatusOK)
response := SuccessResponse{
Message: text,
Status: http.StatusOK,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
} else {
s.sigCh <- killer{}
}
}

80
cmd/app/summary.go Normal file
View File

@@ -0,0 +1,80 @@
package app
import (
"encoding/json"
"io"
"net/http"
"github.com/xanzy/go-gitlab"
)
type SummaryUpdateRequest struct {
Description string `json:"description"`
Title string `json:"title"`
}
type SummaryUpdateResponse struct {
SuccessResponse
MergeRequest *gitlab.MergeRequest `json:"mr"`
}
type summaryService struct {
data
client MergeRequestUpdater
}
func (a summaryService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPut {
w.Header().Set("Access-Control-Allow-Methods", http.MethodPut)
handleError(w, InvalidRequestError{}, "Expected PUT", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
handleError(w, err, "Could not read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var SummaryUpdateRequest SummaryUpdateRequest
err = json.Unmarshal(body, &SummaryUpdateRequest)
if err != nil {
handleError(w, err, "Could not read JSON from request", http.StatusBadRequest)
return
}
mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{
Description: &SummaryUpdateRequest.Description,
Title: &SummaryUpdateRequest.Title,
})
if err != nil {
handleError(w, err, "Could not edit merge request summary", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/summary"}, "Could not edit merge request summary", res.StatusCode)
return
}
w.WriteHeader(http.StatusOK)
response := SummaryUpdateResponse{
SuccessResponse: SuccessResponse{
Message: "Summary updated",
Status: http.StatusOK,
},
MergeRequest: mr,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

149
cmd/app/test_helpers.go Normal file
View File

@@ -0,0 +1,149 @@
package app
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/harrisoncramer/gitlab.nvim/cmd/app/git"
"github.com/xanzy/go-gitlab"
)
var errorFromGitlab = errors.New("Some error from Gitlab")
/* The assert function is a helper function used to check two comparables */
func assert[T comparable](t *testing.T, got T, want T) {
t.Helper()
if got != want {
t.Errorf("Got '%v' but wanted '%v'", got, want)
}
}
/* Will create a new request with the given method, endpoint and body */
func makeRequest(t *testing.T, method string, endpoint string, body any) *http.Request {
t.Helper()
var reader io.Reader
if body != nil {
j, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(j)
}
request, err := http.NewRequest(method, endpoint, reader)
if err != nil {
t.Fatal(err)
}
return request
}
/* Make response makes a simple response value with the right status code */
func makeResponse(status int) *gitlab.Response {
return &gitlab.Response{
Response: &http.Response{
StatusCode: status,
Body: http.NoBody,
},
}
}
var testProjectData = data{
projectInfo: &ProjectInfo{},
gitInfo: &git.GitData{
BranchName: "some-branch",
},
}
func getSuccessData(t *testing.T, svc ServiceWithHandler, request *http.Request) SuccessResponse {
res := httptest.NewRecorder()
svc.handler(res, request)
var data SuccessResponse
err := json.Unmarshal(res.Body.Bytes(), &data)
if err != nil {
t.Error(err)
}
return data
}
func getFailData(t *testing.T, svc ServiceWithHandler, request *http.Request) ErrorResponse {
res := httptest.NewRecorder()
svc.handler(res, request)
var data ErrorResponse
err := json.Unmarshal(res.Body.Bytes(), &data)
if err != nil {
t.Error(err)
}
return data
}
type testBase struct {
errFromGitlab bool
status int
}
// Helper for easily mocking bad responses or errors from Gitlab
func (f *testBase) handleGitlabError() (*gitlab.Response, error) {
if f.errFromGitlab {
return nil, errorFromGitlab
}
if f.status == 0 {
f.status = 200
}
return makeResponse(f.status), nil
}
func checkErrorFromGitlab(t *testing.T, data ErrorResponse, msg string) {
t.Helper()
assert(t, data.Status, http.StatusInternalServerError)
assert(t, data.Message, msg)
assert(t, data.Details, errorFromGitlab.Error())
}
func checkBadMethod(t *testing.T, data ErrorResponse, methods ...string) {
t.Helper()
assert(t, data.Status, http.StatusMethodNotAllowed)
assert(t, data.Details, "Invalid request type")
expectedMethods := strings.Join(methods, " or ")
assert(t, data.Message, fmt.Sprintf("Expected %s", expectedMethods))
}
func checkNon200(t *testing.T, data ErrorResponse, msg, endpoint string) {
t.Helper()
assert(t, data.Status, http.StatusSeeOther)
assert(t, data.Message, msg)
assert(t, data.Details, fmt.Sprintf("An error occurred on the %s endpoint", endpoint))
}
type FakeGitManager struct {
RemoteUrl string
BranchName string
ProjectName string
Namespace string
}
func (f FakeGitManager) RefreshProjectInfo(remote string) error {
return nil
}
func (f FakeGitManager) GetCurrentBranchNameFromNativeGitCmd() (string, error) {
return f.BranchName, nil
}
func (f FakeGitManager) GetLatestCommitOnRemote(remote string, branchName string) (string, error) {
return "", nil
}
func (f FakeGitManager) GetProjectUrlFromNativeGitCmd(string) (url string, err error) {
return f.RemoteUrl, nil
}

56
cmd/app/user.go Normal file
View File

@@ -0,0 +1,56 @@
package app
import (
"encoding/json"
"net/http"
"github.com/xanzy/go-gitlab"
)
type UserResponse struct {
SuccessResponse
User *gitlab.User `json:"user"`
}
type MeGetter interface {
CurrentUser(options ...gitlab.RequestOptionFunc) (*gitlab.User, *gitlab.Response, error)
}
type meService struct {
data
client MeGetter
}
func (a meService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodGet {
w.Header().Set("Access-Control-Allow-Methods", http.MethodGet)
handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed)
return
}
user, res, err := a.client.CurrentUser()
if err != nil {
handleError(w, err, "Failed to get current user", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, err, "User API returned non-200 status", res.StatusCode)
return
}
response := UserResponse{
SuccessResponse: SuccessResponse{
Message: "User fetched successfully",
Status: http.StatusOK,
},
User: user,
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}